Compare commits

..

33 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
syntaxbullet
740e302c58 fix: update sponsoring section height for better responsiveness on larger screens 2026-07-13 22:55:41 +02:00
syntaxbullet
e28666fc47 feat: clean up Preistraeger detail structure by removing unused fields and updating related migrations 2026-07-13 22:46:35 +02:00
syntaxbullet
50b1cb6e2e fix: remove inline maxWidth style from About and Netzwerk components for improved layout consistency 2026-07-13 22:19:27 +02:00
syntaxbullet
b57e08940d Refactor typography styles across multiple pages to use CSS classes for consistency and maintainability
- Updated Participation.tsx to replace inline styles with class names for headings and paragraphs.
- Refactored Preistraeger.tsx to utilize CSS classes for typography instead of inline styles.
- Modified PreistraegerDetail.tsx to implement class-based styling for headings and text elements.
- Adjusted Press.tsx to standardize typography using CSS classes, enhancing readability and maintainability.
2026-07-13 21:46:56 +02:00
syntaxbullet
ae76a32e99 feat: update EventDetail styles and remove subscription functionality 2026-07-13 20:54:05 +02:00
syntaxbullet
63387ce335 feat: implement jury member selection and sorting functionality, update related database migrations 2026-07-13 20:49:06 +02:00
syntaxbullet
98af6423c5 feat: add anchor IDs and scroll margin for award cards and sponsoring form 2026-07-13 20:27:37 +02:00
syntaxbullet
b372b23b0c feat: implement contact form submission and database integration
- Added a new collection for contact submissions with validation and error handling.
- Created a migration script to set up the contact submissions table in the database.
- Updated the SponsoringForm component to handle newsletter subscriptions based on application phase.
- Refactored the UI components for improved user experience and error messaging.
- Removed the package selection step from the sponsoring form.
- Updated the styling across various pages to maintain consistency with the new design.
2026-07-13 19:56:36 +02:00
syntaxbullet
308d75c44a feat: add participation and network support pages, update about fields and home winners section
- Added support for '/teilnahme' and '/netzwerk' in findSupportPages function.
- Updated the description in aboutFields to clarify the relationship with the homepage Preisträger grid.
- Enhanced the About page by integrating HomeWinnersSection for better modularity.
- Created HomeWinnersSection component to encapsulate winners display logic and improve code organization.
- Removed unused WinnerCardData type and related logic from Home component.
2026-07-13 19:18:12 +02:00
syntaxbullet
234da4cdac feat: update WegZurAuszeichnungSection styles for improved mobile experience and enhance process timeline scrolling behavior 2026-07-13 18:23:19 +02:00
syntaxbullet
9ebe544987 feat: add application form configuration and migration for shared application form data
- Introduced a new configuration file for the application form with various fields and validation messages.
- Created a migration script to set up the database schema for the application form, including necessary tables and default values.
- Added a migration for supporting awards copy to enhance the pages with additional columns.
- Implemented a new SPA component for the application form section, integrating form logic and UI elements.
2026-07-13 16:07:58 +02:00
syntaxbullet
ee7e891a7d feat: implement home winners selection logic with fallback handling and add integration tests 2026-07-13 15:19:37 +02:00
syntaxbullet
b7c499dd35 refactor: update awards grid section and participation page styles for consistency 2026-07-13 15:02:37 +02:00
syntaxbullet
b4be98fee0 feat: enhance Preistraeger and PreistraegerDetail components with optional year handling and archive support 2026-07-13 14:11:27 +02:00
114 changed files with 8331 additions and 3449 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

@@ -0,0 +1,205 @@
import type { Field, GlobalConfig } from 'payload'
import { authenticated } from '@/access/authenticated'
import { homeContent } from '@/spa/homeContent'
const text = (name: string, label: string, defaultValue?: string): Field => ({
name,
type: 'text',
label,
defaultValue,
})
const textarea = (name: string, label: string, defaultValue?: string): Field => ({
name,
type: 'textarea',
label,
defaultValue,
})
const iconOptions = [
{ label: 'Alert circle', value: 'alertCircle' },
{ label: 'Arrow right', value: 'arrowRight' },
{ label: 'Building', value: 'building2' },
{ label: 'Check', value: 'check' },
{ label: 'Send', value: 'send' },
{ label: 'Trophy', value: 'trophy' },
{ label: 'User', value: 'user' },
]
const iconField = (): Field => ({
name: 'icon',
label: 'Icon',
type: 'select',
defaultValue: 'trophy',
options: iconOptions,
})
const formFields: Field[] = [
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: 'application_form_self_steps',
defaultValue: homeContent.form.selfSteps,
fields: [text('label', 'Label'), iconField()],
},
{
name: 'nominationSteps',
label: 'Nomination step labels',
type: 'array',
dbName: 'application_form_nom_steps',
defaultValue: homeContent.form.nominationSteps,
fields: [text('label', 'Label'), iconField()],
},
{
name: 'employeeOptions',
label: 'Employee options',
type: 'array',
dbName: 'application_form_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)),
},
]
export const ApplicationForm: GlobalConfig = {
slug: 'application-form',
label: 'Bewerbungsformular',
access: {
read: () => true,
update: authenticated,
},
admin: {
group: 'Formulare',
},
fields: [
{
name: 'section',
label: 'Gemeinsamer Formularbereich',
type: 'group',
admin: {
description: 'Gemeinsamer Bereich für Startseite und Teilnahme-Seite.',
},
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',
dbName: 'application_form_benefits',
defaultValue: homeContent.application.benefits,
fields: [text('num', 'Number'), text('label', 'Label'), text('desc', 'Description')],
},
{
name: 'awardImage',
type: 'upload',
relationTo: 'media',
label: 'Award image',
admin: {
description: `Current frontend image: /images/${homeContent.application.awardImageFilename}`,
},
},
text('awardImageAlt', 'Award image alt text', homeContent.application.awardImageAlt),
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: 'Formularfelder und Logik',
type: 'group',
admin: {
description: 'Gemeinsame Texte, Optionen und Eignungsregeln des mehrstufigen Bewerbungsformulars.',
},
fields: formFields,
},
],
}

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

@@ -5,11 +5,13 @@ import { draftMode } from 'next/headers'
import { notFound } from 'next/navigation'
import NextApp from '@/spa/NextApp'
import { normalizeApplicationFormData, type SpaApplicationFormData } from '@/spa/applicationForm'
import { normalizeApplicationPhaseData, type SpaApplicationPhaseData } from '@/spa/applicationPhase'
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'
@@ -42,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: {
@@ -62,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' } : {}),
@@ -76,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: {
@@ -88,6 +87,9 @@ async function findSupportPages(payload: Awaited<ReturnType<typeof getPayload>>,
{ spaPath: { equals: '/netzwerk' } },
{ slug: { equals: 'netzwerk' } },
{ slug: { equals: 'network' } },
{ spaPath: { equals: '/teilnahme' } },
{ slug: { equals: 'teilnahme' } },
{ slug: { equals: 'participation' } },
],
},
})
@@ -104,6 +106,7 @@ export default async function Page({ params }: Args) {
header,
footer,
siteSettings,
applicationForm,
applicationPhase,
newsletterForm,
route,
@@ -117,6 +120,7 @@ export default async function Page({ params }: Args) {
payload.findGlobal({ slug: 'header', depth: 1 }).catch(() => undefined),
payload.findGlobal({ slug: 'footer', depth: 1 }).catch(() => undefined),
payload.findGlobal({ slug: 'site-settings', depth: 1 }).catch(() => undefined),
payload.findGlobal({ slug: 'application-form', depth: 1 }).catch(() => undefined),
payload.findGlobal({ slug: 'application-phase', depth: 0 }).catch(() => undefined),
payload.findGlobal({ slug: 'newsletter-form', depth: 0 }).catch(() => undefined),
findRouteDoc(payload, pathname, draft),
@@ -136,6 +140,7 @@ export default async function Page({ params }: Args) {
header={normalizeHeaderData(header)}
footer={normalizeFooterData(footer)}
siteSettings={normalizeSiteSettings(siteSettings as Partial<SpaSiteSettingsData> | undefined)}
applicationForm={normalizeApplicationFormData(applicationForm as SpaApplicationFormData | undefined)}
applicationPhase={normalizeApplicationPhaseData(applicationPhase as Partial<SpaApplicationPhaseData> | undefined)}
newsletterForm={normalizeNewsletterFormData(newsletterForm as Partial<SpaNewsletterFormData> | undefined)}
cmsRoute={{

View File

@@ -24,6 +24,17 @@
--color-input: var(--input);
--color-ring: var(--ring);
--font-size-eyebrow: var(--type-eyebrow);
--font-size-caption: var(--type-caption);
--font-size-small: var(--type-small);
--font-size-body: var(--type-step-0);
--font-size-lead: var(--type-step-1);
--font-size-card-title: var(--type-step-2);
--font-size-subtitle: var(--type-step-3);
--font-size-section-title: var(--type-step-4);
--font-size-page-title: var(--type-step-6);
--font-size-display: var(--type-step-7);
--radius-lg: 0.5rem;
--radius-md: calc(0.5rem - 2px);
--radius-sm: calc(0.5rem - 4px);
@@ -43,11 +54,45 @@
--muted: #f1f5f9;
--muted-foreground: #64748b;
--accent: #EFBF04;
--accent-foreground: #ffffff;
--accent-foreground: #101828;
--destructive: #ef4444;
--border: #e2e8f0;
--input: #e2e8f0;
--ring: #03093A;
/* Fluid type scale: 320px -> 1440px, 16px -> 18px body. */
--type-eyebrow: clamp(0.6875rem, 0.6696rem + 0.0893vw, 0.75rem);
--type-caption: clamp(0.75rem, 0.7143rem + 0.1786vw, 0.875rem);
--type-small: clamp(0.875rem, 0.8393rem + 0.1786vw, 1rem);
--type-step-0: clamp(1rem, 0.9643rem + 0.1786vw, 1.125rem);
--type-step-1: clamp(1.125rem, 1.0446rem + 0.4018vw, 1.4063rem);
--type-step-2: clamp(1.2656rem, 1.125rem + 0.7031vw, 1.7578rem);
--type-step-3: clamp(1.4238rem, 1.2028rem + 1.1049vw, 2.1973rem);
--type-step-4: clamp(1.6018rem, 1.2747rem + 1.6354vw, 2.7466rem);
--type-step-5: clamp(1.802rem, 1.336rem + 2.3303vw, 3.4332rem);
--type-step-6: clamp(2.0273rem, 1.3804rem + 3.2346vw, 4.2915rem);
--type-step-7: clamp(2.2807rem, 1.3996rem + 4.4053vw, 5.3644rem);
--leading-display: 0.98;
--leading-heading: 1.05;
--leading-body: 1.7;
--leading-small: 1.55;
/* Semantic text roles. Gold stays bright on dark surfaces and decorative uses. */
--text-heading: #101828;
--text-body: #344054;
--text-secondary: #475467;
--text-muted: #596579;
--text-link: #111D55;
--text-link-hover: #051A87;
--text-eyebrow-light: #285F8C;
--text-brand-on-light: #765600;
--text-brand-on-dark: #EFBF04;
--text-on-accent: #101828;
--text-inverse: #ffffff;
--text-inverse-secondary: rgba(255, 255, 255, 0.72);
--text-inverse-muted: rgba(255, 255, 255, 0.65);
--text-danger: #B91C1C;
}
.dark {
@@ -64,7 +109,7 @@
--muted: #1e293b;
--muted-foreground: #94a3b8;
--accent: #EFBF04;
--accent-foreground: #ffffff;
--accent-foreground: #101828;
--destructive: #7f1d1d;
--border: rgba(255, 255, 255, 0.1);
--input: rgba(255, 255, 255, 0.15);
@@ -77,6 +122,9 @@
}
body {
@apply bg-background text-foreground font-body;
color: var(--text-body);
font-size: var(--type-step-0);
line-height: var(--leading-body);
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
margin: 0;
@@ -88,6 +136,7 @@
}
h1, h2, h3, h4, h5, h6 {
@apply font-display font-bold;
color: var(--text-heading);
}
blockquote, q,
@@ -106,6 +155,99 @@
}
}
@layer components {
.type-display,
.type-page-title,
.type-section-title,
.type-subtitle,
.type-card-title,
.type-eyebrow {
font-family: var(--font-display);
font-weight: 700;
hyphens: auto;
overflow-wrap: anywhere;
}
.type-display {
font-size: var(--type-step-7);
line-height: var(--leading-display);
letter-spacing: -0.03em;
}
.type-page-title {
font-size: var(--type-step-6);
line-height: var(--leading-display);
letter-spacing: -0.03em;
}
.type-section-title {
font-size: var(--type-step-4);
line-height: var(--leading-heading);
letter-spacing: -0.025em;
}
.type-subtitle {
font-size: var(--type-step-3);
line-height: var(--leading-heading);
letter-spacing: -0.02em;
}
.type-card-title {
font-size: var(--type-step-2);
line-height: 1.1;
letter-spacing: -0.02em;
}
.type-lead,
.type-body,
.type-small,
.type-caption {
font-family: var(--font-body);
font-weight: 400;
overflow-wrap: anywhere;
}
.type-lead {
font-size: var(--type-step-1);
line-height: var(--leading-body);
}
.type-body {
font-size: var(--type-step-0);
line-height: var(--leading-body);
}
.type-small {
font-size: var(--type-small);
line-height: var(--leading-small);
}
.type-caption {
font-size: var(--type-caption);
line-height: var(--leading-small);
}
.type-eyebrow {
font-size: var(--type-eyebrow);
line-height: 1.3;
letter-spacing: 0.18em;
text-transform: uppercase;
}
.text-role-heading { color: var(--text-heading); }
.text-role-body { color: var(--text-body); }
.text-role-secondary { color: var(--text-secondary); }
.text-role-muted { color: var(--text-muted); }
.text-role-link { color: var(--text-link); }
.text-role-eyebrow { color: var(--text-eyebrow-light); }
.text-role-brand-light { color: var(--text-brand-on-light); }
.text-role-brand-dark { color: var(--text-brand-on-dark); }
.text-role-inverse { color: var(--text-inverse); }
.text-role-inverse-secondary { color: var(--text-inverse-secondary); }
.text-role-inverse-muted { color: var(--text-inverse-muted); }
.text-role-danger { color: var(--text-danger); }
}
@keyframes fade-in {
from {
opacity: 0;

View File

@@ -0,0 +1,125 @@
import type { CollectionConfig, PayloadRequest } from 'payload'
import { authenticated } from '@/access/authenticated'
const emailPattern = /^[^\s@]+@[^\s@]+\.[^\s@]+$/
type ContactSubmissionBody = {
subject?: unknown
name?: unknown
email?: unknown
message?: unknown
website?: unknown
}
const normalizedText = (value: unknown) => typeof value === 'string' ? value.trim() : ''
async function submitContactForm(req: PayloadRequest) {
let body: ContactSubmissionBody
try {
if (!req.json) throw new Error('Request body is unavailable.')
body = (await req.json()) as ContactSubmissionBody
} catch {
return Response.json({ success: false }, { status: 400 })
}
if (normalizedText(body.website)) return Response.json({ success: true })
const subject = normalizedText(body.subject)
const name = normalizedText(body.name)
const email = normalizedText(body.email).toLowerCase()
const message = normalizedText(body.message)
if (
!subject || subject.length > 120 ||
!name || name.length > 200 ||
!email || email.length > 254 || !emailPattern.test(email) ||
!message || message.length > 10_000
) {
return Response.json({ success: false }, { status: 400 })
}
try {
await req.payload.create({
collection: 'contact-submissions',
data: {
subject,
name,
email,
message,
submittedAt: new Date().toISOString(),
},
})
return Response.json({ success: true })
} catch (error) {
req.payload.logger.error({ err: error, msg: 'Contact submission could not be stored.' })
return Response.json({ success: false }, { status: 500 })
}
}
export const ContactSubmissions: CollectionConfig<'contact-submissions'> = {
slug: 'contact-submissions',
labels: {
singular: 'Kontaktanfrage',
plural: 'Kontaktanfragen',
},
access: {
create: authenticated,
delete: authenticated,
read: authenticated,
update: authenticated,
},
admin: {
defaultColumns: ['submittedAt', 'subject', 'name', 'email'],
group: 'Formulare',
useAsTitle: 'email',
},
endpoints: [
{
path: '/submit',
method: 'post',
handler: submitContactForm,
},
],
fields: [
{
name: 'subject',
label: 'Betreff',
type: 'text',
required: true,
},
{
name: 'name',
label: 'Name',
type: 'text',
required: true,
},
{
name: 'email',
label: 'E-Mail-Adresse',
type: 'email',
required: true,
index: true,
},
{
name: 'message',
label: 'Nachricht',
type: 'textarea',
required: true,
},
{
name: 'submittedAt',
label: 'Eingegangen am',
type: 'date',
required: true,
defaultValue: () => new Date().toISOString(),
index: true,
admin: {
date: { displayFormat: 'dd.MM.yyyy HH:mm' },
readOnly: true,
},
},
],
}

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

@@ -4,8 +4,8 @@ import { authenticated } from '@/access/authenticated'
import type { NewsletterPhase, NewsletterSource } from '@/spa/newsletterForm'
const emailPattern = /^[^\s@]+@[^\s@]+\.[^\s@]+$/
const sources = new Set<NewsletterSource>(['homepage', 'participation'])
const phases = new Set<NewsletterPhase>(['evaluation', 'completed'])
const sources = new Set<NewsletterSource>(['homepage', 'participation', 'network'])
const phases = new Set<NewsletterPhase>(['open', 'evaluation', 'completed'])
type SubscribeBody = {
email?: unknown
@@ -118,6 +118,7 @@ export const NewsletterSubscriptions: CollectionConfig<'newsletter-subscriptions
options: [
{ label: 'Startseite', value: 'homepage' },
{ label: 'Teilnahme-Seite', value: 'participation' },
{ label: 'Netzwerk / Sponsoring', value: 'network' },
],
},
{
@@ -126,6 +127,7 @@ export const NewsletterSubscriptions: CollectionConfig<'newsletter-subscriptions
type: 'select',
required: true,
options: [
{ label: 'Bewerbungsphase offen', value: 'open' },
{ label: 'Jury / Auswertung', value: 'evaluation' },
{ label: 'Preisverleihung abgeschlossen', value: 'completed' },
],

View File

@@ -47,7 +47,7 @@ export const aboutFields: Field[] = [
type: 'group',
admin: {
description:
'Edit the Der BMP page in the same order it appears on the frontend. The generic Hero and Content tabs are hidden for this SPA-managed route.',
'Edit the Der BMP page in the same order it appears on the frontend. The Preisträger grid reuses the homepage Preisträger card grid and is edited on the Startseite page. The generic Hero and Content tabs are hidden for this SPA-managed route.',
},
fields: [
{
@@ -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,49 +123,9 @@ export const aboutFields: Field[] = [
},
],
},
{
name: 'highlights',
label: '04 · Preisträger highlights',
type: 'group',
admin: sectionAdmin('Header copy and editor-selected winners for the photo grid.'),
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: [
@@ -196,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: [
@@ -216,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: [
@@ -234,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: [
@@ -253,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('Final application pitch, award image, embedded form heading, and footnote.'),
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('Text, options, and eligibility thresholds used inside the home page multi-step application form.'),
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

@@ -48,18 +48,9 @@ const personFields: Field[] = [
text('institution', 'Institution'),
]
const juryMemberFields: Field[] = [
text('name', 'Name'),
text('role', 'Role'),
textarea('bio', 'Bio'),
uploadField('imageUpload', 'Image'),
text('image', 'Image URL'),
]
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[] = [
@@ -109,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),
],
},
{
@@ -142,20 +151,21 @@ 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',
type: 'array',
dbName: 'network_jury',
defaultValue: netzwerkContent.jury.members,
fields: juryMemberFields,
type: 'relationship',
relationTo: 'jury-mitglieder',
hasMany: true,
admin: {
description: 'Choose the jury members rendered on this page. Members with a quote are displayed before members without one.',
},
},
],
},
@@ -189,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')],
},
{
@@ -220,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),
@@ -228,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',
@@ -246,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',
@@ -254,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),
@@ -281,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),
],
},
],
@@ -243,11 +225,13 @@ export const participationFields: Field[] = [
name: 'awardsGrid',
label: '06 · Awards grid',
type: 'group',
admin: sectionAdmin('Awards section rendered after the dates banner. On the participation page, Goldene Bavaria is featured above the remaining award cards.'),
admin: sectionAdmin('Awards section rendered after the dates banner. On the participation page, the CMS heading and intro belong to the standalone Goldene Bavaria section; the remaining award cards render in a separate section below.'),
fields: [
text('eyebrow', 'Eyebrow', participationContent.awardsGrid.eyebrow),
textarea('heading', 'Heading', participationContent.awardsGrid.heading),
textarea('description', 'Intro paragraph', participationContent.awardsGrid.description),
textarea('supportingHeading', 'Other prizes heading', participationContent.awardsGrid.supportingHeading),
textarea('supportingDescription', 'Other prizes subtext', participationContent.awardsGrid.supportingDescription),
{
name: 'cards',
label: 'Award cards',
@@ -266,149 +250,6 @@ export const participationFields: Field[] = [
},
],
},
{
name: 'applicationForm',
label: '07 · Application form wrapper',
type: 'group',
admin: sectionAdmin('Navy/gold section containing the phase-aware application form or newsletter prompt.'),
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('Text, options, and eligibility thresholds used inside the multi-step application form.'),
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,
@@ -68,6 +73,11 @@ export const Preistraeger: CollectionConfig = {
{
name: 'year',
type: 'number',
label: 'Jahr (optional)',
admin: {
description:
'Einträge von 2007 bis 2023 werden gemeinsam im Archiv-Reiter angezeigt. Wenn das genaue Gewinnjahr unbekannt ist, das Feld leer lassen; der Eintrag wird ebenfalls diesem Archiv zugeordnet.',
},
},
{
name: 'awardGroup',
@@ -77,7 +87,7 @@ export const Preistraeger: CollectionConfig = {
options: awardGroupOptions,
admin: {
description:
'Steuert die Ausgabe auf der Preisträger-Übersicht. Pro Jahrgang werden 15 Gewinner angezeigt, davon maximal 3 in der Goldene-Bavaria-Section.',
'Steuert die Ausgabe für einzelne Jahrgänge ab 2024. Im Archiv 2007–2023 werden alle Einträge als einfache Liste angezeigt.',
},
},
{
@@ -87,7 +97,7 @@ export const Preistraeger: CollectionConfig = {
min: 1,
admin: {
description:
'1 bis 3 sind die besten Gewinner und sollten als Goldene Bavaria markiert werden. Niedrigere Zahlen erscheinen zuerst.',
'Für einzelne Jahrgänge ab 2024: 1 bis 3 sind die besten Gewinner und sollten als Goldene Bavaria markiert werden. Niedrigere Zahlen erscheinen zuerst.',
},
},
{
@@ -96,7 +106,7 @@ export const Preistraeger: CollectionConfig = {
label: 'Sortierung',
defaultValue: 100,
admin: {
description: 'Fallback-Sortierung innerhalb eines Jahrgangs, wenn kein Rang gesetzt ist.',
description: 'Sortierung innerhalb eines Jahrgangs beziehungsweise innerhalb des Archivs 2007–2023.',
},
},
{
@@ -270,9 +280,6 @@ export const Preistraeger: CollectionConfig = {
{ name: 'category', type: 'text', defaultValue: preistraegerDetailContent.fallbackWinner.category },
{ name: 'awardType', type: 'text', defaultValue: preistraegerDetailContent.fallbackWinner.awardType },
{ name: 'longDesc', type: 'textarea', defaultValue: preistraegerDetailContent.fallbackWinner.longDesc },
{ name: 'quote', type: 'textarea', defaultValue: preistraegerDetailContent.fallbackWinner.quote },
{ name: 'quotePerson', type: 'text', defaultValue: preistraegerDetailContent.fallbackWinner.quotePerson },
{ name: 'quoteRole', type: 'text', defaultValue: preistraegerDetailContent.fallbackWinner.quoteRole },
{ name: 'location', type: 'text', defaultValue: preistraegerDetailContent.fallbackWinner.location },
{ name: 'industry', type: 'text', defaultValue: preistraegerDetailContent.fallbackWinner.industry },
],
@@ -317,19 +324,6 @@ export const Preistraeger: CollectionConfig = {
{ name: 'imageAlt', type: 'text', defaultValue: preistraegerDetailContent.sideImage.imageAlt },
],
},
{
name: 'facts',
label: 'Facts box labels',
type: 'group',
fields: [
{ name: 'heading', type: 'text', defaultValue: preistraegerDetailContent.facts.heading },
{ name: 'categoryLabel', type: 'text', defaultValue: preistraegerDetailContent.facts.categoryLabel },
{ name: 'awardLabel', type: 'text', defaultValue: preistraegerDetailContent.facts.awardLabel },
{ name: 'yearLabel', type: 'text', defaultValue: preistraegerDetailContent.facts.yearLabel },
{ name: 'locationLabel', type: 'text', defaultValue: preistraegerDetailContent.facts.locationLabel },
{ name: 'industryLabel', type: 'text', defaultValue: preistraegerDetailContent.facts.industryLabel },
],
},
{
name: 'websiteCta',
label: 'Website CTA',
@@ -383,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,191 @@
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 sectionScalars = [
['eyebrow', homeContent.application.eyebrow],
['heading', homeContent.application.heading],
['body', homeContent.application.body],
['awardImageAlt', homeContent.application.awardImageAlt],
['formEyebrow', homeContent.application.formEyebrow],
['formTitle', homeContent.application.formTitle],
['footnote', homeContent.application.footnote],
['footnoteStrong', homeContent.application.footnoteStrong],
] as const
const topLevelFormScalars = [
['stepCompleteLabel', homeContent.form.stepCompleteLabel],
['backLabel', homeContent.form.backLabel],
['nextLabel', homeContent.form.nextLabel],
['submitLabel', homeContent.form.submitLabel],
['yesLabel', homeContent.form.yesLabel],
['noLabel', homeContent.form.noLabel],
['requiredSuffix', homeContent.form.requiredSuffix],
] as const
const formGroups = {
validation: homeContent.form.validation,
typeStep: homeContent.form.typeStep,
eligibility: homeContent.form.eligibility,
selfContact: homeContent.form.selfContact,
selfSummary: homeContent.form.selfSummary,
nominationCompany: homeContent.form.nominationCompany,
nominationContact: homeContent.form.nominationContact,
nominationSummary: homeContent.form.nominationSummary,
success: homeContent.form.success,
} as const
const mainScalarDefinitions = [
...sectionScalars.map(([name, value]) => [`section_${snake(name)}`, value] as const),
...topLevelFormScalars.map(([name, value]) => [`form_${snake(name)}`, value] as const),
...Object.entries(formGroups).flatMap(([group, values]) =>
Object.entries(values).map(([name, value]) => [`form_${snake(group)}_${snake(name)}`, value] 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 createArrayTable(
db: MigrationDB,
tableName: string,
fields: Array<{ name: string; defaultValue?: string }>,
) {
const fieldDefinitions = fields
.map((field) => `${ident(field.name)} text${field.defaultValue ? ` DEFAULT ${literal(field.defaultValue)}` : ''}`)
.join(',\n')
await db.run(sql.raw(`
CREATE TABLE IF NOT EXISTS ${ident(tableName)} (
\`_order\` integer NOT NULL,
\`_parent_id\` integer NOT NULL,
\`id\` text PRIMARY KEY NOT NULL,
${fieldDefinitions},
FOREIGN KEY (\`_parent_id\`) REFERENCES \`application_form\`(\`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)} (\`_order\`);`))
await db.run(sql.raw(`CREATE INDEX IF NOT EXISTS ${ident(`${tableName}_parent_id_idx`)} ON ${ident(tableName)} (\`_parent_id\`);`))
}
async function seedArrayFromPage(
db: MigrationDB,
target: string,
source: string,
columns: string[],
defaults: ReadonlyArray<Record<string, unknown>>,
) {
const sourceExists = await tableExists(db, source)
if (sourceExists) {
const columnList = columns.map(ident).join(', ')
await db.run(sql.raw(`
INSERT OR IGNORE INTO ${ident(target)} (\`_order\`, \`_parent_id\`, \`id\`, ${columnList})
SELECT source.\`_order\`, 1, lower(hex(randomblob(16))), ${columns.map((column) => `source.${ident(column)}`).join(', ')}
FROM ${ident(source)} source
INNER JOIN \`pages\` page ON page.\`id\` = source.\`_parent_id\`
WHERE page.\`spa_path\` = '/' OR page.\`slug\` IN ('startseite', 'home')
ORDER BY source.\`_order\`;
`))
}
const count = (await db.all(sql.raw(`SELECT COUNT(*) AS count FROM ${ident(target)} WHERE \`_parent_id\` = 1;`))) as Array<{ count: number }>
if (Number(count[0]?.count || 0) > 0) return
for (const [index, item] of defaults.entries()) {
const values = columns.map((column) => literal(String(item[column] ?? ''))).join(', ')
await db.run(sql.raw(`
INSERT INTO ${ident(target)} (\`_order\`, \`_parent_id\`, \`id\`, ${columns.map(ident).join(', ')})
VALUES (${index + 1}, 1, lower(hex(randomblob(16))), ${values});
`))
}
}
export async function up({ db }: MigrateUpArgs): Promise<void> {
const scalarColumns = mainScalarDefinitions
.map(([name, value]) => `${ident(name)} text DEFAULT ${literal(value)}`)
.join(',\n')
await db.run(sql.raw(`
CREATE TABLE IF NOT EXISTS \`application_form\` (
\`id\` integer PRIMARY KEY NOT NULL,
${scalarColumns},
\`section_award_image_id\` integer,
\`updated_at\` text,
\`created_at\` text,
FOREIGN KEY (\`section_award_image_id\`) REFERENCES \`media\`(\`id\`) ON UPDATE no action ON DELETE set null
);
`))
await db.run(sql.raw('CREATE INDEX IF NOT EXISTS `application_form_section_section_award_image_idx` ON `application_form` (`section_award_image_id`);'))
await createArrayTable(db, 'application_form_benefits', [
{ name: 'num' },
{ name: 'label' },
{ name: 'desc' },
])
await createArrayTable(db, 'application_form_self_steps', [
{ name: 'label' },
{ name: 'icon', defaultValue: 'trophy' },
])
await createArrayTable(db, 'application_form_nom_steps', [
{ name: 'label' },
{ name: 'icon', defaultValue: 'trophy' },
])
await createArrayTable(db, 'application_form_emp_opts', [
{ name: 'value' },
{ name: 'label' },
])
await db.run(sql.raw(`
INSERT OR IGNORE INTO \`application_form\` (\`id\`, \`updated_at\`, \`created_at\`)
VALUES (1, strftime('%Y-%m-%dT%H:%M:%fZ', 'now'), strftime('%Y-%m-%dT%H:%M:%fZ', 'now'));
`))
if (await columnExists(db, 'pages', 'home_application_eyebrow')) {
const assignments = [
...sectionScalars.map(([name]) => [`section_${snake(name)}`, `home_application_${snake(name)}`] as const),
...topLevelFormScalars.map(([name]) => [`form_${snake(name)}`, `home_form_${snake(name)}`] as const),
...Object.keys(formGroups).flatMap((group) =>
Object.keys(formGroups[group as keyof typeof formGroups]).map((name) => [
`form_${snake(group)}_${snake(name)}`,
`home_form_${snake(group)}_${snake(name)}`,
] as const),
),
]
.filter(([, source]) => source !== 'home_application_award_image')
.map(([target, source]) => `${ident(target)} = COALESCE((SELECT ${ident(source)} FROM \`pages\` WHERE \`spa_path\` = '/' OR \`slug\` IN ('startseite', 'home') ORDER BY CASE WHEN \`spa_path\` = '/' THEN 0 ELSE 1 END LIMIT 1), ${ident(target)})`)
if (await columnExists(db, 'pages', 'home_application_award_image_id')) {
assignments.push("`section_award_image_id` = (SELECT `home_application_award_image_id` FROM `pages` WHERE `spa_path` = '/' OR `slug` IN ('startseite', 'home') ORDER BY CASE WHEN `spa_path` = '/' THEN 0 ELSE 1 END LIMIT 1)")
}
await db.run(sql.raw(`UPDATE \`application_form\` SET ${assignments.join(',\n')} WHERE \`id\` = 1;`))
}
await seedArrayFromPage(db, 'application_form_benefits', 'pages_home_application_benefits', ['num', 'label', 'desc'], homeContent.application.benefits)
await seedArrayFromPage(db, 'application_form_self_steps', 'home_self_steps', ['label', 'icon'], homeContent.form.selfSteps)
await seedArrayFromPage(db, 'application_form_nom_steps', 'home_nom_steps', ['label', 'icon'], homeContent.form.nominationSteps)
await seedArrayFromPage(db, 'application_form_emp_opts', 'home_emp_opts', ['value', 'label'], homeContent.form.employeeOptions)
}
export async function down({ db }: MigrateDownArgs): Promise<void> {
await db.run(sql.raw('DROP TABLE IF EXISTS `application_form_emp_opts`;'))
await db.run(sql.raw('DROP TABLE IF EXISTS `application_form_nom_steps`;'))
await db.run(sql.raw('DROP TABLE IF EXISTS `application_form_self_steps`;'))
await db.run(sql.raw('DROP TABLE IF EXISTS `application_form_benefits`;'))
await db.run(sql.raw('DROP TABLE IF EXISTS `application_form`;'))
}

View File

@@ -0,0 +1,59 @@
import { type MigrateDownArgs, type MigrateUpArgs, sql } from '@payloadcms/db-sqlite'
type MigrationDB = MigrateUpArgs['db']
const heading = 'WEITERE AUSZEICHNUNGEN.'
const description =
'Der Roland-Berger-Zukunftspreis und der Studentenpreis Bavarian Future Award setzen jeweils einen eigenen Schwerpunkt.'
const columns = [
{
page: 'participation_awards_grid_supporting_heading',
version: 'version_participation_awards_grid_supporting_heading',
defaultValue: heading,
},
{
page: 'participation_awards_grid_supporting_description',
version: 'version_participation_awards_grid_supporting_description',
defaultValue: description,
},
] as const
const ident = (value: string) => `\`${value.replace(/`/g, '``')}\``
const literal = (value: string) => `'${value.replace(/'/g, "''")}'`
async function columnExists(db: MigrationDB, tableName: string, columnName: string) {
const table = (await db.all(
sql.raw(`SELECT name FROM sqlite_master WHERE type = 'table' AND name = ${literal(tableName)}`),
)) as Array<{ name: string }>
if (table.length === 0) return false
const tableColumns = (await db.all(sql.raw(`PRAGMA table_info(${ident(tableName)})`))) as Array<{ name: string }>
return tableColumns.some((column) => column.name === columnName)
}
async function addTextColumn(db: MigrationDB, tableName: string, columnName: string, defaultValue: string) {
if (await columnExists(db, tableName, columnName)) return
await db.run(
sql.raw(`ALTER TABLE ${ident(tableName)} ADD ${ident(columnName)} text DEFAULT ${literal(defaultValue)};`),
)
}
async function dropColumn(db: MigrationDB, tableName: string, columnName: string) {
if (!(await columnExists(db, tableName, columnName))) return
await db.run(sql.raw(`ALTER TABLE ${ident(tableName)} DROP COLUMN ${ident(columnName)};`))
}
export async function up({ db }: MigrateUpArgs): Promise<void> {
for (const column of columns) {
await addTextColumn(db, 'pages', column.page, column.defaultValue)
await addTextColumn(db, '_pages_v', column.version, column.defaultValue)
}
}
export async function down({ db }: MigrateDownArgs): Promise<void> {
for (const column of [...columns].reverse()) {
await dropColumn(db, '_pages_v', column.version)
await dropColumn(db, 'pages', column.page)
}
}

View File

@@ -0,0 +1,118 @@
import { type MigrateDownArgs, type MigrateUpArgs, sql } from '@payloadcms/db-sqlite'
type MigrationDB = MigrateUpArgs['db']
const lockedRelationColumns = [
'id',
'order',
'parent_id',
'path',
'pages_id',
'preistraeger_id',
'jury_mitglieder_id',
'partners_id',
'posts_id',
'events_id',
'newsletter_subscriptions_id',
'media_id',
'users_id',
'payload_folders_id',
] 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 = '${tableName.replace(/'/g, "''")}'`),
)) as Array<{ name: string }>
return rows.length > 0
}
async function rebuildLockedDocumentRelations(db: MigrationDB, includeContactSubmissions: boolean) {
if (!(await tableExists(db, 'payload_locked_documents_rels'))) return
const contactColumn = includeContactSubmissions ? ',\n `contact_submissions_id` integer' : ''
const contactForeignKey = includeContactSubmissions
? ',\n FOREIGN KEY (`contact_submissions_id`) REFERENCES `contact_submissions`(`id`) ON UPDATE no action ON DELETE cascade'
: ''
await db.run(sql.raw('DROP TABLE IF EXISTS `__new_payload_locked_documents_rels`;'))
await db.run(sql.raw(`
CREATE TABLE "__new_payload_locked_documents_rels" (
"id" integer PRIMARY KEY NOT NULL,
"order" integer,
"parent_id" integer NOT NULL,
"path" text NOT NULL,
"pages_id" integer,
"preistraeger_id" integer,
"jury_mitglieder_id" integer,
"partners_id" integer,
"posts_id" integer,
"events_id" integer,
"newsletter_subscriptions_id" integer${contactColumn},
"media_id" integer,
"users_id" integer,
"payload_folders_id" integer,
FOREIGN KEY ("parent_id") REFERENCES "payload_locked_documents"("id") ON UPDATE no action ON DELETE cascade,
FOREIGN KEY ("pages_id") REFERENCES "pages"("id") ON UPDATE no action ON DELETE cascade,
FOREIGN KEY ("preistraeger_id") REFERENCES "preistraeger"("id") ON UPDATE no action ON DELETE cascade,
FOREIGN KEY ("jury_mitglieder_id") REFERENCES "jury_mitglieder"("id") ON UPDATE no action ON DELETE cascade,
FOREIGN KEY ("partners_id") REFERENCES "partners"("id") ON UPDATE no action ON DELETE cascade,
FOREIGN KEY ("posts_id") REFERENCES "posts"("id") ON UPDATE no action ON DELETE cascade,
FOREIGN KEY ("events_id") REFERENCES "events"("id") ON UPDATE no action ON DELETE cascade,
FOREIGN KEY ("newsletter_subscriptions_id") REFERENCES "newsletter_subscriptions"("id") ON UPDATE no action ON DELETE cascade${contactForeignKey},
FOREIGN KEY ("media_id") REFERENCES "media"("id") ON UPDATE no action ON DELETE cascade,
FOREIGN KEY ("users_id") REFERENCES "users"("id") ON UPDATE no action ON DELETE cascade,
FOREIGN KEY ("payload_folders_id") REFERENCES "payload_folders"("id") ON UPDATE no action ON DELETE cascade
);
`))
const columns = lockedRelationColumns.map((column) => `"${column}"`).join(', ')
await db.run(sql.raw(`INSERT INTO "__new_payload_locked_documents_rels" (${columns}) SELECT ${columns} FROM "payload_locked_documents_rels";`))
await db.run(sql.raw('DROP TABLE `payload_locked_documents_rels`;'))
await db.run(sql.raw('ALTER TABLE `__new_payload_locked_documents_rels` RENAME TO `payload_locked_documents_rels`;'))
for (const [suffix, column] of [
['order', 'order'],
['parent', 'parent_id'],
['path', 'path'],
['pages_id', 'pages_id'],
['preistraeger_id', 'preistraeger_id'],
['jury_mitglieder_id', 'jury_mitglieder_id'],
['partners_id', 'partners_id'],
['posts_id', 'posts_id'],
['events_id', 'events_id'],
['newsletter_subscriptions_i', 'newsletter_subscriptions_id'],
...(includeContactSubmissions ? [['contact_submissions_id', 'contact_submissions_id']] : []),
['media_id', 'media_id'],
['users_id', 'users_id'],
['payload_folders_id', 'payload_folders_id'],
] as string[][]) {
await db.run(sql.raw(`CREATE INDEX "payload_locked_documents_rels_${suffix}_idx" ON "payload_locked_documents_rels" ("${column}");`))
}
}
export async function up({ db, payload: _payload, req: _req }: MigrateUpArgs): Promise<void> {
await db.run(sql.raw(`
CREATE TABLE IF NOT EXISTS "contact_submissions" (
"id" integer PRIMARY KEY NOT NULL,
"subject" text NOT NULL,
"name" text NOT NULL,
"email" text NOT NULL,
"message" text NOT NULL,
"submitted_at" text NOT NULL,
"updated_at" text DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ', 'now')) NOT NULL,
"created_at" text DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ', 'now')) NOT NULL
);
`))
await db.run(sql.raw('CREATE INDEX IF NOT EXISTS `contact_submissions_email_idx` ON `contact_submissions` (`email`);'))
await db.run(sql.raw('CREATE INDEX IF NOT EXISTS `contact_submissions_submitted_at_idx` ON `contact_submissions` (`submitted_at`);'))
await db.run(sql.raw('CREATE INDEX IF NOT EXISTS `contact_submissions_updated_at_idx` ON `contact_submissions` (`updated_at`);'))
await db.run(sql.raw('CREATE INDEX IF NOT EXISTS `contact_submissions_created_at_idx` ON `contact_submissions` (`created_at`);'))
await rebuildLockedDocumentRelations(db, true)
}
export async function down({ db, payload: _payload, req: _req }: MigrateDownArgs): Promise<void> {
await rebuildLockedDocumentRelations(db, false)
await db.run(sql.raw('DROP TABLE IF EXISTS `contact_submissions`;'))
}

View File

@@ -0,0 +1,133 @@
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, "''")}'`
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 addJuryRelationshipColumn(db: MigrationDB, tableName: string) {
if (!(await tableExists(db, tableName))) return
if (!(await columnExists(db, tableName, 'jury_mitglieder_id'))) {
await db.run(
sql.raw(
`ALTER TABLE ${ident(tableName)} ADD ${ident('jury_mitglieder_id')} integer REFERENCES ${ident('jury_mitglieder')}(${ident('id')});`,
),
)
}
await db.run(
sql.raw(
`CREATE INDEX IF NOT EXISTS ${ident(`${tableName}_jury_mitglieder_id_idx`)} ON ${ident(tableName)} (${ident('jury_mitglieder_id')});`,
),
)
}
async function backfillLivePageSelections(db: MigrationDB) {
if (!(await tableExists(db, 'pages_rels')) || !(await tableExists(db, 'jury_mitglieder'))) return
await db.run(sql.raw(`DELETE FROM ${ident('pages_rels')} WHERE ${ident('path')} = ${literal('netzwerk.jury.members')};`))
await db.run(sql.raw(`
INSERT INTO ${ident('pages_rels')} (
${ident('order')},
${ident('parent_id')},
${ident('path')},
${ident('jury_mitglieder_id')}
)
SELECT
ROW_NUMBER() OVER (
PARTITION BY page.${ident('id')}
ORDER BY
CASE WHEN LENGTH(TRIM(COALESCE(member.${ident('quote')}, ''))) > 0 THEN 0 ELSE 1 END,
COALESCE(member.${ident('sort_order')}, 100),
member.${ident('id')}
),
page.${ident('id')},
${literal('netzwerk.jury.members')},
member.${ident('id')}
FROM ${ident('pages')} AS page
CROSS JOIN ${ident('jury_mitglieder')} AS member
WHERE
(page.${ident('spa_path')} = ${literal('/netzwerk')}
OR page.${ident('slug')} IN (${literal('netzwerk')}, ${literal('network')}))
AND COALESCE(member.${ident('active')}, 1) = 1;
`))
}
async function backfillVersionSelections(db: MigrationDB) {
if (!(await tableExists(db, '_pages_v_rels')) || !(await tableExists(db, 'jury_mitglieder'))) return
await db.run(
sql.raw(`DELETE FROM ${ident('_pages_v_rels')} WHERE ${ident('path')} = ${literal('version.netzwerk.jury.members')};`),
)
await db.run(sql.raw(`
INSERT INTO ${ident('_pages_v_rels')} (
${ident('order')},
${ident('parent_id')},
${ident('path')},
${ident('jury_mitglieder_id')}
)
SELECT
ROW_NUMBER() OVER (
PARTITION BY version.${ident('id')}
ORDER BY
CASE WHEN LENGTH(TRIM(COALESCE(member.${ident('quote')}, ''))) > 0 THEN 0 ELSE 1 END,
COALESCE(member.${ident('sort_order')}, 100),
member.${ident('id')}
),
version.${ident('id')},
${literal('version.netzwerk.jury.members')},
member.${ident('id')}
FROM ${ident('_pages_v')} AS version
CROSS JOIN ${ident('jury_mitglieder')} AS member
WHERE
(version.${ident('version_spa_path')} = ${literal('/netzwerk')}
OR version.${ident('version_slug')} IN (${literal('netzwerk')}, ${literal('network')}))
AND COALESCE(member.${ident('active')}, 1) = 1;
`))
}
async function removeJuryRelationshipColumn(db: MigrationDB, tableName: string) {
if (!(await tableExists(db, tableName))) return
await db.run(sql.raw(`DROP INDEX IF EXISTS ${ident(`${tableName}_jury_mitglieder_id_idx`)};`))
if (await columnExists(db, tableName, 'jury_mitglieder_id')) {
await db.run(sql.raw(`ALTER TABLE ${ident(tableName)} DROP COLUMN ${ident('jury_mitglieder_id')};`))
}
}
export async function up({ db, payload: _payload, req: _req }: MigrateUpArgs): Promise<void> {
await addJuryRelationshipColumn(db, 'pages_rels')
await addJuryRelationshipColumn(db, '_pages_v_rels')
await backfillLivePageSelections(db)
await backfillVersionSelections(db)
}
export async function down({ db, payload: _payload, req: _req }: MigrateDownArgs): Promise<void> {
if (await tableExists(db, 'pages_rels')) {
await db.run(sql.raw(`DELETE FROM ${ident('pages_rels')} WHERE ${ident('path')} = ${literal('netzwerk.jury.members')};`))
}
if (await tableExists(db, '_pages_v_rels')) {
await db.run(
sql.raw(`DELETE FROM ${ident('_pages_v_rels')} WHERE ${ident('path')} = ${literal('version.netzwerk.jury.members')};`),
)
}
await removeJuryRelationshipColumn(db, '_pages_v_rels')
await removeJuryRelationshipColumn(db, 'pages_rels')
}

View File

@@ -0,0 +1,73 @@
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 factsColumns = [
['heading', 'Auf einen Blick'],
['category_label', 'Kategorie'],
['award_label', 'Auszeichnung'],
['year_label', 'Jahr'],
['location_label', 'Standort'],
['industry_label', 'Branche'],
] as const
const fallbackQuoteColumns = [
['quote', 'Diese Erfolgsgeschichte wird aktuell in Payload CMS gepflegt.'],
['quote_person', 'BMP'],
['quote_role', 'Preisträger'],
] 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 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};`))
}
async function dropColumn(db: MigrationDB, tableName: string, columnName: string) {
if (!(await columnExists(db, tableName, columnName))) return
await db.run(sql.raw(`ALTER TABLE ${ident(tableName)} DROP COLUMN ${ident(columnName)};`))
}
export async function up({ db, payload: _payload, req: _req }: MigrateUpArgs): Promise<void> {
for (const [suffix] of factsColumns) {
await dropColumn(db, 'preistraeger', `detail_page_facts_${suffix}`)
await dropColumn(db, '_preistraeger_v', `version_detail_page_facts_${suffix}`)
}
for (const [suffix] of fallbackQuoteColumns) {
await dropColumn(db, 'preistraeger', `detail_page_fallback_winner_${suffix}`)
await dropColumn(db, '_preistraeger_v', `version_detail_page_fallback_winner_${suffix}`)
}
}
export async function down({ db, payload: _payload, req: _req }: MigrateDownArgs): Promise<void> {
for (const [suffix, defaultValue] of factsColumns) {
await addColumn(db, 'preistraeger', `detail_page_facts_${suffix}`, `text DEFAULT ${literal(defaultValue)}`)
await addColumn(db, '_preistraeger_v', `version_detail_page_facts_${suffix}`, `text DEFAULT ${literal(defaultValue)}`)
}
for (const [suffix, defaultValue] of fallbackQuoteColumns) {
await addColumn(db, 'preistraeger', `detail_page_fallback_winner_${suffix}`, `text DEFAULT ${literal(defaultValue)}`)
await addColumn(db, '_preistraeger_v', `version_detail_page_fallback_winner_${suffix}`, `text DEFAULT ${literal(defaultValue)}`)
}
}

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

@@ -10,6 +10,17 @@ import * as migration_20260707_124500_site_settings_preistraeger_placeholder fro
import * as migration_20260707_130500_preistraeger_awards_jury_members from './20260707_130500_preistraeger_awards_jury_members';
import * as migration_20260712_120000_partner_media_only from './20260712_120000_partner_media_only';
import * as migration_20260713_133000_newsletter_form_and_subscriptions from './20260713_133000_newsletter_form_and_subscriptions';
import * as migration_20260713_155500_shared_application_form from './20260713_155500_shared_application_form';
import * as migration_20260713_163000_participation_supporting_awards_copy from './20260713_163000_participation_supporting_awards_copy';
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 = [
{
@@ -72,4 +83,59 @@ export const migrations = [
down: migration_20260713_133000_newsletter_form_and_subscriptions.down,
name: '20260713_133000_newsletter_form_and_subscriptions',
},
{
up: migration_20260713_155500_shared_application_form.up,
down: migration_20260713_155500_shared_application_form.down,
name: '20260713_155500_shared_application_form',
},
{
up: migration_20260713_163000_participation_supporting_awards_copy.up,
down: migration_20260713_163000_participation_supporting_awards_copy.down,
name: '20260713_163000_participation_supporting_awards_copy',
},
{
up: migration_20260713_194500_contact_submissions.up,
down: migration_20260713_194500_contact_submissions.down,
name: '20260713_194500_contact_submissions',
},
{
up: migration_20260713_213000_netzwerk_jury_selection.up,
down: migration_20260713_213000_netzwerk_jury_selection.down,
name: '20260713_213000_netzwerk_jury_selection',
},
{
up: migration_20260713_220000_preistraeger_detail_cleanup.up,
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

@@ -5,6 +5,7 @@ import { buildConfig, PayloadRequest } from 'payload'
import { fileURLToPath } from 'url'
import { Events } from './collections/Events'
import { ContactSubmissions } from './collections/ContactSubmissions'
import { JuryMitglieder } from './collections/JuryMitglieder'
import { Media } from './collections/Media'
import { NewsletterSubscriptions } from './collections/NewsletterSubscriptions'
@@ -14,6 +15,7 @@ import { Posts } from './collections/Posts'
import { Preistraeger } from './collections/Preistraeger'
import { Users } from './collections/Users'
import { ApplicationPhase } from './ApplicationPhase/config'
import { ApplicationForm } from './ApplicationForm/config'
import { Footer } from './Footer/config'
import { Header } from './Header/config'
import { NewsletterForm } from './NewsletterForm/config'
@@ -69,9 +71,9 @@ export default buildConfig({
url: process.env.DATABASE_URL || '',
},
}),
collections: [Pages, Preistraeger, JuryMitglieder, Partners, Posts, Events, NewsletterSubscriptions, Media, Users],
collections: [Pages, Preistraeger, JuryMitglieder, Partners, Posts, Events, ContactSubmissions, NewsletterSubscriptions, Media, Users],
cors: [getServerSideURL()].filter(Boolean),
globals: [Header, Footer, SiteSettings, ApplicationPhase, NewsletterForm],
globals: [Header, Footer, SiteSettings, ApplicationPhase, ApplicationForm, NewsletterForm],
plugins,
secret: process.env.PAYLOAD_SECRET,
sharp,

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

@@ -4,6 +4,7 @@ import config from '@payload-config'
import { getPayload } from 'payload'
import { mediaByFilename } from './mediaByFilename'
import { defaultApplicationFormData } from '@/spa/applicationForm'
import { defaultApplicationPhaseData } from '@/spa/applicationPhase'
import { defaultFooterData, defaultHeaderData } from '@/spa/cmsNavigation'
import { defaultNewsletterFormData } from '@/spa/newsletterForm'
@@ -12,11 +13,12 @@ import { defaultSiteSettings } from '@/spa/siteSettings'
async function main() {
const payload = await getPayload({ config })
const [logo, ewifLogo, partnerLogos, skylineImage] = await Promise.all([
const [logo, ewifLogo, partnerLogos, skylineImage, applicationFormImage] = await Promise.all([
mediaByFilename(payload, 'bmp-logo.png'),
mediaByFilename(payload, 'ewif-logo.png'),
mediaByFilename(payload, 'partner-logos.png'),
mediaByFilename(payload, 'munich-skyline.jpg'),
mediaByFilename(payload, 'preisuebergabe.jpg'),
])
const { logo: _headerLogo, ...headerData } = defaultHeaderData
@@ -60,6 +62,17 @@ async function main() {
context: { disableRevalidate: true },
data: defaultApplicationPhaseData as never,
}),
payload.updateGlobal({
slug: 'application-form',
context: { disableRevalidate: true },
data: {
...defaultApplicationFormData,
section: {
...defaultApplicationFormData.section,
awardImage: applicationFormImage,
},
} as never,
}),
payload.updateGlobal({
slug: 'newsletter-form',
context: { disableRevalidate: true },

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

@@ -76,6 +76,44 @@ async function upsertPartners(payload: Payload) {
)
}
async function juryMembersForPage(payload: Payload) {
const existing = await payload.find({
collection: 'jury-mitglieder',
limit: 300,
pagination: false,
draft: true,
overrideAccess: true,
sort: 'sortOrder',
})
if (existing.docs.length) {
return existing.docs.filter((member) => member.active !== false).map((member) => member.id)
}
const membersWithUploads = await itemsWithImageUploads(payload, netzwerkContent.jury.members)
const created = await Promise.all(
membersWithUploads.map((member, index) =>
payload.create({
collection: 'jury-mitglieder',
overrideAccess: true,
context: { disableRevalidate: true },
data: {
name: member.name,
image: member.imageUpload,
imageAlt: member.name,
role: member.role,
bio: member.bio,
sortOrder: index,
active: true,
_status: 'published',
},
}),
),
)
return created.map((member) => member.id)
}
async function main() {
const payload = await getPayload({ config })
@@ -97,10 +135,11 @@ 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),
itemsWithImageUploads(payload, netzwerkContent.jury.members),
juryMembersForPage(payload),
])
await payload.update({
@@ -117,6 +156,7 @@ async function main() {
},
patronage: {
...netzwerkContent.patronage,
greetingVideo: existingGreetingVideo,
people: patronPeople,
},
jury: {
@@ -129,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

@@ -4,6 +4,7 @@ import React from 'react'
import Layout from '@/spa/components/layout/Layout'
import ScrollToTop from '@/spa/components/layout/ScrollToTop'
import type { SpaApplicationFormData } from '@/spa/applicationForm'
import type { SpaApplicationPhaseData } from '@/spa/applicationPhase'
import { type SpaFooterData, type SpaHeaderData } from '@/spa/cmsNavigation'
import { CmsRouteProvider, type CmsRouteData } from '@/spa/cmsRoute'
@@ -62,6 +63,7 @@ export default function NextApp({
footer,
cmsRoute,
siteSettings,
applicationForm,
applicationPhase,
newsletterForm,
}: {
@@ -70,6 +72,7 @@ export default function NextApp({
footer?: SpaFooterData
cmsRoute?: CmsRouteData
siteSettings?: SpaSiteSettingsData
applicationForm?: SpaApplicationFormData
applicationPhase?: SpaApplicationPhaseData
newsletterForm?: SpaNewsletterFormData
}) {
@@ -77,7 +80,7 @@ export default function NextApp({
return (
<>
<CmsRouteProvider value={cmsRoute ? { ...cmsRoute, siteSettings, applicationPhase, newsletterForm } : undefined}>
<CmsRouteProvider value={cmsRoute ? { ...cmsRoute, siteSettings, applicationForm, applicationPhase, newsletterForm } : undefined}>
<ScrollToTop />
<Layout header={header} footer={footer}>
{page}

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

@@ -0,0 +1,21 @@
import { homeContent } from '@/spa/homeContent'
export type SpaApplicationFormData = {
section?: Partial<typeof homeContent.application> & { awardImage?: unknown }
form?: Partial<typeof homeContent.form>
}
export const defaultApplicationFormData: SpaApplicationFormData = {
section: homeContent.application,
form: homeContent.form,
}
export function normalizeApplicationFormData(value?: SpaApplicationFormData | null): SpaApplicationFormData {
return {
section: {
...homeContent.application,
...(value?.section || {}),
},
form: value?.form || homeContent.form,
}
}

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

@@ -176,6 +176,9 @@ export const participationAwardsGrid = {
heading: 'DREI AUSZEICHNUNGEN.\nEIN ANSPRUCH.',
description:
'Direkt im Bewerbungsjahr 2026 stehen drei Auszeichnungen im Fokus: die Goldene Bavaria, der Roland-Berger-Zukunftspreis und der Studentenpreis Bavarian Future Award.',
supportingHeading: 'WEITERE AUSZEICHNUNGEN.',
supportingDescription:
'Der Roland-Berger-Zukunftspreis und der Studentenpreis Bavarian Future Award setzen jeweils einen eigenen Schwerpunkt.',
cards: awardArticles.map(({ imageFilename, imageAlt, card, articleCta, mailtoCta }) => ({
...card,
imageFilename,

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

@@ -2,11 +2,13 @@
import React, { createContext, useContext } from 'react'
import type { SpaApplicationFormData } from '@/spa/applicationForm'
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
@@ -26,13 +28,20 @@ export type CmsRouteData = {
doc: CmsRouteDoc
lists?: Partial<Record<CmsRouteCollection, CmsRouteDoc[]>>
siteSettings?: SpaSiteSettingsData
applicationForm?: SpaApplicationFormData
applicationPhase?: SpaApplicationPhaseData
newsletterForm?: SpaNewsletterFormData
}
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>
}
@@ -46,16 +55,27 @@ 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() {
return useCmsRoute()?.applicationPhase
}
export function useApplicationForm() {
return useCmsRoute()?.applicationForm
}
export function useNewsletterForm() {
return useCmsRoute()?.newsletterForm
}

View File

@@ -12,6 +12,7 @@ const FF = '"IBM Plex Sans", sans-serif';
const FB = '"Inter", sans-serif';
const NAVY = '#111D55';
const GOLD = '#EFBF04';
const AWARD_ARTWORK_EDGE_NAVY = '#132B48';
type AwardCard = (typeof participationContent.awardsGrid.cards)[number];
type AwardCardCms = Partial<AwardCard> & { image?: unknown };
@@ -53,6 +54,16 @@ const isGoldeneBavariaCard = (card: ResolvedAwardCard) =>
normalizeLookupText(card.title).includes('goldene') ||
normalizeLookupText(card.label).includes('goldene');
const awardAnchorId = (card: ResolvedAwardCard) => {
const lookup = normalizeLookupText(`${card.title} ${card.label} ${card.articleCta?.url}`);
if (lookup.includes('goldene-bavaria') || lookup.includes('goldene')) return 'goldene-bavaria';
if (lookup.includes('roland-berger') || lookup.includes('roland berger')) return 'roland-berger-zukunftspreis';
if (lookup.includes('bavarian-future-award') || lookup.includes('bavarian future award')) return 'bavarian-future-award';
return undefined;
};
const splitFeaturedAwardCard = (cards: ResolvedAwardCard[], enabled: boolean) => {
if (!enabled) return { featuredCard: null, supportingCards: cards };
@@ -80,8 +91,8 @@ function FeaturedAwardCard({ card }: { card: ResolvedAwardCard }) {
const isContainedImage = isContainedAwardImage(imageFilename);
return (
<section aria-label={fallbackText(card.title, 'Goldene Bavaria')} style={{ position: 'relative', zIndex: 1, display: 'grid', gridTemplateColumns: isMobile ? '1fr' : 'minmax(0, 1fr) minmax(320px, 1fr)', borderBottom: '1px solid rgba(3,9,58,0.1)' }}>
<div style={{ position: 'relative', height: isMobile ? 180 : 600, background: 'linear-gradient(145deg, #111D55 0%, #1E2B69 44%, #C9A227 100%)', display: 'flex', alignItems: 'center', justifyContent: 'center', overflow: 'hidden' }}>
<article id={awardAnchorId(card)} aria-label={fallbackText(card.title, 'Goldene Bavaria')} style={{ position: 'relative', zIndex: 1, display: 'grid', gridTemplateColumns: isMobile ? '1fr' : 'minmax(0, 1fr) minmax(320px, 1fr)', borderBottom: '1px solid rgba(3,9,58,0.1)', scrollMarginTop: 80 }}>
<div style={{ position: 'relative', height: isMobile ? 180 : 600, background: AWARD_ARTWORK_EDGE_NAVY, display: 'flex', alignItems: 'center', justifyContent: 'center', overflow: 'hidden' }}>
<Image unoptimized
src={mediaUrl(card.image, `/images/${imageFilename}`)}
alt={mediaAlt(card.image, fallbackText(card.imageAlt, fallbackText(card.title, 'Auszeichnung')))}
@@ -90,24 +101,23 @@ function FeaturedAwardCard({ card }: { card: ResolvedAwardCard }) {
height: '100%',
objectFit: isContainedImage ? 'contain' : 'cover',
objectPosition: 'center',
padding: imageFilename.endsWith('.png') ? (isMobile ? 22 : 30) : 0,
padding: 0,
boxSizing: 'border-box',
display: 'block',
filter: imageFilename.endsWith('.png') ? 'drop-shadow(0 32px 46px rgba(3,9,58,0.34))' : undefined,
}}
/>
<div style={{ position: 'absolute', inset: 0, background: 'linear-gradient(to top, rgba(3,9,58,0.42), transparent 42%)', pointerEvents: 'none' }} />
</div>
<div style={{ background: NAVY, color: '#fff', padding: isMobile ? '24px 24px 30px' : '34px 48px', display: 'flex', flexDirection: 'column', justifyContent: 'center' }}>
<span style={{ fontFamily: FF, fontSize: 10, fontWeight: 700, letterSpacing: '0.32em', textTransform: 'uppercase', color: 'rgba(239,191,4,0.86)', marginBottom: 16 }}>
<div style={{ background: '#fff', color: '#101828', padding: isMobile ? '24px 24px 30px' : '34px 48px', display: 'flex', flexDirection: 'column', justifyContent: 'center' }}>
<span style={{ fontFamily: FF, fontSize: 10, fontWeight: 700, letterSpacing: '0.32em', textTransform: 'uppercase', color: 'var(--text-eyebrow-light)', marginBottom: 16 }}>
{fallbackText(card.label, 'Auszeichnung 01')}
</span>
<h3 style={{ fontFamily: FF, fontSize: isMobile ? 'clamp(1.7rem, 8vw, 2.8rem)' : 'clamp(2.1rem, 3.2vw, 3.3rem)', fontWeight: 900, color: '#fff', textTransform: 'uppercase', letterSpacing: '-0.025em', lineHeight: 0.98, margin: '0 0 14px', overflowWrap: 'anywhere' }}>
<h3 className="type-section-title text-role-heading" style={{ textTransform: 'uppercase', margin: '0 0 14px', overflowWrap: 'anywhere' }}>
<Lines text={fallbackText(card.title, '')} />
</h3>
<div style={{ width: 44, height: 2, background: GOLD, marginBottom: 18 }} />
<p style={{ fontFamily: FB, fontSize: isMobile ? 16 : 17, color: 'rgba(255,255,255,0.68)', lineHeight: 1.6, margin: 0, maxWidth: 560 }}>
<p className="type-body text-role-secondary" style={{ margin: 0, maxWidth: 560 }}>
{fallbackText(card.description, '')}
</p>
@@ -144,8 +154,8 @@ function FeaturedAwardCard({ card }: { card: ResolvedAwardCard }) {
alignItems: 'center',
justifyContent: 'center',
gap: 8,
color: '#fff',
border: '1px solid rgba(255,255,255,0.24)',
color: NAVY,
border: '1px solid rgba(3,9,58,0.18)',
fontFamily: FF,
fontSize: 13,
fontWeight: 700,
@@ -160,7 +170,128 @@ function FeaturedAwardCard({ card }: { card: ResolvedAwardCard }) {
</a>
</div>
</div>
</section>
</article>
);
}
function SupportingAwardCards({
cards,
allCards,
followsFeaturedCard,
}: {
cards: ResolvedAwardCard[];
allCards: ResolvedAwardCard[];
followsFeaturedCard: boolean;
}) {
const isMobile = useIsMobile();
if (cards.length === 0) return null;
return (
<div style={{ position: 'relative', zIndex: 1, display: 'grid', gridTemplateColumns: isMobile ? '1fr' : `repeat(${Math.min(Math.max(cards.length, 1), 3)}, minmax(0, 1fr))`, borderBottom: '1px solid rgba(3,9,58,0.08)' }}>
{cards.map((card, index) => {
const sourceIndex = allCards.indexOf(card);
const imageFilename = getAwardImageFilename(card, sourceIndex >= 0 ? sourceIndex : index);
const isContainedImage = isContainedAwardImage(imageFilename);
const usesGoldeneBavariaArtwork = imageFilename.includes('goldene-bavaria');
const isLast = index === cards.length - 1;
const isDarkCard = !followsFeaturedCard && index === 1;
return (
<article
key={`${fallbackText(card.title, 'award')}-${index}`}
id={awardAnchorId(card)}
style={{
display: 'flex',
flexDirection: 'column',
minHeight: isMobile ? 'auto' : followsFeaturedCard ? 540 : 620,
borderRight: !isMobile && !isLast ? '1px solid rgba(3,9,58,0.1)' : 'none',
borderBottom: isMobile && !isLast ? '1px solid rgba(3,9,58,0.1)' : 'none',
background: isDarkCard ? NAVY : followsFeaturedCard ? '#fff' : 'rgba(255,255,255,0.72)',
scrollMarginTop: 80,
}}
>
<div style={{ height: isMobile ? 220 : 260, background: usesGoldeneBavariaArtwork ? AWARD_ARTWORK_EDGE_NAVY : imageFilename.includes('roland-berger') ? '#DDE7E2' : NAVY, display: 'flex', alignItems: 'center', justifyContent: 'center', overflow: 'hidden' }}>
<Image unoptimized
src={mediaUrl(card.image, `/images/${imageFilename}`)}
alt={mediaAlt(card.image, fallbackText(card.imageAlt, fallbackText(card.title, 'Auszeichnung')))}
style={{
width: '100%',
height: '100%',
objectFit: isContainedImage ? 'contain' : 'cover',
objectPosition: isDarkCard ? 'center top' : 'center',
padding: usesGoldeneBavariaArtwork ? 0 : imageFilename.endsWith('.png') ? 22 : 0,
boxSizing: 'border-box',
display: 'block',
}}
/>
</div>
<div style={{ padding: isMobile ? '28px 24px 32px' : followsFeaturedCard ? '38px 44px 42px' : '36px 36px 40px', display: 'flex', flexDirection: 'column', flex: 1 }}>
<span style={{ fontFamily: FF, fontSize: 9, fontWeight: 700, letterSpacing: '0.26em', textTransform: 'uppercase', color: isDarkCard ? 'rgba(239,191,4,0.8)' : 'var(--text-eyebrow-light)', marginBottom: 12 }}>
{fallbackText(card.label, '')}
</span>
<h3 className={`type-card-title ${isDarkCard ? 'text-role-inverse' : 'text-role-heading'}`} style={{ textTransform: 'uppercase', margin: '0 0 16px', overflowWrap: 'anywhere' }}>
<Lines text={fallbackText(card.title, '')} />
</h3>
<div style={{ width: 36, height: 2, background: GOLD, marginBottom: 22 }} />
<p className={`type-body ${isDarkCard ? 'text-role-inverse-secondary' : 'text-role-secondary'}`} style={{ margin: 0, flex: 1 }}>
{fallbackText(card.description, '')}
</p>
<div style={{ display: 'flex', flexDirection: isMobile ? 'column' : 'row', gap: 10, marginTop: 32 }}>
<Link
to={fallbackText(card.articleCta?.url, '#bewerben')}
style={{
minHeight: 46,
padding: '0 18px',
display: 'inline-flex',
alignItems: 'center',
justifyContent: 'center',
gap: 8,
background: GOLD,
color: '#101828',
fontFamily: FF,
fontSize: 13,
fontWeight: 700,
textTransform: 'uppercase',
letterSpacing: '0.08em',
textDecoration: 'none',
whiteSpace: 'normal',
textAlign: 'center',
}}
>
{fallbackText(card.articleCta?.label, 'Artikel lesen')} <ArrowRight size={13} />
</Link>
<a
href={fallbackText(card.mailtoCta?.url, `mailto:${participationContent.form.eligibility.ineligibleContactEmail}`)}
style={{
minHeight: 46,
padding: '0 18px',
display: 'inline-flex',
alignItems: 'center',
justifyContent: 'center',
gap: 8,
color: isDarkCard ? '#fff' : NAVY,
border: `1px solid ${isDarkCard ? 'rgba(255,255,255,0.22)' : 'rgba(3,9,58,0.18)'}`,
fontFamily: FF,
fontSize: 13,
fontWeight: 700,
textTransform: 'uppercase',
letterSpacing: '0.08em',
textDecoration: 'none',
whiteSpace: 'normal',
textAlign: 'center',
}}
>
<Mail size={13} /> {fallbackText(card.mailtoCta?.label, 'Kontakt aufnehmen')}
</a>
</div>
</div>
</article>
);
})}
</div>
);
}
@@ -171,127 +302,47 @@ export default function AwardsGridSection({ content, featuredGoldeneBavaria = fa
const { featuredCard, supportingCards } = splitFeaturedAwardCard(cards, featuredGoldeneBavaria);
return (
<section id="auszeichnungen" style={{ background: '#F7F7F5', position: 'relative', overflow: 'hidden', isolation: 'isolate' }}>
<MunichSkylineBg />
<div style={{ position: 'relative', zIndex: 1, padding: isMobile ? '48px 24px 32px' : '80px 80px 56px', borderBottom: '1px solid rgba(3,9,58,0.1)' }}>
<span style={{ fontFamily: FF, fontSize: 10, color: '#4A8FC9', textTransform: 'uppercase', letterSpacing: '0.32em', fontWeight: 700, display: 'block', marginBottom: 16 }}>
{fallbackText(section.eyebrow, participationContent.awardsGrid.eyebrow)}
</span>
<div style={{ display: 'grid', gridTemplateColumns: isMobile ? '1fr' : 'minmax(0, 0.9fr) minmax(0, 1fr)', gap: isMobile ? 16 : 56, alignItems: 'end' }}>
<h2 style={{ fontFamily: FF, fontSize: 'clamp(2rem, 3.5vw, 3rem)', fontWeight: 900, color: '#101828', textTransform: 'uppercase', letterSpacing: '-0.025em', lineHeight: 1.03, margin: 0, overflowWrap: 'anywhere' }}>
<Lines text={fallbackText(section.heading, participationContent.awardsGrid.heading)} />
</h2>
<p style={{ fontFamily: FB, fontSize: 18, color: 'rgba(16,24,40,0.55)', lineHeight: 1.8, margin: 0 }}>
{fallbackText(section.description, participationContent.awardsGrid.description)}
</p>
<>
<section id="auszeichnungen" style={{ background: featuredCard ? '#fff' : '#F7F7F5', position: 'relative', overflow: 'hidden', isolation: 'isolate' }}>
{!featuredCard ? <MunichSkylineBg /> : null}
<div style={{ position: 'relative', zIndex: 1, padding: isMobile ? '48px 24px 32px' : '80px 80px 56px', borderBottom: '1px solid rgba(3,9,58,0.1)' }}>
<span style={{ fontFamily: FF, fontSize: 10, color: 'var(--text-eyebrow-light)', textTransform: 'uppercase', letterSpacing: '0.32em', fontWeight: 700, display: 'block', marginBottom: 16 }}>
{fallbackText(section.eyebrow, participationContent.awardsGrid.eyebrow)}
</span>
<div style={{ display: 'grid', gridTemplateColumns: isMobile ? '1fr' : 'minmax(0, 0.9fr) minmax(0, 1fr)', gap: isMobile ? 16 : 56, alignItems: 'end' }}>
<h2 className="type-section-title text-role-heading" style={{ textTransform: 'uppercase', margin: 0, overflowWrap: 'anywhere' }}>
<Lines text={fallbackText(section.heading, participationContent.awardsGrid.heading)} />
</h2>
<p className="type-body text-role-secondary" style={{ margin: 0 }}>
{fallbackText(section.description, participationContent.awardsGrid.description)}
</p>
</div>
</div>
</div>
{featuredCard ? <FeaturedAwardCard card={featuredCard} /> : null}
{featuredCard ? (
<div style={{ position: 'relative', zIndex: 1, background: '#fff', paddingBottom: isMobile ? 32 : 56 }}>
<FeaturedAwardCard card={featuredCard} />
</div>
) : (
<SupportingAwardCards cards={supportingCards} allCards={cards} followsFeaturedCard={false} />
)}
</section>
{supportingCards.length > 0 ? (
<div style={{ position: 'relative', zIndex: 1, display: 'grid', gridTemplateColumns: isMobile ? '1fr' : `repeat(${Math.min(Math.max(supportingCards.length, 1), 3)}, minmax(0, 1fr))`, borderBottom: '1px solid rgba(3,9,58,0.08)' }}>
{supportingCards.map((card, index) => {
const sourceIndex = cards.indexOf(card);
const imageFilename = getAwardImageFilename(card, sourceIndex >= 0 ? sourceIndex : index);
const isContainedImage = isContainedAwardImage(imageFilename);
const isLast = index === supportingCards.length - 1;
const isDarkCard = !featuredCard && index === 1;
return (
<article
key={`${fallbackText(card.title, 'award')}-${index}`}
style={{
display: 'flex',
flexDirection: 'column',
minHeight: isMobile ? 'auto' : featuredCard ? 540 : 620,
borderRight: !isMobile && !isLast ? '1px solid rgba(3,9,58,0.1)' : 'none',
borderBottom: isMobile && !isLast ? '1px solid rgba(3,9,58,0.1)' : 'none',
background: isDarkCard ? NAVY : 'rgba(255,255,255,0.72)',
}}
>
<div style={{ height: isMobile ? 220 : 260, background: imageFilename.includes('roland-berger') ? '#DDE7E2' : NAVY, display: 'flex', alignItems: 'center', justifyContent: 'center', overflow: 'hidden' }}>
<Image unoptimized
src={mediaUrl(card.image, `/images/${imageFilename}`)}
alt={mediaAlt(card.image, fallbackText(card.imageAlt, fallbackText(card.title, 'Auszeichnung')))}
style={{
width: '100%',
height: '100%',
objectFit: isContainedImage ? 'contain' : 'cover',
objectPosition: isDarkCard ? 'center top' : 'center',
padding: imageFilename.endsWith('.png') ? 22 : 0,
boxSizing: 'border-box',
display: 'block',
}}
/>
</div>
<div style={{ padding: isMobile ? '28px 24px 32px' : featuredCard ? '38px 44px 42px' : '36px 36px 40px', display: 'flex', flexDirection: 'column', flex: 1 }}>
<span style={{ fontFamily: FF, fontSize: 9, fontWeight: 700, letterSpacing: '0.26em', textTransform: 'uppercase', color: isDarkCard ? 'rgba(239,191,4,0.8)' : '#4A8FC9', marginBottom: 12 }}>
{fallbackText(card.label, '')}
</span>
<h3 style={{ fontFamily: FF, fontSize: 'clamp(1.25rem, 2vw, 1.7rem)', fontWeight: 900, color: isDarkCard ? '#fff' : '#101828', textTransform: 'uppercase', letterSpacing: '-0.02em', lineHeight: 1.08, margin: '0 0 16px', overflowWrap: 'anywhere' }}>
<Lines text={fallbackText(card.title, '')} />
</h3>
<div style={{ width: 36, height: 2, background: GOLD, marginBottom: 22 }} />
<p style={{ fontFamily: FB, fontSize: 18, color: isDarkCard ? 'rgba(255,255,255,0.62)' : 'rgba(16,24,40,0.55)', lineHeight: 1.7, margin: 0, flex: 1 }}>
{fallbackText(card.description, '')}
</p>
<div style={{ display: 'flex', flexDirection: isMobile ? 'column' : 'row', gap: 10, marginTop: 32 }}>
<Link
to={fallbackText(card.articleCta?.url, '#bewerben')}
style={{
minHeight: 46,
padding: '0 18px',
display: 'inline-flex',
alignItems: 'center',
justifyContent: 'center',
gap: 8,
background: GOLD,
color: '#101828',
fontFamily: FF,
fontSize: 13,
fontWeight: 700,
textTransform: 'uppercase',
letterSpacing: '0.08em',
textDecoration: 'none',
whiteSpace: 'normal',
textAlign: 'center',
}}
>
{fallbackText(card.articleCta?.label, 'Artikel lesen')} <ArrowRight size={13} />
</Link>
<a
href={fallbackText(card.mailtoCta?.url, `mailto:${participationContent.form.eligibility.ineligibleContactEmail}`)}
style={{
minHeight: 46,
padding: '0 18px',
display: 'inline-flex',
alignItems: 'center',
justifyContent: 'center',
gap: 8,
color: isDarkCard ? '#fff' : NAVY,
border: `1px solid ${isDarkCard ? 'rgba(255,255,255,0.22)' : 'rgba(3,9,58,0.18)'}`,
fontFamily: FF,
fontSize: 13,
fontWeight: 700,
textTransform: 'uppercase',
letterSpacing: '0.08em',
textDecoration: 'none',
whiteSpace: 'normal',
textAlign: 'center',
}}
>
<Mail size={13} /> {fallbackText(card.mailtoCta?.label, 'Kontakt aufnehmen')}
</a>
</div>
</div>
</article>
);
})}
</div>
{featuredCard && supportingCards.length > 0 ? (
<section id="weitere-auszeichnungen" aria-label="Weitere Auszeichnungen" style={{ background: '#fff', position: 'relative', overflow: 'hidden', padding: isMobile ? '40px 0 56px' : '64px 0 80px', borderTop: '1px solid rgba(17,29,85,0.08)', scrollMarginTop: 80 }}>
<div style={{ padding: isMobile ? '0 24px 32px' : '0 80px 48px' }}>
<div style={{ display: 'grid', gridTemplateColumns: isMobile ? '1fr' : 'minmax(0, 0.9fr) minmax(0, 1fr)', gap: isMobile ? 16 : 56, alignItems: 'end' }}>
<h2 className="type-section-title text-role-heading" style={{ textTransform: 'uppercase', margin: 0, overflowWrap: 'anywhere' }}>
<Lines text={fallbackText(section.supportingHeading, participationContent.awardsGrid.supportingHeading)} />
</h2>
<p className="type-body text-role-secondary" style={{ margin: 0 }}>
{fallbackText(section.supportingDescription, participationContent.awardsGrid.supportingDescription)}
</p>
</div>
</div>
<SupportingAwardCards cards={supportingCards} allCards={cards} followsFeaturedCard />
</section>
) : null}
</section>
</>
);
}

View File

@@ -0,0 +1,186 @@
import React, { useRef, useState } from 'react';
import { ChevronLeft, ChevronRight, Star } from 'lucide-react';
import Image from '@/spa/components/ui/UnoptimizedImage';
import { mediaUrl } from '@/spa/cmsMediaField';
import { useCmsCollection } from '@/spa/cmsRoute';
import { WINNERS } from '@/spa/data/winners';
import { homeContent } from '@/spa/homeContent';
import { resolveHomeWinners } from '@/spa/homeWinnerSelection';
import { useIsMobile } from '@/spa/hooks/useIsMobile';
import { usePreistraegerPlaceholderImage } from '@/spa/preistraegerPlaceholder';
import { Link } from '@/spa/router';
type CmsRecord = Record<string, unknown>;
export type HomeWinnersContent = CmsRecord & {
cardFallbackTitle?: string | null;
cta?: { label?: string | null; url?: string | null } | null;
eyebrow?: string | null;
fallbackImage?: unknown;
featured?: unknown;
heading?: string | null;
hoverLabel?: string | null;
};
type WinnerCardData = CmsRecord & {
id?: string | number;
slug?: string;
title?: string;
name?: string;
img?: string;
image?: unknown;
spaPath?: string;
year?: string | number;
awardType?: string;
type?: string;
category?: string;
};
const fallbackText = (value: unknown, fallback: string) =>
typeof value === 'string' && value.length > 0 ? value : fallback;
function Lines({ text }: { text: string }) {
return <>{text.split('\n').map((line, i) => <React.Fragment key={`${line}-${i}`}>{i > 0 && <br />}{line}</React.Fragment>)}</>;
}
function HomeWinnerCard({
winner,
fallbackTitle,
fallbackImage,
hoverLabel,
horizontalScroll = false,
}: {
winner: WinnerCardData;
fallbackTitle: string;
fallbackImage: string;
hoverLabel: string;
horizontalScroll?: boolean;
}) {
const [hovered, setHovered] = useState(false);
const isMobile = useIsMobile();
const staticWinner = WINNERS.find(
(item) =>
String(item.id) === String(winner?.id) ||
item.slug === winner?.slug ||
item.name === winner?.title ||
item.name === winner?.name,
);
const title = fallbackText(winner?.title, fallbackText(winner?.name, staticWinner?.name || fallbackTitle));
const imageSrc = winner?.img || mediaUrl(winner?.image, fallbackImage);
const href = winner?.spaPath || (winner?.slug ? `/preistraeger/${winner.slug}` : staticWinner ? `/preistraeger/${staticWinner.slug}` : '/preistraeger');
return (
<Link
to={href}
style={{ textDecoration: 'none', display: 'block', position: 'relative', height: isMobile ? 220 : 280, overflow: 'hidden', scrollSnapAlign: isMobile || horizontalScroll ? 'start' : undefined }}
onMouseEnter={() => setHovered(true)}
onMouseLeave={() => setHovered(false)}
>
<Image unoptimized src={imageSrc} alt={title} style={{ width: '100%', height: '100%', objectFit: 'cover', display: 'block', transition: 'transform 0.5s ease', transform: hovered ? 'scale(1.07)' : 'scale(1)', filter: 'grayscale(15%)' }} />
<div style={{ position: 'absolute', inset: 0, background: 'linear-gradient(to top, rgba(0,0,0,0.75) 0%, rgba(0,0,0,0.1) 55%, transparent 100%)' }} />
<div style={{ position: 'absolute', inset: 0, background: 'rgba(0,0,0,0.2)', opacity: hovered ? 1 : 0, transition: 'opacity 0.25s' }} />
<div style={{ position: 'absolute', inset: 0, boxShadow: 'inset 0 0 0 1px rgba(255,255,255,0.06)' }} />
<div style={{ position: 'absolute', bottom: 0, left: 0, padding: '18px 20px' }}>
<div style={{ display: 'flex', alignItems: 'center', gap: 5, marginBottom: 5 }}>
<Star size={10} style={{ color: '#EFBF04', flexShrink: 0 }} fill="#EFBF04" />
<span style={{ fontFamily: '"IBM Plex Sans", sans-serif', fontSize: 10, fontWeight: 700, color: '#EFBF04', letterSpacing: '0.12em', textTransform: 'uppercase' }}>{winner?.year || staticWinner?.year} · {winner?.awardType || winner?.type || staticWinner?.type}</span>
</div>
<h3 className="type-small text-role-inverse" style={{ margin: '0 0 3px', fontWeight: 700 }}>{title}</h3>
<p className="type-caption text-role-inverse-secondary" style={{ margin: 0 }}>{winner?.category || staticWinner?.category}</p>
<div style={{ marginTop: 10, display: 'inline-block', background: '#EFBF04', color: '#101828', padding: '5px 14px', borderRadius: 999, fontSize: 10, fontFamily: '"IBM Plex Sans", sans-serif', fontWeight: 800, letterSpacing: '0.1em', textTransform: 'uppercase', opacity: hovered ? 1 : 0, transform: hovered ? 'translateY(0)' : 'translateY(6px)', transition: 'all 0.25s' }}>
{hoverLabel}
</div>
</div>
</Link>
);
}
export default function HomeWinnersSection({ content }: { content?: HomeWinnersContent }) {
const winnersScrollRef = useRef<HTMLDivElement>(null);
const isMobile = useIsMobile();
const placeholderImage = usePreistraegerPlaceholderImage();
const cmsPreistraeger = useCmsCollection('preistraeger');
const winnersSection = content || {};
const homeWinners = resolveHomeWinners(winnersSection.featured, cmsPreistraeger);
const scrollDesktopWinners = !isMobile && homeWinners.length > 8;
const scrollWinnerGrid = isMobile || scrollDesktopWinners;
const scrollWinners = (direction: -1 | 1) => {
const container = winnersScrollRef.current;
if (!container) return;
container.scrollBy({
left: direction * Math.max(container.clientWidth * 0.8, 320),
behavior: 'smooth',
});
};
return (
<section style={{ background: '#111D55' }}>
<div style={{ padding: isMobile ? '48px 24px 32px' : '72px 80px 48px', display: 'flex', flexDirection: isMobile ? 'column' : 'row', alignItems: isMobile ? 'flex-start' : 'flex-end', justifyContent: 'space-between', gap: isMobile ? 16 : 0 }}>
<div>
<span style={{ fontFamily: '"IBM Plex Sans", sans-serif', fontSize: 10, fontWeight: 700, letterSpacing: '0.3em', textTransform: 'uppercase', color: '#EFBF04', display: 'block', marginBottom: 12 }}>{fallbackText(winnersSection.eyebrow, homeContent.winners.eyebrow)}</span>
<h2 className="type-section-title text-role-inverse" style={{ textTransform: 'uppercase', margin: 0 }}>
<Lines text={fallbackText(winnersSection.heading, homeContent.winners.heading)} />
</h2>
</div>
<div style={{ display: 'flex', alignItems: 'center', gap: 20 }}>
{scrollDesktopWinners && (
<div style={{ display: 'flex', alignItems: 'center', gap: 8 }}>
<button
type="button"
aria-label="Vorherige Preisträger anzeigen"
onClick={() => scrollWinners(-1)}
style={{ width: 42, height: 42, border: '1px solid rgba(255,255,255,0.28)', background: 'transparent', color: '#fff', display: 'inline-flex', alignItems: 'center', justifyContent: 'center', cursor: 'pointer' }}
>
<ChevronLeft size={18} />
</button>
<button
type="button"
aria-label="Weitere Preisträger anzeigen"
onClick={() => scrollWinners(1)}
style={{ width: 42, height: 42, border: '1px solid rgba(255,255,255,0.28)', background: 'transparent', color: '#fff', display: 'inline-flex', alignItems: 'center', justifyContent: 'center', cursor: 'pointer' }}
>
<ChevronRight size={18} />
</button>
</div>
)}
<Link
to={fallbackText(winnersSection.cta?.url, homeContent.winners.cta.url)}
style={{ fontFamily: '"IBM Plex Sans", sans-serif', fontSize: 15, fontWeight: 700, letterSpacing: '0.1em', textTransform: 'uppercase', color: '#EFBF04', textDecoration: 'none', display: 'flex', alignItems: 'center', gap: 6, paddingBottom: 4, borderBottom: '1px solid #EFBF04', whiteSpace: 'nowrap' }}
>
{fallbackText(winnersSection.cta?.label, homeContent.winners.cta.label)} <ChevronRight size={14} />
</Link>
</div>
</div>
<div ref={winnersScrollRef} style={{
display: 'grid',
gridTemplateColumns: scrollWinnerGrid ? undefined : 'repeat(4, 1fr)',
gridAutoFlow: scrollWinnerGrid ? 'column' : undefined,
gridTemplateRows: scrollWinnerGrid ? `repeat(2, ${isMobile ? '1fr' : '280px'})` : undefined,
gridAutoColumns: isMobile ? '66vw' : scrollDesktopWinners ? '25vw' : undefined,
overflowX: scrollWinnerGrid ? 'auto' : undefined,
scrollSnapType: scrollWinnerGrid ? 'x mandatory' : undefined,
overscrollBehaviorX: scrollWinnerGrid ? 'contain' : undefined,
gap: isMobile ? 8 : 0,
padding: isMobile ? '0 16px 8px' : scrollDesktopWinners ? '0 0 12px' : 0,
WebkitOverflowScrolling: 'touch',
scrollbarWidth: scrollDesktopWinners ? 'thin' : 'none',
scrollbarColor: scrollDesktopWinners ? '#EFBF04 rgba(255,255,255,0.14)' : undefined,
}}>
{homeWinners.map((winner) => (
<HomeWinnerCard
key={(winner as WinnerCardData).id || (winner as WinnerCardData).slug}
winner={winner as WinnerCardData}
fallbackTitle={fallbackText(winnersSection.cardFallbackTitle, homeContent.winners.cardFallbackTitle)}
fallbackImage={placeholderImage}
hoverLabel={fallbackText(winnersSection.hoverLabel, homeContent.winners.hoverLabel)}
horizontalScroll={scrollDesktopWinners}
/>
))}
</div>
</section>
);
}

View File

@@ -77,8 +77,8 @@ export default function TestimonialsSection({ data }: { data?: TestimonialsCms }
{isMobile && (
<div style={{ padding: '48px 24px 52px', position: 'relative', zIndex: 1 }}>
{/* Section header */}
<span style={{ fontFamily: FF, fontSize: 10, color: '#4A8FC9', textTransform: 'uppercase', letterSpacing: '0.3em', fontWeight: 700, display: 'block', marginBottom: 14 }}>{eyebrow}</span>
<h2 style={{ fontFamily: FF, fontSize: 'clamp(1.8rem, 8vw, 2.3rem)', fontWeight: 900, color: '#101828', textTransform: 'uppercase', letterSpacing: '-0.025em', lineHeight: 1.04, margin: '0 0 16px' }}>
<span style={{ fontFamily: FF, fontSize: 10, color: 'var(--text-eyebrow-light)', textTransform: 'uppercase', letterSpacing: '0.3em', fontWeight: 700, display: 'block', marginBottom: 14 }}>{eyebrow}</span>
<h2 className="type-section-title text-role-heading" style={{ textTransform: 'uppercase', margin: '0 0 16px' }}>
<Lines text={heading} />
</h2>
<div style={{ width: 40, height: 2, background: '#EFBF04', marginBottom: 28 }} />
@@ -94,7 +94,7 @@ export default function TestimonialsSection({ data }: { data?: TestimonialsCms }
{/* Body */}
<div style={{ padding: '22px 22px 24px' }}>
<span aria-hidden style={{ fontFamily: 'Georgia, serif', fontSize: 52, lineHeight: 0.4, color: '#EFBF04', display: 'block', height: 26, marginBottom: 6 }}>&ldquo;</span>
<p style={{ fontFamily: FF, fontSize: '1.2rem', fontWeight: 300, color: '#101828', lineHeight: 1.5, margin: '0 0 22px' }}>{t.quote}</p>
<p className="type-lead text-role-body" style={{ margin: '0 0 22px' }}>{t.quote}</p>
<div style={{ width: 36, height: 2, background: '#EFBF04', marginBottom: 16 }} />
<div style={{ fontFamily: FF, fontSize: 16, fontWeight: 700, color: '#101828', marginBottom: 4 }}>{t.name}</div>
<div style={{ fontFamily: FF, fontSize: 14, color: 'rgba(16,24,40,0.5)' }}>{t.role} · {t.company}</div>
@@ -134,13 +134,13 @@ export default function TestimonialsSection({ data }: { data?: TestimonialsCms }
{/* Right – quote */}
<div style={{ padding: isMobile ? '40px 22px 56px' : '80px 72px 80px 56px', display: 'flex', flexDirection: 'column', justifyContent: 'center' }}>
<span style={{ fontFamily: FF, fontSize: 10, color: '#4A8FC9', textTransform: 'uppercase', letterSpacing: '0.3em', fontWeight: 700, display: 'block', marginBottom: 14 }}>{eyebrow}</span>
<span style={{ fontFamily: FF, fontSize: 10, color: 'var(--text-eyebrow-light)', textTransform: 'uppercase', letterSpacing: '0.3em', fontWeight: 700, display: 'block', marginBottom: 14 }}>{eyebrow}</span>
<h2 style={{ fontFamily: FF, fontSize: 'clamp(1.8rem, 2.6vw, 2.6rem)', fontWeight: 900, color: '#101828', textTransform: 'uppercase', letterSpacing: '-0.025em', lineHeight: 1.04, margin: '0 0 36px' }}>
<h2 className="type-section-title text-role-heading" style={{ textTransform: 'uppercase', margin: '0 0 36px' }}>
<Lines text={desktopHeading} />
</h2>
<p style={{ fontFamily: FF, fontSize: 'clamp(1.4rem, 2.2vw, 2rem)', fontWeight: 300, color: '#101828', lineHeight: 1.55, margin: '0 0 40px' }}>
<p className="type-lead text-role-body" style={{ margin: '0 0 40px' }}>
{t.quote}
</p>
@@ -148,7 +148,7 @@ export default function TestimonialsSection({ data }: { data?: TestimonialsCms }
<div>
<div style={{ fontFamily: FF, fontSize: 15, fontWeight: 700, color: '#101828', marginBottom: 4 }}>{t.name}</div>
<div style={{ fontFamily: FF, fontSize: 12, color: 'rgba(16,24,40,0.5)' }}>{t.role} · {t.company}</div>
<div style={{ fontFamily: FF, fontSize: 11, fontWeight: 700, color: '#4A8FC9', letterSpacing: '0.1em', textTransform: 'uppercase', marginTop: 4 }}>{yearPrefix} {t.year}</div>
<div style={{ fontFamily: FF, fontSize: 11, fontWeight: 700, color: 'var(--text-eyebrow-light)', letterSpacing: '0.1em', textTransform: 'uppercase', marginTop: 4 }}>{yearPrefix} {t.year}</div>
</div>
<div style={{ display: 'flex', alignItems: 'center', gap: 10, marginTop: 40 }}>

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,23 +35,80 @@ 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 (
<section id="schritte" style={{ background: NAVY, padding: '56px 22px' }}>
{/* Section header */}
<div style={{ marginBottom: 36 }}>
<span style={{ fontFamily: FF, fontSize: 10, color: '#4A8FC9', textTransform: 'uppercase', letterSpacing: '0.32em', fontWeight: 700, display: 'block', marginBottom: 12 }}>{fallbackText(section.eyebrow, participationContent.process.eyebrow)}</span>
<h2 style={{ fontFamily: FF, fontSize: 'clamp(1.9rem, 8vw, 2.6rem)', fontWeight: 900, color: '#fff', textTransform: 'uppercase', letterSpacing: '-0.025em', lineHeight: 1.05, margin: '0 0 16px' }}>
<span style={{ fontFamily: FF, fontSize: 10, color: GOLD, textTransform: 'uppercase', letterSpacing: '0.32em', fontWeight: 700, display: 'block', marginBottom: 12 }}>{fallbackText(section.eyebrow, participationContent.process.eyebrow)}</span>
<h2 className="type-section-title text-role-inverse" style={{ textTransform: 'uppercase', margin: '0 0 16px' }}>
<Lines text={fallbackText(section.heading, participationContent.process.heading)} />
</h2>
<p style={{ fontFamily: FB, fontSize: 15, color: 'rgba(255,255,255,0.65)', lineHeight: 1.7, margin: 0 }}>
<p className="type-small text-role-inverse-secondary" style={{ margin: 0 }}>
{fallbackText(section.description, participationContent.process.description)}
</p>
</div>
@@ -60,8 +118,8 @@ export default function WegZurAuszeichnungSection({ content }: { content?: Proce
{steps.map((item, index) => {
const Icon = ICONS[item.icon as keyof typeof ICONS] || FileText;
return (
<div key={`${item.step}-${index}`} style={{ border: '1px solid rgba(239,191,4,0.25)', background: '#111D55', padding: '24px 22px' }}>
<div style={{ display: 'flex', alignItems: 'center', gap: 12, marginBottom: 20 }}>
<div key={`${item.step}-${index}`} style={{ border: '1px solid rgba(239,191,4,0.25)', background: NAVY, padding: '18px' }}>
<div style={{ display: 'flex', alignItems: 'center', gap: 12, marginBottom: 16 }}>
<div style={{ width: 44, height: 44, border: '1px solid rgba(239,191,4,0.5)', display: 'flex', alignItems: 'center', justifyContent: 'center', flexShrink: 0 }}>
<Icon size={18} style={{ color: GOLD }} strokeWidth={1.5} />
</div>
@@ -71,16 +129,16 @@ export default function WegZurAuszeichnungSection({ content }: { content?: Proce
<span style={{ width: 1, height: 11, background: 'rgba(255,255,255,0.2)' }} />
<span style={{ fontFamily: FF, fontSize: 10, color: 'rgba(255,255,255,0.62)', letterSpacing: '0.06em' }}>{fallbackText(item.date, '')}</span>
</div>
<h3 style={{ fontFamily: FF, fontSize: '1.35rem', fontWeight: 900, color: '#fff', textTransform: 'uppercase', letterSpacing: '-0.02em', lineHeight: 1.1, margin: 0 }}>
<h3 className="type-card-title text-role-inverse" style={{ textTransform: 'uppercase', margin: 0 }}>
{fallbackText(item.title, '')}
</h3>
</div>
</div>
<p style={{ fontFamily: FB, fontSize: 17, color: 'rgba(255,255,255,0.68)', lineHeight: 1.7, margin: 0 }}>
<p className="type-body text-role-inverse-secondary" style={{ margin: 0 }}>
{fallbackText(item.desc, '')}
</p>
{/* Step progress indicator */}
<div style={{ display: 'flex', gap: 4, marginTop: 20 }}>
<div style={{ display: 'flex', gap: 4, marginTop: 16 }}>
{steps.map((_, i) => (
<div key={i} style={{ height: 2, flex: i === index ? 2 : 1, background: i === index ? GOLD : 'rgba(255,255,255,0.1)' }} />
))}
@@ -94,31 +152,29 @@ export default function WegZurAuszeichnungSection({ content }: { content?: Proce
}
return (
<ContainerScroll id="schritte" className="h-[380vh]" 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: '#4A8FC9', textTransform: 'uppercase', letterSpacing: '0.32em', fontWeight: 700, display: 'block', marginBottom: 14 }}>{fallbackText(section.eyebrow, participationContent.process.eyebrow)}</span>
<h2 style={{ fontFamily: FF, fontSize: 'clamp(1.8rem, 3vw, 2.8rem)', fontWeight: 900, color: '#fff', textTransform: 'uppercase', letterSpacing: '-0.025em', lineHeight: 1.03, margin: 0 }}>
<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 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 style={{ fontFamily: FB, fontSize: 14, color: 'rgba(255,255,255,0.65)', lineHeight: 1.8, margin: 0 }}>
<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,27 +183,60 @@ 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="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-8 px-5 min-w-[80px]">
<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]">
<div style={{ width: 40, height: 40, border: '1px solid rgba(239,191,4,0.5)', display: 'flex', alignItems: 'center', justifyContent: 'center' }}>
<Icon size={16} style={{ color: GOLD }} strokeWidth={1.5} />
</div>
@@ -157,42 +246,106 @@ export default function WegZurAuszeichnungSection({ content }: { content?: Proce
</ProcessCardTitle>
{/* Card body */}
<ProcessCardBody className="flex flex-col justify-between py-10 px-10 gap-6">
<ProcessCardBody className="flex flex-col justify-between py-6 px-7 gap-4">
<div>
<div style={{ display: 'flex', alignItems: 'center', gap: 12, marginBottom: 24 }}>
<div style={{ display: 'flex', alignItems: 'center', gap: 12, marginBottom: 16 }}>
<span style={{ fontFamily: FF, fontSize: 10, fontWeight: 700, color: GOLD, letterSpacing: '0.22em', textTransform: 'uppercase' }}>
{stepPrefix} {fallbackText(item.step, '')}
</span>
<span style={{ width: 1, height: 12, background: 'rgba(255,255,255,0.2)' }} />
<span style={{ fontFamily: FF, fontSize: 10, color: 'rgba(255,255,255,0.62)', letterSpacing: '0.08em' }}>{fallbackText(item.date, '')}</span>
</div>
<div style={{ width: 32, height: 1, background: GOLD, marginBottom: 20, opacity: 0.5 }} />
<h3 style={{ fontFamily: FF, fontSize: 'clamp(1.5rem, 2.5vw, 2.2rem)', fontWeight: 900, color: '#fff', textTransform: 'uppercase', letterSpacing: '-0.02em', lineHeight: 1.1, margin: 0 }}>
<div style={{ width: 32, height: 1, background: GOLD, marginBottom: 16, opacity: 0.5 }} />
<h3 className="type-card-title text-role-inverse" style={{ textTransform: 'uppercase', margin: 0 }}>
{fallbackText(item.title, '')}
</h3>
</div>
<p style={{ fontFamily: FB, fontSize: 17, color: 'rgba(255,255,255,0.68)', lineHeight: 1.7, margin: 0 }}>
<p className="type-body text-role-inverse-secondary" style={{ margin: 0 }}>
{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

@@ -0,0 +1,120 @@
'use client'
import React from 'react'
import BewerbungsForm from '@/spa/components/forms/BewerbungsForm'
import { NewsletterInterestForm } from '@/spa/components/forms/NewsletterInterestForm'
import Image from '@/spa/components/ui/UnoptimizedImage'
import { useApplicationForm, useApplicationPhase, useNewsletterForm } from '@/spa/cmsRoute'
import { mediaAlt, mediaUrl } from '@/spa/cmsMediaField'
import { homeContent } from '@/spa/homeContent'
import { useIsMobile } from '@/spa/hooks/useIsMobile'
import {
newsletterFormCopyForPhase,
newsletterPhaseForApplicationPhase,
type NewsletterBenefit,
type NewsletterSource,
} from '@/spa/newsletterForm'
const FF = '"IBM Plex Sans", sans-serif'
const APPLICATION_PHASE_OPEN = '0'
const fallbackText = (value: unknown, fallback: string) =>
typeof value === 'string' && value.length > 0 ? value : fallback
const fallbackArray = <T,>(value: unknown, fallback: readonly T[]) =>
Array.isArray(value) && value.length > 0 ? (value as T[]) : fallback
function Lines({ text }: { text: string }) {
return <>{text.split('\n').map((line, index) => <React.Fragment key={`${line}-${index}`}>{index > 0 && <br />}{line}</React.Fragment>)}</>
}
export function ApplicationFormSection({ source }: { source: NewsletterSource }) {
const isMobile = useIsMobile()
const sharedForm = useApplicationForm()
const applicationPhase = useApplicationPhase()
const newsletterForm = useNewsletterForm()
const section = { ...homeContent.application, ...(sharedForm?.section || {}) }
const form = sharedForm?.form || homeContent.form
const activeApplicationPhase = applicationPhase?.activePhase || APPLICATION_PHASE_OPEN
const showApplicationForm = activeApplicationPhase === APPLICATION_PHASE_OPEN
const newsletterCopy = newsletterFormCopyForPhase(newsletterForm, activeApplicationPhase)
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
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 }}>
<Lines text={showApplicationForm ? fallbackText(section.heading, homeContent.application.heading) : newsletterCopy.heading} />
</h2>
<div style={{ width: 32, height: 2, background: '#EFBF04', marginBottom: 20 }} />
<p className="type-small text-role-inverse-secondary" style={{ marginBottom: 32 }}>
{showApplicationForm ? fallbackText(section.body, homeContent.application.body) : newsletterCopy.body}
</p>
<div style={{ borderTop: '1px solid rgba(255,255,255,0.08)' }}>
{finalBenefits.map((item) => (
<div key={item.num} style={{ display: 'grid', gridTemplateColumns: '24px 1fr', gap: '0 14px', padding: '13px 0', borderBottom: '1px solid rgba(255,255,255,0.06)', alignItems: 'center' }}>
<span style={{ fontFamily: FF, fontSize: 9, fontWeight: 700, color: 'rgba(239,191,4,0.55)', letterSpacing: '0.1em' }}>{item.num}</span>
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'baseline', gap: 8 }}>
<span style={{ fontFamily: FF, fontSize: 14, fontWeight: 700, color: '#fff', textTransform: 'uppercase', letterSpacing: '0.08em' }}>{item.label}</span>
<span style={{ fontFamily: FF, fontSize: 14, color: 'rgba(255,255,255,0.45)', textAlign: 'right' }}>{item.desc}</span>
</div>
</div>
))}
</div>
</div>
{!isMobile && <div style={{ background: 'rgba(255,255,255,0.07)' }} />}
<div style={{ display: 'flex', flexDirection: 'column', overflow: 'hidden', minHeight: 0, position: 'relative', background: 'linear-gradient(160deg,#DDB84A 0%,#C9A227 52%,#A87800 100%)' }}>
<svg style={{ position: 'absolute', inset: 0, width: '100%', height: '100%', pointerEvents: 'none', zIndex: 0 }} aria-hidden="true">
<defs>
<pattern id={`applicationFormCross-${source}`} width="18" height="18" patternUnits="userSpaceOnUse">
<path d="M0,0 L18,18 M18,0 L0,18" stroke="rgba(17,29,85,0.055)" strokeWidth="0.65" />
</pattern>
</defs>
<rect width="100%" height="100%" fill={`url(#applicationFormCross-${source})`} />
</svg>
<div style={{ position: 'absolute', inset: 0, background: 'radial-gradient(ellipse at center, transparent 55%, rgba(100,70,0,0.25) 100%)', pointerEvents: 'none', zIndex: 0 }} />
{!isMobile && (
<div style={{ height: 220, position: 'relative', overflow: 'hidden', flexShrink: 0, zIndex: 1 }}>
<Image
unoptimized
src={mediaUrl(section.awardImage, `/images/${homeContent.application.awardImageFilename}`)}
alt={mediaAlt(section.awardImage, fallbackText(section.awardImageAlt, homeContent.application.awardImageAlt))}
style={{ width: '100%', height: '100%', objectFit: 'cover', objectPosition: 'center 25%', filter: 'sepia(0.18) brightness(0.92)' }}
/>
<div style={{ position: 'absolute', inset: 0, background: 'linear-gradient(to bottom, rgba(168,120,0,0.1) 0%, rgba(168,120,0,0.25) 50%, rgba(168,120,0,0.92) 88%, #A87800 100%)' }} />
<div style={{ position: 'absolute', bottom: 0, left: 0, right: 0, height: 2, background: 'rgba(17,29,85,0.35)' }} />
</div>
)}
<div style={{ padding: isMobile ? '32px 24px' : '24px 48px 32px 40px', flex: isMobile ? 'none' : 1, height: isMobile && showApplicationForm ? '82svh' : undefined, display: 'flex', flexDirection: 'column', minHeight: 0, position: 'relative', zIndex: 1 }}>
<span style={{ fontFamily: FF, fontSize: 10, color: 'rgba(17,29,85,0.5)', textTransform: 'uppercase', letterSpacing: '0.32em', fontWeight: 700, display: 'block', marginBottom: 8 }}>{showApplicationForm ? fallbackText(section.formEyebrow, homeContent.application.formEyebrow) : newsletterCopy.formEyebrow}</span>
<h3 className="type-card-title text-role-heading" style={{ textTransform: 'uppercase', marginBottom: 24 }}>{showApplicationForm ? fallbackText(section.formTitle, homeContent.application.formTitle) : newsletterCopy.formTitle}</h3>
{showApplicationForm ? <BewerbungsForm theme="gold" content={form} /> : <NewsletterInterestForm copy={newsletterCopy} phase={newsletterPhase} source={source} />}
</div>
</div>
</div>
<div style={{ position: 'relative', zIndex: 1, padding: isMobile ? '16px 24px 20px' : '14px 56px 18px', display: 'flex', flexWrap: 'wrap', justifyContent: 'center', textAlign: 'center', borderTop: '1px solid rgba(255,255,255,0.06)', flexShrink: 0 }}>
<span style={{ fontFamily: FF, fontSize: 15, color: 'rgba(255,255,255,0.45)', marginRight: isMobile ? 6 : 10 }}>{showApplicationForm ? fallbackText(section.footnote, homeContent.application.footnote) : newsletterCopy.footnote}</span>
<span style={{ fontFamily: FF, fontSize: 15, fontWeight: 700, color: 'rgba(239,191,4,0.8)' }}>{showApplicationForm ? fallbackText(section.footnoteStrong, homeContent.application.footnoteStrong) : newsletterCopy.footnoteStrong}</span>
</div>
<div style={{ height: 2, background: 'linear-gradient(to right, #EFBF04, rgba(239,191,4,0.25), transparent)', flexShrink: 0 }} />
</section>
)
}

View File

@@ -345,10 +345,10 @@ export default function BewerbungsForm({ theme = 'dark', content }: { theme?: 'd
<div style={{ width: 52, height: 52, background: c.successIcon, display: 'flex', alignItems: 'center', justifyContent: 'center', margin: '0 auto 20px' }}>
<Check size={22} color={c.successIconInner} strokeWidth={3} />
</div>
<h3 style={{ fontSize: 18, fontWeight: 900, color: c.successHeading, textTransform: 'uppercase', letterSpacing: '-0.01em', marginBottom: 10 }}>
<h3 className="type-card-title" style={{ color: c.successHeading, textTransform: 'uppercase', marginBottom: 10 }}>
{data.type === 'selbst' ? fallbackText(formContent.success.selfHeading, participationContent.form.success.selfHeading) : fallbackText(formContent.success.nominationHeading, participationContent.form.success.nominationHeading)}
</h3>
<p style={{ fontSize: 13, color: c.successBody, maxWidth: 340, margin: '0 auto', lineHeight: 1.7 }}>
<p className="type-caption" style={{ color: c.successBody, maxWidth: 340, margin: '0 auto' }}>
{data.type === 'selbst'
? <><span>{fallbackText(formContent.success.selfPrefix, participationContent.form.success.selfPrefix)}</span><strong style={{ color: c.successName }}>{data.firmenname}</strong><span>{fallbackText(formContent.success.selfMiddle, participationContent.form.success.selfMiddle)}</span><strong style={{ color: c.successAccent }}>{data.email}</strong><span>{fallbackText(formContent.success.selfSuffix, participationContent.form.success.selfSuffix)}</span></>
: <><span>{fallbackText(formContent.success.nominationPrefix, participationContent.form.success.nominationPrefix)}</span><strong style={{ color: c.successName }}>{data.nomName}</strong><span>{fallbackText(formContent.success.nominationMiddle, participationContent.form.success.nominationMiddle)}</span><strong style={{ color: c.successAccent }}>{data.nomFirma}</strong><span>{fallbackText(formContent.success.nominationSuffix, participationContent.form.success.nominationSuffix)}</span></>
@@ -400,8 +400,8 @@ export default function BewerbungsForm({ theme = 'dark', content }: { theme?: 'd
{/* ── Step 0: Typ-Wahl ── */}
{step === 0 && (
<div>
<p style={{ fontFamily: FF, fontSize: 10, color: c.accentLabel, textTransform: 'uppercase', letterSpacing: '0.25em', fontWeight: 700, marginBottom: 8 }}>{fallbackText(formContent.typeStep.eyebrow, participationContent.form.typeStep.eyebrow)}</p>
<h3 style={{ fontFamily: FF, fontSize: 16, fontWeight: 900, color: c.heading, textTransform: 'uppercase', letterSpacing: '-0.01em', marginBottom: 14 }}>{fallbackText(formContent.typeStep.heading, participationContent.form.typeStep.heading)}</h3>
<p className="type-eyebrow" style={{ color: c.accentLabel, marginBottom: 8 }}>{fallbackText(formContent.typeStep.eyebrow, participationContent.form.typeStep.eyebrow)}</p>
<h3 className="type-card-title" style={{ color: c.heading, textTransform: 'uppercase', marginBottom: 14 }}>{fallbackText(formContent.typeStep.heading, participationContent.form.typeStep.heading)}</h3>
<div style={{ display: 'flex', flexDirection: isMobile ? 'column' : 'row', gap: 8 }}>
<TypeCard c={c} selected={data.type === 'selbst'} title={fallbackText(formContent.typeStep.selfTitle, participationContent.form.typeStep.selfTitle)}
desc={fallbackText(formContent.typeStep.selfDesc, participationContent.form.typeStep.selfDesc)}
@@ -417,8 +417,8 @@ export default function BewerbungsForm({ theme = 'dark', content }: { theme?: 'd
{step === 1 && data.type === 'selbst' && (
<div style={{ display: 'flex', flexDirection: 'column', gap: 20 }}>
<div>
<p style={{ fontFamily: FF, fontSize: 10, color: c.accentLabel, textTransform: 'uppercase', letterSpacing: '0.25em', fontWeight: 700, marginBottom: 6 }}>{fallbackText(formContent.eligibility.eyebrow, participationContent.form.eligibility.eyebrow)}</p>
<h3 style={{ fontFamily: FF, fontSize: 16, fontWeight: 900, color: c.heading, textTransform: 'uppercase', letterSpacing: '-0.01em' }}>{fallbackText(formContent.eligibility.heading, participationContent.form.eligibility.heading)}</h3>
<p className="type-eyebrow" style={{ color: c.accentLabel, marginBottom: 6 }}>{fallbackText(formContent.eligibility.eyebrow, participationContent.form.eligibility.eyebrow)}</p>
<h3 className="type-card-title" style={{ color: c.heading, textTransform: 'uppercase' }}>{fallbackText(formContent.eligibility.heading, participationContent.form.eligibility.heading)}</h3>
</div>
<div style={{ display: 'flex', flexDirection: 'column', gap: 16 }}>
<div style={{ display: 'flex', flexDirection: 'column', gap: 8 }}>
@@ -450,12 +450,12 @@ export default function BewerbungsForm({ theme = 'dark', content }: { theme?: 'd
<div style={{ display: 'flex', flexDirection: 'column', gap: 16, alignItems: 'flex-start' }}>
<div style={{ display: 'flex', alignItems: 'center', gap: 10 }}>
<AlertCircle size={18} color={c.alertIcon} />
<h3 style={{ fontFamily: FF, fontSize: 15, fontWeight: 800, color: c.alertTitle, textTransform: 'uppercase' }}>{fallbackText(formContent.eligibility.ineligibleHeading, participationContent.form.eligibility.ineligibleHeading)}</h3>
<h3 className="type-small" style={{ color: c.alertTitle, textTransform: 'uppercase', fontWeight: 700 }}>{fallbackText(formContent.eligibility.ineligibleHeading, participationContent.form.eligibility.ineligibleHeading)}</h3>
</div>
<p style={{ fontFamily: FF, fontSize: 13, color: c.bodyText, lineHeight: 1.7 }}>
<p className="type-caption" style={{ color: c.bodyText }}>
{fallbackText(formContent.eligibility.ineligibleBody, participationContent.form.eligibility.ineligibleBody)}
</p>
<p style={{ fontFamily: FF, fontSize: 13, color: c.mutedText, lineHeight: 1.7 }}>
<p className="type-caption" style={{ color: c.mutedText }}>
{fallbackText(formContent.eligibility.ineligibleContactPrefix, participationContent.form.eligibility.ineligibleContactPrefix)} <span style={{ color: c.alertEmail }}>{fallbackText(formContent.eligibility.ineligibleContactEmail, participationContent.form.eligibility.ineligibleContactEmail)}</span>
</p>
</div>
@@ -463,8 +463,8 @@ export default function BewerbungsForm({ theme = 'dark', content }: { theme?: 'd
return (
<div style={{ display: 'flex', flexDirection: 'column', gap: 14 }}>
<div>
<p style={{ fontFamily: FF, fontSize: 10, color: c.accentLabel, textTransform: 'uppercase', letterSpacing: '0.25em', fontWeight: 700, marginBottom: 6 }}>{fallbackText(formContent.selfContact.eyebrow, participationContent.form.selfContact.eyebrow)}</p>
<h3 style={{ fontFamily: FF, fontSize: 16, fontWeight: 900, color: c.heading, textTransform: 'uppercase', letterSpacing: '-0.01em' }}>{fallbackText(formContent.selfContact.heading, participationContent.form.selfContact.heading)}</h3>
<p className="type-eyebrow" style={{ color: c.accentLabel, marginBottom: 6 }}>{fallbackText(formContent.selfContact.eyebrow, participationContent.form.selfContact.eyebrow)}</p>
<h3 className="type-card-title" style={{ color: c.heading, textTransform: 'uppercase' }}>{fallbackText(formContent.selfContact.heading, participationContent.form.selfContact.heading)}</h3>
</div>
<div style={{ display: 'grid', gridTemplateColumns: isMobile ? '1fr' : '1fr 1fr', gap: 14 }}>
<Field c={c} label={fallbackText(formContent.selfContact.companyLabel, participationContent.form.selfContact.companyLabel)} error={errors.firmenname} requiredSuffix={fallbackText(formContent.requiredSuffix, participationContent.form.requiredSuffix)}>
@@ -480,7 +480,7 @@ export default function BewerbungsForm({ theme = 'dark', content }: { theme?: 'd
<StyledInput c={c} type="tel" value={data.telefon} onChange={e => set('telefon', e.target.value)} placeholder={fallbackText(formContent.selfContact.phonePlaceholder, participationContent.form.selfContact.phonePlaceholder)} />
</Field>
</div>
<p style={{ fontFamily: FF, fontSize: 12, color: c.mutedText, lineHeight: 1.6 }}>
<p className="type-caption" style={{ color: c.mutedText }}>
{fallbackText(formContent.selfContact.footnote, participationContent.form.selfContact.footnote)}
</p>
</div>
@@ -491,8 +491,8 @@ export default function BewerbungsForm({ theme = 'dark', content }: { theme?: 'd
{step === 3 && data.type === 'selbst' && (
<div style={{ display: 'flex', flexDirection: 'column', gap: 14 }}>
<div>
<p style={{ fontFamily: FF, fontSize: 10, color: c.accentLabel, textTransform: 'uppercase', letterSpacing: '0.25em', fontWeight: 700, marginBottom: 6 }}>{fallbackText(formContent.selfSummary.eyebrow, participationContent.form.selfSummary.eyebrow)}</p>
<h3 style={{ fontFamily: FF, fontSize: 16, fontWeight: 900, color: c.heading, textTransform: 'uppercase', letterSpacing: '-0.01em' }}>{fallbackText(formContent.selfSummary.heading, participationContent.form.selfSummary.heading)}</h3>
<p className="type-eyebrow" style={{ color: c.accentLabel, marginBottom: 6 }}>{fallbackText(formContent.selfSummary.eyebrow, participationContent.form.selfSummary.eyebrow)}</p>
<h3 className="type-card-title" style={{ color: c.heading, textTransform: 'uppercase' }}>{fallbackText(formContent.selfSummary.heading, participationContent.form.selfSummary.heading)}</h3>
</div>
<div style={{ borderTop: `1px solid ${c.border}` }}>
{[
@@ -514,8 +514,8 @@ export default function BewerbungsForm({ theme = 'dark', content }: { theme?: 'd
{step === 1 && data.type === 'vorschlag' && (
<div style={{ display: 'flex', flexDirection: 'column', gap: 14 }}>
<div>
<p style={{ fontFamily: FF, fontSize: 10, color: c.accentLabel, textTransform: 'uppercase', letterSpacing: '0.25em', fontWeight: 700, marginBottom: 6 }}>{fallbackText(formContent.nominationCompany.eyebrow, participationContent.form.nominationCompany.eyebrow)}</p>
<h3 style={{ fontFamily: FF, fontSize: 16, fontWeight: 900, color: c.heading, textTransform: 'uppercase', letterSpacing: '-0.01em' }}>{fallbackText(formContent.nominationCompany.heading, participationContent.form.nominationCompany.heading)}</h3>
<p className="type-eyebrow" style={{ color: c.accentLabel, marginBottom: 6 }}>{fallbackText(formContent.nominationCompany.eyebrow, participationContent.form.nominationCompany.eyebrow)}</p>
<h3 className="type-card-title" style={{ color: c.heading, textTransform: 'uppercase' }}>{fallbackText(formContent.nominationCompany.heading, participationContent.form.nominationCompany.heading)}</h3>
</div>
<div style={{ display: 'grid', gridTemplateColumns: isMobile ? '1fr' : '1fr 1fr', gap: 14 }}>
<Field c={c} label={fallbackText(formContent.nominationCompany.companyLabel, participationContent.form.nominationCompany.companyLabel)} error={errors.nomFirma} requiredSuffix={fallbackText(formContent.requiredSuffix, participationContent.form.requiredSuffix)}>
@@ -535,8 +535,8 @@ export default function BewerbungsForm({ theme = 'dark', content }: { theme?: 'd
{step === 2 && data.type === 'vorschlag' && (
<div style={{ display: 'flex', flexDirection: 'column', gap: 14 }}>
<div>
<p style={{ fontFamily: FF, fontSize: 10, color: c.accentLabel, textTransform: 'uppercase', letterSpacing: '0.25em', fontWeight: 700, marginBottom: 6 }}>{fallbackText(formContent.nominationContact.eyebrow, participationContent.form.nominationContact.eyebrow)}</p>
<h3 style={{ fontFamily: FF, fontSize: 16, fontWeight: 900, color: c.heading, textTransform: 'uppercase', letterSpacing: '-0.01em' }}>{fallbackText(formContent.nominationContact.heading, participationContent.form.nominationContact.heading)}</h3>
<p className="type-eyebrow" style={{ color: c.accentLabel, marginBottom: 6 }}>{fallbackText(formContent.nominationContact.eyebrow, participationContent.form.nominationContact.eyebrow)}</p>
<h3 className="type-card-title" style={{ color: c.heading, textTransform: 'uppercase' }}>{fallbackText(formContent.nominationContact.heading, participationContent.form.nominationContact.heading)}</h3>
</div>
<div style={{ display: 'grid', gridTemplateColumns: isMobile ? '1fr' : '1fr 1fr', gap: 14 }}>
<Field c={c} label={fallbackText(formContent.nominationContact.nameLabel, participationContent.form.nominationContact.nameLabel)} error={errors.nomName} requiredSuffix={fallbackText(formContent.requiredSuffix, participationContent.form.requiredSuffix)}>
@@ -549,7 +549,7 @@ export default function BewerbungsForm({ theme = 'dark', content }: { theme?: 'd
<StyledInput c={c} value={data.nomBeziehung} onChange={e => set('nomBeziehung', e.target.value)} placeholder={fallbackText(formContent.nominationContact.relationshipPlaceholder, participationContent.form.nominationContact.relationshipPlaceholder)} />
</Field>
</div>
<p style={{ fontFamily: FF, fontSize: 12, color: c.mutedText, lineHeight: 1.6 }}>
<p className="type-caption" style={{ color: c.mutedText }}>
{fallbackText(formContent.nominationContact.footnote, participationContent.form.nominationContact.footnote)}
</p>
</div>
@@ -559,8 +559,8 @@ export default function BewerbungsForm({ theme = 'dark', content }: { theme?: 'd
{step === 3 && data.type === 'vorschlag' && (
<div style={{ display: 'flex', flexDirection: 'column', gap: 14 }}>
<div>
<p style={{ fontFamily: FF, fontSize: 10, color: c.accentLabel, textTransform: 'uppercase', letterSpacing: '0.25em', fontWeight: 700, marginBottom: 6 }}>{fallbackText(formContent.nominationSummary.eyebrow, participationContent.form.nominationSummary.eyebrow)}</p>
<h3 style={{ fontFamily: FF, fontSize: 16, fontWeight: 900, color: c.heading, textTransform: 'uppercase', letterSpacing: '-0.01em' }}>{fallbackText(formContent.nominationSummary.heading, participationContent.form.nominationSummary.heading)}</h3>
<p className="type-eyebrow" style={{ color: c.accentLabel, marginBottom: 6 }}>{fallbackText(formContent.nominationSummary.eyebrow, participationContent.form.nominationSummary.eyebrow)}</p>
<h3 className="type-card-title" style={{ color: c.heading, textTransform: 'uppercase' }}>{fallbackText(formContent.nominationSummary.heading, participationContent.form.nominationSummary.heading)}</h3>
</div>
<div style={{ borderTop: `1px solid ${c.border}` }}>
{[

View File

@@ -5,6 +5,7 @@ import { useIsMobile } from '@/spa/hooks/useIsMobile';
import { contactFormContent } from '@/spa/contactFormContent';
const FF = '"IBM Plex Sans", sans-serif';
const SUBMISSION_ERROR = 'Die Nachricht konnte nicht gespeichert werden. Bitte versuchen Sie es später erneut.';
const DARK = {
border: 'rgba(255,255,255,0.12)',
@@ -201,12 +202,16 @@ export default function KontaktForm({ theme = 'dark', content }: { theme?: 'dark
const [step, setStep] = useState(0);
const [dir, setDir] = useState(1);
const [submitted, setSubmitted] = useState(false);
const [submitting, setSubmitting] = useState(false);
const [submissionError, setSubmissionError] = useState('');
const [website, setWebsite] = useState('');
const [data, setData] = useState<FormData>({ betreff: 'allgemein', name: '', email: '', nachricht: '' });
const [errors, setErrors] = useState<Partial<Record<keyof FormData, string>>>({});
const set = (k: keyof FormData, v: string) => {
setData(d => ({ ...d, [k]: v }));
setErrors(e => { const n = { ...e }; delete n[k]; return n; });
setSubmissionError('');
};
const validate = () => {
@@ -223,7 +228,33 @@ export default function KontaktForm({ theme = 'dark', content }: { theme?: 'dark
const next = () => { if (validate()) { setDir(1); setStep(s => s + 1); } };
const prev = () => { setDir(-1); setStep(s => s - 1); };
const submit = () => { if (validate()) setSubmitted(true); };
const submit = async () => {
if (!validate() || submitting) return;
setSubmissionError('');
setSubmitting(true);
try {
const response = await fetch('/api/contact-submissions/submit', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
subject: copy.subjects.find((subject) => subject.value === data.betreff)?.label || data.betreff,
name: data.name,
email: data.email,
message: data.nachricht,
website,
}),
});
if (!response.ok) throw new Error(`Contact submission failed with ${response.status}`);
setSubmitted(true);
} catch {
setSubmissionError(SUBMISSION_ERROR);
} finally {
setSubmitting(false);
}
};
if (submitted) {
return (
@@ -233,10 +264,10 @@ export default function KontaktForm({ theme = 'dark', content }: { theme?: 'dark
<div style={{ width: 56, height: 56, background: c.successIcon, display: 'flex', alignItems: 'center', justifyContent: 'center', margin: '0 auto 20px' }}>
<Check size={24} color={c.successIconIn} strokeWidth={2.5} />
</div>
<h3 style={{ fontSize: 20, fontWeight: 800, color: c.successHeading, marginBottom: 10, letterSpacing: '-0.02em', fontFamily: FF }}>
<h3 className="type-card-title" style={{ color: c.successHeading, marginBottom: 10 }}>
{fallbackText(copy.success.heading, contactFormContent.success.heading)}
</h3>
<p style={{ fontSize: 15, color: c.successBody, lineHeight: 1.7, maxWidth: 280, margin: '0 auto', fontFamily: FF, overflowWrap: 'anywhere' }}>
<p className="type-small" style={{ color: c.successBody, maxWidth: 280, margin: '0 auto', overflowWrap: 'anywhere' }}>
{fallbackText(copy.success.prefix, contactFormContent.success.prefix)} <strong style={{ color: c.successHeading }}>{data.name}</strong>. {fallbackText(copy.success.suffixBeforeEmail, contactFormContent.success.suffixBeforeEmail)}{' '}
<strong style={{ color: c.successIcon }}>{data.email}</strong>.
</p>
@@ -246,6 +277,19 @@ export default function KontaktForm({ theme = 'dark', content }: { theme?: 'dark
return (
<div style={{ display: 'flex', flexDirection: 'column', flex: 1, minHeight: 0 }}>
<div aria-hidden="true" style={{ position: 'absolute', left: '-10000px', width: 1, height: 1, overflow: 'hidden' }}>
<label htmlFor="contact-website">Website</label>
<input
id="contact-website"
name="website"
type="text"
tabIndex={-1}
autoComplete="off"
value={website}
onChange={(event) => setWebsite(event.target.value)}
/>
</div>
{/* Progress */}
<div style={{ height: 3, background: c.progressBg, flexShrink: 0 }}>
<div style={{ height: '100%', background: c.progressFill, width: step === 0 ? '0%' : `${(step / (copy.steps.length - 1)) * 100}%`, transition: 'width 0.4s ease' }} />
@@ -282,7 +326,7 @@ export default function KontaktForm({ theme = 'dark', content }: { theme?: 'dark
{step === 0 && (
<div style={{ display: 'flex', flexDirection: 'column', gap: 8 }}>
<p style={{ fontSize: 14, color: c.subtext, marginBottom: 12, fontFamily: FF }}>{fallbackText(copy.prompts.subject, contactFormContent.prompts.subject)}</p>
<p className="type-small" style={{ color: c.subtext, marginBottom: 12 }}>{fallbackText(copy.prompts.subject, contactFormContent.prompts.subject)}</p>
{copy.subjects.map(item => (
<BetreffCard key={item.value} c={c} item={item} selected={data.betreff === item.value}
onClick={() => { set('betreff', item.value); setDir(1); setStep(1); }} />
@@ -292,7 +336,7 @@ export default function KontaktForm({ theme = 'dark', content }: { theme?: 'dark
{step === 1 && (
<div style={{ display: 'flex', flexDirection: 'column', gap: 16 }}>
<p style={{ fontSize: 14, color: c.subtext, marginBottom: 4, fontFamily: FF }}>{fallbackText(copy.prompts.contact, contactFormContent.prompts.contact)}</p>
<p className="type-small" style={{ color: c.subtext, marginBottom: 4 }}>{fallbackText(copy.prompts.contact, contactFormContent.prompts.contact)}</p>
{([
{ label: copy.fields.nameLabel, key: 'name' as const, type: 'text', placeholder: copy.fields.namePlaceholder, error: errors.name },
{ label: copy.fields.emailLabel, key: 'email' as const, type: 'email', placeholder: copy.fields.emailPlaceholder, error: errors.email },
@@ -308,7 +352,7 @@ export default function KontaktForm({ theme = 'dark', content }: { theme?: 'dark
{step === 2 && (
<div style={{ display: 'flex', flexDirection: 'column', gap: 16 }}>
<p style={{ fontSize: 14, color: c.subtext, marginBottom: 4, fontFamily: FF }}>{fallbackText(copy.prompts.message, contactFormContent.prompts.message)}</p>
<p className="type-small" style={{ color: c.subtext, marginBottom: 4 }}>{fallbackText(copy.prompts.message, contactFormContent.prompts.message)}</p>
<div style={{ display: 'flex', flexDirection: 'column', gap: 6 }}>
<label style={{ fontSize: 11, fontWeight: 700, textTransform: 'uppercase', letterSpacing: '0.12em', color: c.labelText, fontFamily: FF }}>{fallbackText(copy.fields.messageLabel, contactFormContent.fields.messageLabel)}</label>
<StyledTextarea c={c} value={data.nachricht} onChange={e => set('nachricht', e.target.value)} rows={5} placeholder={fallbackText(copy.fields.messagePlaceholder, contactFormContent.fields.messagePlaceholder)} />
@@ -347,10 +391,10 @@ export default function KontaktForm({ theme = 'dark', content }: { theme?: 'dark
{fallbackText(copy.navigation.next, contactFormContent.navigation.next)} <ArrowRight size={14} />
</button>
) : (
<button type="button" onClick={submit} style={{
fontSize: 14, fontWeight: 700, color: c.btnText, background: c.btnBg, border: 'none', cursor: 'pointer',
<button type="button" onClick={submit} disabled={submitting} style={{
fontSize: 14, fontWeight: 700, color: c.btnText, background: c.btnBg, border: 'none', cursor: submitting ? 'wait' : 'pointer',
padding: '12px 28px', fontFamily: FF, display: 'flex', alignItems: 'center', gap: 8,
transition: 'background 0.15s',
transition: 'background 0.15s', opacity: submitting ? 0.7 : 1,
...(isMobile ? { width: '100%', justifyContent: 'center' } : {}),
}}
onMouseEnter={e => (e.currentTarget.style.background = c.btnBgHover)}
@@ -361,6 +405,7 @@ export default function KontaktForm({ theme = 'dark', content }: { theme?: 'dark
)}
</div>
)}
{submissionError && <p role="alert" className="type-caption" style={{ color: c.errorText, margin: '10px 0 0', textAlign: 'right' }}>{submissionError}</p>}
</div>
);
}

View File

@@ -72,10 +72,10 @@ export function NewsletterInterestForm({
<div style={{ width: 52, height: 52, background: '#111D55', display: 'flex', alignItems: 'center', justifyContent: 'center', marginBottom: 22 }}>
<Check size={22} color="#EFBF04" strokeWidth={3} />
</div>
<h4 style={{ fontFamily: FF, fontSize: isMobile ? 20 : 24, fontWeight: 900, color: '#111D55', textTransform: 'uppercase', letterSpacing: '-0.02em', margin: '0 0 12px' }}>
<h4 className="type-card-title text-role-heading" style={{ textTransform: 'uppercase', margin: '0 0 12px' }}>
{copy.successHeading}
</h4>
<p style={{ fontFamily: FF, fontSize: 16, color: 'rgba(17,29,85,0.66)', lineHeight: 1.7, margin: 0, maxWidth: 430 }}>
<p className="type-body text-role-secondary" style={{ margin: 0, maxWidth: 430 }}>
{copy.successBody}
</p>
</div>
@@ -95,7 +95,7 @@ export function NewsletterInterestForm({
minHeight: 0,
}}
>
<p style={{ fontFamily: FF, fontSize: 16, color: 'rgba(17,29,85,0.68)', lineHeight: 1.75, margin: '0 0 28px', maxWidth: 520 }}>
<p className="type-body text-role-secondary" style={{ margin: '0 0 28px', maxWidth: 520 }}>
{copy.formBody}
</p>
<div aria-hidden="true" style={{ position: 'absolute', left: '-10000px', width: 1, height: 1, overflow: 'hidden' }}>
@@ -168,7 +168,7 @@ export function NewsletterInterestForm({
{copy.submitLabel} <Mail size={14} />
</button>
</div>
<p style={{ fontFamily: FF, fontSize: 13, color: 'rgba(17,29,85,0.48)', lineHeight: 1.6, margin: '18px 0 0', maxWidth: 520 }}>
<p className="type-caption text-role-muted" style={{ margin: '18px 0 0', maxWidth: 520 }}>
{copy.privacy}
</p>
</form>

View File

@@ -1,8 +1,10 @@
import React, { useState } from 'react';
import { AnimatePresence, motion } from 'framer-motion';
import { ArrowRight, ArrowLeft, Check, Layers, Building2, Mail } from 'lucide-react';
import { ArrowRight, ArrowLeft, Check, Building2, Mail } from 'lucide-react';
import { useIsMobile } from '@/spa/hooks/useIsMobile';
import { useApplicationPhase } from '@/spa/cmsRoute';
import { netzwerkContent } from '@/spa/netzwerkContent';
import { newsletterPhaseForApplicationPhase } from '@/spa/newsletterForm';
const FF = '"IBM Plex Sans", sans-serif';
@@ -87,23 +89,21 @@ const GOLD = {
type C = typeof DARK;
type FormData = {
paket: string;
unternehmen: string;
branche: string;
kontakt: string;
email: string;
};
const STEP_ICONS = [Layers, Building2, Mail];
const STEP_ICONS = [Building2, Mail];
type SponsoringFormContent = Partial<typeof netzwerkContent.sponsoringForm>;
type PackageOption = (typeof netzwerkContent.sponsoringForm.packages)[number];
type StepOption = (typeof netzwerkContent.sponsoringForm.steps)[number];
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 isPremiumPackage = (pkg: PackageOption) =>
[pkg.value, pkg.title].some((value) => typeof value === 'string' && value.toLowerCase().includes('premium'));
const numberedStepLabel = (value: unknown, fallback: string, step: number) =>
fallbackText(value, fallback).replace(/^Schritt\s+\d+/i, `Schritt ${step}`);
const variants = {
enter: (dir: number) => ({ x: dir > 0 ? 40 : -40, opacity: 0 }),
@@ -142,51 +142,30 @@ function StyledInput({ c, ...props }: React.InputHTMLAttributes<HTMLInputElement
);
}
function PaketCard({ c, pkg, selected, onClick }: { c: C; pkg: PackageOption; selected: boolean; onClick: () => void }) {
return (
<button type="button" onClick={onClick} style={{
width: '100%', padding: '13px 18px', textAlign: 'left', cursor: 'pointer',
border: `2px solid ${selected ? c.cardBorderSel : c.cardBorder}`,
background: selected ? c.cardBgSel : c.cardBg,
fontFamily: FF, transition: 'all 0.15s',
display: 'flex', alignItems: 'flex-start', justifyContent: 'space-between', gap: 12,
}}>
<div style={{ flex: 1 }}>
<div style={{ fontSize: 14, fontWeight: 700, textTransform: 'uppercase', letterSpacing: '0.06em', color: selected ? c.cardTitleSel : c.cardTitle, marginBottom: 5 }}>{pkg.title}</div>
<div style={{ fontSize: 13, color: c.cardDesc, lineHeight: 1.45 }}>{pkg.desc}</div>
</div>
<div style={{
width: 18, height: 18, borderRadius: '50%', flexShrink: 0, marginTop: 2,
border: `2px solid ${selected ? c.cardBorderSel : c.cardBorder}`,
background: selected ? c.radioFill : 'transparent',
transition: 'all 0.15s',
}} />
</button>
);
}
export default function SponsoringForm({ theme = 'dark', content }: { theme?: 'dark' | 'gold'; content?: SponsoringFormContent }) {
const c = theme === 'gold' ? GOLD : DARK;
const isMobile = useIsMobile();
const applicationPhase = useApplicationPhase();
const copy = { ...netzwerkContent.sponsoringForm, ...(content || {}) };
const steps = fallbackArray<StepOption>(copy.steps, netzwerkContent.sponsoringForm.steps);
const packages = fallbackArray<PackageOption>(copy.packages, netzwerkContent.sponsoringForm.packages).filter((pkg) => !isPremiumPackage(pkg));
const [step, setStep] = useState(0);
const [dir, setDir] = useState(1);
const [submitted, setSubmitted] = useState(false);
const [data, setData] = useState<FormData>({ paket: '', unternehmen: '', branche: '', kontakt: '', email: '' });
const [submitting, setSubmitting] = useState(false);
const [submissionError, setSubmissionError] = useState('');
const [data, setData] = useState<FormData>({ unternehmen: '', branche: '', kontakt: '', email: '' });
const [errors, setErrors] = useState<Partial<Record<keyof FormData, string>>>({});
const set = (k: keyof FormData, v: string) => {
setData(d => ({ ...d, [k]: v }));
setErrors(e => { const n = { ...e }; delete n[k]; return n; });
setSubmissionError('');
};
const validate = () => {
const e: typeof errors = {};
if (step === 0 && !data.paket) e.paket = fallbackText(copy.requiredPackageError, netzwerkContent.sponsoringForm.requiredPackageError);
if (step === 1 && !data.unternehmen) e.unternehmen = fallbackText(copy.requiredFieldError, netzwerkContent.sponsoringForm.requiredFieldError);
if (step === 2) {
if (step === 0 && !data.unternehmen) e.unternehmen = fallbackText(copy.requiredFieldError, netzwerkContent.sponsoringForm.requiredFieldError);
if (step === 1) {
if (!data.kontakt) e.kontakt = fallbackText(copy.requiredFieldError, netzwerkContent.sponsoringForm.requiredFieldError);
if (!data.email) e.email = fallbackText(copy.requiredFieldError, netzwerkContent.sponsoringForm.requiredFieldError);
else if (!/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(data.email)) e.email = fallbackText(copy.invalidEmailError, netzwerkContent.sponsoringForm.invalidEmailError);
@@ -197,7 +176,32 @@ export default function SponsoringForm({ theme = 'dark', content }: { theme?: 'd
const next = () => { if (validate()) { setDir(1); setStep(s => s + 1); } };
const prev = () => { setDir(-1); setStep(s => s - 1); };
const submit = () => { if (validate()) setSubmitted(true); };
const submit = async () => {
if (!validate() || submitting) return;
setSubmissionError('');
setSubmitting(true);
try {
const response = await fetch('/api/newsletter-subscriptions/subscribe', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
email: data.email.trim(),
phase: newsletterPhaseForApplicationPhase(applicationPhase?.activePhase),
source: 'network',
website: '',
}),
});
if (!response.ok) throw new Error(`Sponsoring request failed with ${response.status}`);
setSubmitted(true);
} catch {
setSubmissionError('Die Anfrage konnte nicht gespeichert werden. Bitte versuchen Sie es später erneut.');
} finally {
setSubmitting(false);
}
};
const progress = (step / (steps.length - 1)) * 100;
@@ -207,10 +211,10 @@ export default function SponsoringForm({ theme = 'dark', content }: { theme?: 'd
<div style={{ width: 52, height: 52, background: c.successIcon, display: 'flex', alignItems: 'center', justifyContent: 'center', margin: '0 auto 20px' }}>
<Check size={22} color={c.successIconIn} strokeWidth={3} />
</div>
<h3 style={{ fontSize: 18, fontWeight: 900, color: c.successHeading, textTransform: 'uppercase', letterSpacing: '-0.01em', marginBottom: 10 }}>
<h3 className="type-card-title" style={{ color: c.successHeading, textTransform: 'uppercase', marginBottom: 10 }}>
{fallbackText(copy.successHeading, netzwerkContent.sponsoringForm.successHeading)}
</h3>
<p style={{ fontSize: 12, color: c.successBody, maxWidth: 320, margin: '0 auto', lineHeight: 1.6 }}>
<p className="type-caption" style={{ color: c.successBody, maxWidth: 320, margin: '0 auto' }}>
{fallbackText(copy.successMessagePrefix, netzwerkContent.sponsoringForm.successMessagePrefix)} <strong style={{ color: c.successHeading }}>{data.kontakt}</strong>. {fallbackText(copy.successMessageSuffix, netzwerkContent.sponsoringForm.successMessageSuffix)}
</p>
</div>
@@ -227,7 +231,7 @@ export default function SponsoringForm({ theme = 'dark', content }: { theme?: 'd
{/* Step tabs */}
<div style={{ display: 'flex', borderBottom: `1px solid ${c.border}`, flexShrink: 0 }}>
{steps.map((s, i) => {
const Icon = STEP_ICONS[i] || Layers;
const Icon = STEP_ICONS[i] || Building2;
const done = i < step, active = i === step;
return (
<div key={i} style={{
@@ -240,7 +244,7 @@ export default function SponsoringForm({ theme = 'dark', content }: { theme?: 'd
marginBottom: -1, transition: 'color 0.2s',
}}>
<Icon size={11} strokeWidth={active ? 2 : 1.5} />
<span>{done ? fallbackText(copy.doneLabel, netzwerkContent.sponsoringForm.doneLabel) : fallbackText(s.label, netzwerkContent.sponsoringForm.steps[i]?.label || '')}</span>
<span>{done ? fallbackText(copy.doneLabel, netzwerkContent.sponsoringForm.doneLabel) : fallbackText(s.label, '')}</span>
</div>
);
})}
@@ -254,25 +258,10 @@ export default function SponsoringForm({ theme = 'dark', content }: { theme?: 'd
transition={{ duration: 0.22, ease: 'easeInOut' }}>
{step === 0 && (
<div style={{ display: 'flex', flexDirection: 'column', gap: 10 }}>
<div>
<p style={{ fontFamily: FF, fontSize: 10, color: c.accentLabel, textTransform: 'uppercase', letterSpacing: '0.25em', fontWeight: 700, marginBottom: 6 }}>{fallbackText(copy.step1Eyebrow, netzwerkContent.sponsoringForm.step1Eyebrow)}</p>
<h3 style={{ fontFamily: FF, fontSize: 18, fontWeight: 900, color: c.heading, textTransform: 'uppercase', letterSpacing: '-0.01em', marginBottom: 12 }}>{fallbackText(copy.step1Heading, netzwerkContent.sponsoringForm.step1Heading)}</h3>
</div>
<div style={{ display: 'flex', flexDirection: 'column', gap: 6 }}>
{packages.map(pkg => (
<PaketCard key={pkg.value} c={c} pkg={pkg} selected={data.paket === pkg.value} onClick={() => set('paket', pkg.value)} />
))}
</div>
{errors.paket && <p style={{ fontFamily: FF, fontSize: 11, color: c.errorText, marginTop: 4 }}>{errors.paket}</p>}
</div>
)}
{step === 1 && (
<div style={{ display: 'flex', flexDirection: 'column', gap: 16 }}>
<div>
<p style={{ fontFamily: FF, fontSize: 10, color: c.accentLabel, textTransform: 'uppercase', letterSpacing: '0.25em', fontWeight: 700, marginBottom: 8 }}>{fallbackText(copy.step2Eyebrow, netzwerkContent.sponsoringForm.step2Eyebrow)}</p>
<h3 style={{ fontFamily: FF, fontSize: 18, fontWeight: 900, color: c.heading, textTransform: 'uppercase', letterSpacing: '-0.01em' }}>{fallbackText(copy.step2Heading, netzwerkContent.sponsoringForm.step2Heading)}</h3>
<p className="type-eyebrow" style={{ color: c.accentLabel, marginBottom: 8 }}>{numberedStepLabel(copy.step2Eyebrow, netzwerkContent.sponsoringForm.step2Eyebrow, 1)}</p>
<h3 className="type-card-title" style={{ color: c.heading, textTransform: 'uppercase' }}>{fallbackText(copy.step2Heading, netzwerkContent.sponsoringForm.step2Heading)}</h3>
</div>
<Field c={c} label={fallbackText(copy.companyLabel, netzwerkContent.sponsoringForm.companyLabel)} error={errors.unternehmen}>
<StyledInput c={c} value={data.unternehmen} onChange={e => set('unternehmen', e.target.value)} placeholder={fallbackText(copy.companyPlaceholder, netzwerkContent.sponsoringForm.companyPlaceholder)} />
@@ -283,11 +272,11 @@ export default function SponsoringForm({ theme = 'dark', content }: { theme?: 'd
</div>
)}
{step === 2 && (
{step === 1 && (
<div style={{ display: 'flex', flexDirection: 'column', gap: 16 }}>
<div>
<p style={{ fontFamily: FF, fontSize: 10, color: c.accentLabel, textTransform: 'uppercase', letterSpacing: '0.25em', fontWeight: 700, marginBottom: 8 }}>{fallbackText(copy.step3Eyebrow, netzwerkContent.sponsoringForm.step3Eyebrow)}</p>
<h3 style={{ fontFamily: FF, fontSize: 18, fontWeight: 900, color: c.heading, textTransform: 'uppercase', letterSpacing: '-0.01em' }}>{fallbackText(copy.step3Heading, netzwerkContent.sponsoringForm.step3Heading)}</h3>
<p className="type-eyebrow" style={{ color: c.accentLabel, marginBottom: 8 }}>{numberedStepLabel(copy.step3Eyebrow, netzwerkContent.sponsoringForm.step3Eyebrow, 2)}</p>
<h3 className="type-card-title" style={{ color: c.heading, textTransform: 'uppercase' }}>{fallbackText(copy.step3Heading, netzwerkContent.sponsoringForm.step3Heading)}</h3>
</div>
<Field c={c} label={fallbackText(copy.contactLabel, netzwerkContent.sponsoringForm.contactLabel)} error={errors.kontakt}>
<StyledInput c={c} value={data.kontakt} onChange={e => set('kontakt', e.target.value)} placeholder={fallbackText(copy.contactPlaceholder, netzwerkContent.sponsoringForm.contactPlaceholder)} />
@@ -331,9 +320,9 @@ export default function SponsoringForm({ theme = 'dark', content }: { theme?: 'd
{fallbackText(copy.nextLabel, netzwerkContent.sponsoringForm.nextLabel)} <ArrowRight size={13} />
</button>
) : (
<button type="button" onClick={submit} style={{
<button type="button" onClick={submit} disabled={submitting} style={{
fontFamily: FF, fontSize: 12, fontWeight: 700, textTransform: 'uppercase', letterSpacing: '0.1em',
color: c.btnText, background: c.btnBg, border: 'none', cursor: 'pointer',
color: c.btnText, background: c.btnBg, border: 'none', cursor: submitting ? 'wait' : 'pointer', opacity: submitting ? 0.7 : 1,
padding: isMobile ? '14px 24px' : '12px 24px', minHeight: isMobile ? 44 : undefined,
display: 'flex', alignItems: 'center', gap: 7, transition: 'background 0.15s',
}}
@@ -345,7 +334,9 @@ export default function SponsoringForm({ theme = 'dark', content }: { theme?: 'd
)}
</div>
<p style={{ fontFamily: FF, textAlign: 'center', fontSize: 12, color: c.footerText, paddingTop: 12 }}>
{submissionError && <p role="alert" className="type-caption" style={{ color: c.errorText, margin: '10px 0 0', textAlign: 'right' }}>{submissionError}</p>}
<p className="type-caption" style={{ textAlign: 'center', color: c.footerText, paddingTop: 12 }}>
{fallbackText(copy.footerText, netzwerkContent.sponsoringForm.footerText)}
</p>
</div>

View File

@@ -587,15 +587,9 @@ const Layout: React.FC<LayoutProps> = ({ children, header = defaultHeaderData, f
position: 'relative',
padding: isMobile ? '28px 24px 16px' : '36px 64px 0',
}}>
<h2 style={{
fontFamily: FF_DISPLAY,
fontWeight: 900,
fontSize: 'clamp(1.6rem, 3vw, 2.8rem)',
<h2 className="type-section-title text-role-inverse" style={{
textTransform: 'uppercase',
letterSpacing: '-0.03em',
color: '#fff',
margin: 0,
lineHeight: 1,
whiteSpace: isMobile ? 'normal' : 'nowrap',
}}>
{footer.headlinePrefix} <span style={{ color: GOLD }}>{footer.headlineAccent}</span> {footer.headlineSuffix}
@@ -663,11 +657,7 @@ const Layout: React.FC<LayoutProps> = ({ children, header = defaultHeaderData, f
style={{ width: 140, height: 'auto', marginBottom: 10, opacity: 0.9 }}
/>
<p style={{
fontFamily: FF_BODY,
fontSize: 15,
lineHeight: 1.65,
color: 'rgba(255,255,255,0.45)',
<p className="type-small text-role-inverse-secondary" style={{
margin: '0 0 16px 0',
maxWidth: 300,
}}>
@@ -778,13 +768,7 @@ const Layout: React.FC<LayoutProps> = ({ children, header = defaultHeaderData, f
{footer.linkColumns.map((column, index) => (
<React.Fragment key={column.label + index}>
<div style={{ minWidth: isMobile ? 'auto' : index === footer.linkColumns.length - 1 ? 120 : 160 }}>
<p style={{
fontFamily: FF_DISPLAY,
fontSize: 9,
fontWeight: 700,
textTransform: 'uppercase',
letterSpacing: '0.28em',
color: 'rgba(255,255,255,0.3)',
<p className="type-eyebrow text-role-inverse-muted" style={{
margin: '0 0 10px 0',
}}>{column.label}</p>
<ul style={{ listStyle: 'none', margin: 0, padding: 0, display: 'flex', flexDirection: 'column', gap: 8 }}>
@@ -864,13 +848,9 @@ const Layout: React.FC<LayoutProps> = ({ children, header = defaultHeaderData, f
justifyContent: 'space-between',
gap: isMobile ? 16 : 0,
}}>
<p style={{
fontFamily: FF_DISPLAY,
fontSize: 10,
fontWeight: 400,
<p className="type-caption text-role-inverse-muted" style={{
textTransform: 'uppercase',
letterSpacing: '0.2em',
color: 'rgba(255,255,255,0.3)',
margin: 0,
}}>
{footer.copyright}

View File

@@ -66,19 +66,20 @@ export default function LegalPage({ content, cmsKey }: { content: LegalPageConte
<div style={{ width: 32, height: 2, background: GOLD }} />
<span style={{ fontFamily: FF, fontSize: 9, fontWeight: 700, letterSpacing: '0.35em', textTransform: 'uppercase', color: GOLD }}>{fallbackText(hero.eyebrow, content.hero.eyebrow)}</span>
</div>
<h1 style={{ fontFamily: FF, fontWeight: 900, fontSize: isMobile ? 'clamp(1.8rem, 7vw, 2.6rem)' : 'clamp(2.4rem, 4vw, 3.2rem)', color: '#fff', textTransform: 'uppercase', letterSpacing: '-0.025em', lineHeight: 1.02, margin: '0 0 12px' }}>
<h1 className="type-page-title text-role-inverse" style={{ textTransform: 'uppercase', margin: '0 0 12px' }}>
{fallbackText(hero.heading, content.hero.heading)}
</h1>
<p
style={{ fontFamily: FB, fontSize: 15, color: 'rgba(255,255,255,0.45)', margin: 0 }}
className="type-small text-role-inverse-secondary"
style={{ margin: 0 }}
dangerouslySetInnerHTML={{ __html: fallbackText(hero.descriptionHtml, content.hero.descriptionHtml) }}
/>
</section>
<div style={{ maxWidth: 1100, margin: '0 auto', padding: isMobile ? '40px 24px' : '64px 80px', display: 'grid', gridTemplateColumns: isMobile ? '1fr' : '200px 1fr', gap: isMobile ? 40 : 80, alignItems: 'start' }}>
<div style={{ maxWidth: 1100, margin: '0 auto', padding: isMobile ? '40px 24px' : '64px 80px', display: 'grid', gridTemplateColumns: isMobile ? 'minmax(0, 1fr)' : '200px minmax(0, 1fr)', gap: isMobile ? 40 : 80, alignItems: 'start' }}>
{!isMobile && (
<nav style={{ position: 'sticky', top: 80 }}>
<div style={{ fontFamily: FF, fontSize: 9, fontWeight: 700, letterSpacing: '0.28em', textTransform: 'uppercase', color: GOLD, marginBottom: 16 }}>{fallbackText(navigation.tocTitle, content.navigation.tocTitle)}</div>
<div style={{ fontFamily: FF, fontSize: 12, fontWeight: 700, letterSpacing: '0.18em', textTransform: 'uppercase', color: 'var(--text-brand-on-light)', marginBottom: 16 }}>{fallbackText(navigation.tocTitle, content.navigation.tocTitle)}</div>
<ul style={{ listStyle: 'none', margin: 0, padding: 0, display: 'flex', flexDirection: 'column', gap: 2 }}>
{sections.map((item) => (
<li key={item.id}>
@@ -107,7 +108,7 @@ export default function LegalPage({ content, cmsKey }: { content: LegalPageConte
</nav>
)}
<div style={{ display: 'flex', flexDirection: 'column', gap: 56 }}>
<div style={{ display: 'flex', flexDirection: 'column', gap: 56, minWidth: 0 }}>
{sections.map((section) => (
<Section key={section.id} id={section.id} title={section.title}>
{fallbackArray<LegalContentItem>(section.items, []).filter(isLegalItem).map((item, index) => (
@@ -122,10 +123,6 @@ export default function LegalPage({ content, cmsKey }: { content: LegalPageConte
}
const body: React.CSSProperties = {
fontFamily: '"Inter", sans-serif',
fontSize: 15,
color: 'rgba(58,58,58,0.82)',
lineHeight: 1.85,
margin: '0 0 16px',
overflowWrap: 'anywhere',
}
@@ -134,7 +131,7 @@ function Section({ id, title, children }: { id: string; title: string; children:
return (
<div id={id} style={{ scrollMarginTop: 80 }}>
<div style={{ borderLeft: `3px solid ${GOLD}`, paddingLeft: 16, marginBottom: 28 }}>
<h2 style={{ fontFamily: FF, fontSize: 14, fontWeight: 700, textTransform: 'uppercase', letterSpacing: '0.2em', color: NAVY, margin: 0 }}>
<h2 className="type-subtitle text-role-heading" style={{ textTransform: 'uppercase', margin: 0 }}>
{title}
</h2>
</div>
@@ -151,7 +148,7 @@ function ContentItem({ item }: { item: LegalContentItem }) {
return <Legal html={item.html} />
case 'list':
return (
<ul style={{ ...body, paddingLeft: 20, display: 'flex', flexDirection: 'column', gap: 6 }}>
<ul className="type-body text-role-body" style={{ ...body, paddingLeft: 20, display: 'flex', flexDirection: 'column', gap: 6 }}>
{item.items.map((entry, index) => (
<li key={`${entry}-${index}`}>{entry}</li>
))}
@@ -179,15 +176,15 @@ function Paragraph({ item }: { item: Extract<LegalContentItem, { type: 'paragrap
? { fontStyle: 'italic', borderLeft: `3px solid ${GOLD}`, paddingLeft: 16, marginBottom: 32 }
: {}
return <p style={{ ...body, ...variantStyle }} dangerouslySetInnerHTML={{ __html: item.html }} />
return <p className="type-body text-role-body" style={{ ...body, ...variantStyle }} dangerouslySetInnerHTML={{ __html: item.html }} />
}
function SubSection({ num, title, children }: { num: string; title: string; children: React.ReactNode }) {
return (
<div style={{ marginBottom: 28 }}>
<div style={{ display: 'flex', alignItems: 'baseline', gap: 10, marginBottom: 10 }}>
<span style={{ fontFamily: FF, fontSize: 10, fontWeight: 700, color: GOLD, letterSpacing: '0.12em' }}>{num}.</span>
<h3 style={{ fontFamily: FF, fontSize: 12, fontWeight: 700, textTransform: 'uppercase', letterSpacing: '0.12em', color: NAVY, margin: 0 }}>{title}</h3>
<span style={{ fontFamily: FF, fontSize: 12, fontWeight: 700, color: 'var(--text-brand-on-light)', letterSpacing: '0.12em' }}>{num}.</span>
<h3 className="type-card-title text-role-heading" style={{ textTransform: 'uppercase', margin: 0 }}>{title}</h3>
</div>
{children}
</div>
@@ -197,7 +194,7 @@ function SubSection({ num, title, children }: { num: string; title: string; chil
function AddressBlock({ lines }: { lines: string[] }) {
const isMobile = useIsMobile()
return (
<address style={{ fontFamily: '"Inter", sans-serif', fontSize: isMobile ? 15 : 14, color: 'rgba(58,58,58,0.75)', lineHeight: 1.9, fontStyle: 'normal', background: 'rgba(17,29,85,0.04)', padding: '16px 20px', borderLeft: '2px solid rgba(239,191,4,0.4)', marginBottom: 16, overflowWrap: 'anywhere' }}>
<address className="type-small text-role-secondary" style={{ fontStyle: 'normal', background: 'rgba(17,29,85,0.04)', padding: '16px 20px', borderLeft: '2px solid rgba(239,191,4,0.4)', marginBottom: 16, overflowWrap: 'anywhere' }}>
{lines.map((line, index) => (
<React.Fragment key={`${line}-${index}`}>
{line}
@@ -212,7 +209,8 @@ function Legal({ html }: { html: string }) {
const isMobile = useIsMobile()
return (
<p
style={{ fontFamily: '"Inter", sans-serif', fontSize: isMobile ? 15 : 14, color: NAVY, lineHeight: 1.75, background: 'rgba(17,29,85,0.04)', padding: '12px 16px', borderLeft: `3px solid ${NAVY}`, margin: '0 0 16px', fontWeight: 500, overflowWrap: 'anywhere' }}
className="type-small text-role-link"
style={{ background: 'rgba(17,29,85,0.04)', padding: '12px 16px', borderLeft: `3px solid ${NAVY}`, margin: '0 0 16px', fontWeight: 500, overflowWrap: 'anywhere' }}
dangerouslySetInnerHTML={{ __html: html }}
/>
)

View File

@@ -604,22 +604,14 @@ function StepHeading({ step, total, title, subtitle, template }: { step: number;
{template.replace('{step}', String(step)).replace('{total}', String(total))}
</span>
</div>
<h3 style={{
<h3 className="type-card-title text-role-inverse" style={{
margin: 0,
fontSize: 20,
fontWeight: 600,
color: WHITE,
fontFamily: '"IBM Plex Sans", sans-serif',
letterSpacing: "-0.01em",
}}>
{title}
</h3>
{subtitle && (
<p style={{
<p className="type-caption text-role-inverse-secondary" style={{
margin: "6px 0 0",
fontSize: 13,
color: "rgba(255,255,255,0.45)",
fontFamily: '"IBM Plex Sans", sans-serif',
}}>
{subtitle}
</p>
@@ -1069,22 +1061,13 @@ function SuccessScreen({ copy }: { copy: WizardCopy }) {
</svg>
</div>
<h3 style={{
<h3 className="type-card-title text-role-inverse" style={{
margin: "0 0 10px",
fontSize: 22,
fontWeight: 700,
color: WHITE,
fontFamily: '"IBM Plex Sans", sans-serif',
letterSpacing: "-0.02em",
}}>
{success.heading}
</h3>
<p style={{
<p className="type-small text-role-inverse-secondary" style={{
margin: "0 0 28px",
fontSize: 14,
color: "rgba(255,255,255,0.55)",
fontFamily: '"IBM Plex Sans", sans-serif',
lineHeight: 1.6,
}}>
{success.message}
</p>

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,116 +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,
})
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,
@@ -132,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

@@ -3,7 +3,7 @@ export interface Winner {
slug: string;
name: string;
category: string;
year: number;
year?: number;
type: string;
awardGroup?: 'preistraeger' | 'goldene-bavaria';
winnerRank?: number;
@@ -21,7 +21,9 @@ export interface Winner {
hasMedia: boolean;
}
const winnerEntries: Omit<Winner, 'awardGroup' | 'winnerRank' | 'sortOrder' | 'showOnIndex'>[] = [
type StaticWinner = Omit<Winner, 'awardGroup' | 'winnerRank' | 'sortOrder' | 'showOnIndex'> & { year: number };
const winnerEntries: StaticWinner[] = [
// ── 2025 ──────────────────────────────────────────────────────────────────
{
id: '1', slug: 'techvision-muenchen',
@@ -326,4 +328,6 @@ export const WINNERS: Winner[] = winnerEntries.map((winner) => {
});
export const CATEGORIES = [...new Set(WINNERS.map(w => w.category))].sort();
export const YEARS = [...new Set(WINNERS.map(w => w.year))].sort((a, b) => b - a);
export const YEARS = [...new Set(WINNERS.map(w => w.year))]
.filter((year): year is number => typeof year === 'number')
.sort((a, b) => b - a);

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

@@ -0,0 +1,14 @@
import { resolveCmsRelationships, type CmsRouteDoc } from '@/spa/cmsRoute'
const FALLBACK_YEAR = 2025
const FALLBACK_LIMIT = 8
export function resolveHomeWinners(featured: unknown, cmsWinners: CmsRouteDoc[]) {
const selectedWinners = Array.isArray(featured) ? featured : []
if (selectedWinners.length) return resolveCmsRelationships(selectedWinners, cmsWinners)
return cmsWinners
.filter((winner) => Number(winner.year) === FALLBACK_YEAR)
.slice(0, FALLBACK_LIMIT)
}

26
src/spa/juryMembers.ts Normal file
View File

@@ -0,0 +1,26 @@
import { resolveCmsRelationships, type CmsRouteDoc } from '@/spa/cmsRoute'
const hasText = (value: unknown) => typeof value === 'string' && value.trim().length > 0
export function selectJuryDocs(selection: unknown, docs: CmsRouteDoc[]) {
if (selection === undefined) return docs
if (!Array.isArray(selection)) return []
return resolveCmsRelationships(selection, docs)
}
export function sortJuryMembersQuoteFirst<T extends object>(members: T[]) {
return members
.map((member, index) => ({ member, index }))
.sort((a, b) => {
const aSortable = a.member as { quote?: unknown; sortOrder?: number | null }
const bSortable = b.member as { quote?: unknown; sortOrder?: number | null }
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)
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' },
@@ -299,17 +296,9 @@ export const netzwerkContent = {
},
sponsoringForm: {
steps: [
{ label: 'Paket' },
{ 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 *',
@@ -325,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

@@ -34,8 +34,8 @@ export type SpaNewsletterFormData = {
completed: NewsletterInterestCopy
}
export type NewsletterSource = 'homepage' | 'participation'
export type NewsletterPhase = 'evaluation' | 'completed'
export type NewsletterSource = 'homepage' | 'participation' | 'network'
export type NewsletterPhase = 'open' | 'evaluation' | 'completed'
export const defaultNewsletterFormData: SpaNewsletterFormData = {
evaluation: {
@@ -142,5 +142,6 @@ export function newsletterFormCopyForPhase(data: SpaNewsletterFormData | undefin
}
export function newsletterPhaseForApplicationPhase(activePhase?: string): NewsletterPhase {
if (activePhase === '0') return 'open'
return activePhase === '1' ? 'evaluation' : 'completed'
}

View File

@@ -1,40 +1,32 @@
import React, { useMemo, useState } from 'react'
import { ChevronRight, Star } from 'lucide-react'
import React, { useState } from 'react'
import { ChevronRight } from 'lucide-react'
import AwardsGridSection, { type AwardsGridContent } from '@/spa/components/AwardsGridSection'
import HomeWinnersSection, { type HomeWinnersContent } from '@/spa/components/HomeWinnersSection'
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 { resolveCmsRelationship, useCmsCollection, useCmsRoute, type CmsRouteDoc } from '@/spa/cmsRoute'
import { WINNERS, type Winner } from '@/spa/data/winners'
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'
import { aboutContent } from '@/spa/aboutContent'
import { usePreistraegerPlaceholderImage } from '@/spa/preistraegerPlaceholder'
const FF = '"IBM Plex Sans", sans-serif'
const FB = '"Inter", sans-serif'
const NAVY = '#111D55'
const GOLD = '#EFBF04'
const CREAM = '#E4E2E3'
const WHITE = '#fff'
const HIDE_MEMBERSHIP_ABOUT_CTA = true
type AboutCms = Partial<typeof aboutContent> & {
hero?: Partial<typeof aboutContent.hero> & { backgroundImage?: unknown }
prize?: Partial<typeof aboutContent.prize> & { image?: unknown }
mittelstand?: Partial<typeof aboutContent.mittelstand> & { image?: unknown }
highlights?: Partial<typeof aboutContent.highlights> & {
fallbackImage?: unknown
fallbackWinner?: Partial<typeof aboutContent.highlights.fallbackWinner>
selectedWinners?: unknown[] | null
}
}
type TextRow = { text?: string | null }
const FALLBACK_HIGHLIGHTS = WINNERS.filter((w) => w.year === aboutContent.highlights.year).slice(0, 8)
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)
@@ -69,6 +61,12 @@ const isParticipationPage = (page: CmsRouteDoc) => {
return page.spaPath === '/teilnahme' || page.slug === 'teilnahme' || page.slug === 'participation' || title === 'teilnahme' || title === 'participation'
}
const isHomePage = (page: CmsRouteDoc) => {
const title = String(page.title || '').toLowerCase()
return page.spaPath === '/' || page.slug === 'startseite' || page.slug === 'home' || title === 'startseite' || title === 'home'
}
function HighlightedText({ text, highlight }: { text: string; highlight: string }) {
const index = text.indexOf(highlight)
if (!highlight || index < 0) return <>{text}</>
@@ -82,72 +80,24 @@ function HighlightedText({ text, highlight }: { text: string; highlight: string
)
}
const normalizeWinner = (
doc: CmsRouteDoc | undefined,
placeholderImage: string,
fallbackWinner: typeof aboutContent.highlights.fallbackWinner,
): Winner | undefined => {
if (!doc) return undefined
return {
id: String(doc.id),
slug: String(doc.slug || ''),
name: String(doc.title || fallbackWinner.name),
category: String(doc.category || fallbackWinner.category),
year: Number(doc.year || aboutContent.highlights.year),
type: String(doc.awardType || fallbackWinner.awardType),
img: mediaUrl(doc.image, placeholderImage),
shortDesc: String(doc.shortDesc || doc.description || doc.meta?.description || ''),
longDesc: String(doc.longDesc || doc.description || doc.meta?.description || ''),
quote: String(doc.quote || ''),
quotePerson: String(doc.quotePerson || doc.title || ''),
quoteRole: String(doc.quoteRole || ''),
location: String(doc.location || ''),
industry: String(doc.industry || ''),
website: String(doc.website || ''),
hasMedia: Boolean(doc.hasMedia),
}
}
const About: React.FC = () => {
const isMobile = useIsMobile()
const placeholderImage = usePreistraegerPlaceholderImage()
const isRelevanceStacked = useIsMobile(1024)
const cms = (useCmsRoute()?.doc?.about || {}) as AboutCms
const cmsWinners = useCmsCollection('preistraeger')
const cmsPages = useCmsCollection('pages')
const hero = { ...aboutContent.hero, ...(cms.hero || {}) }
const prize = { ...aboutContent.prize, ...(cms.prize || {}) }
const mittelstand = { ...aboutContent.mittelstand, ...(cms.mittelstand || {}) }
const highlights = useMemo(() => ({ ...aboutContent.highlights, ...(cms.highlights || {}) }), [cms.highlights])
const testimonials = { ...aboutContent.testimonials, ...(cms.testimonials || {}) }
const history = { ...aboutContent.history, ...(cms.history || {}) }
const goals = { ...aboutContent.goals, ...(cms.goals || {}) }
const values = { ...aboutContent.values, ...(cms.values || {}) }
const cta = { ...aboutContent.cta, ...(cms.cta || {}) }
const participationPage = cmsPages.find(isParticipationPage)
const homePage = cmsPages.find(isHomePage)
const participationAwardsGrid = (participationPage?.participation as { awardsGrid?: AwardsGridContent } | undefined)?.awardsGrid
const homeWinners = (homePage?.home as { winners?: HomeWinnersContent } | undefined)?.winners
const showSecondaryCta = !HIDE_MEMBERSHIP_ABOUT_CTA || !isMembershipCta(cta.secondaryCta)
const highlightFallbackWinner = useMemo(
() => ({ ...aboutContent.highlights.fallbackWinner, ...(highlights.fallbackWinner || {}) }),
[highlights],
)
const highlightWinners = useMemo(() => {
const selected = fallbackArray<unknown>(highlights.selectedWinners, [])
.map((entry) => normalizeWinner(resolveCmsRelationship(entry, cmsWinners), placeholderImage, highlightFallbackWinner))
.filter((winner): winner is Winner => Boolean(winner))
if (selected.length) return selected.slice(0, 8)
const isWinnerFromYear = (winner: Winner | undefined): winner is Winner =>
Boolean(winner) && winner?.year === Number(highlights.year || aboutContent.highlights.year)
const cmsByYear = cmsWinners
.map((winner) => normalizeWinner(winner, placeholderImage, highlightFallbackWinner))
.filter(isWinnerFromYear)
.slice(0, 8)
return cmsByYear.length ? cmsByYear : FALLBACK_HIGHLIGHTS
}, [cmsWinners, highlightFallbackWinner, highlights, placeholderImage])
return (
<div className="animate-fade-in">
@@ -169,7 +119,7 @@ const About: React.FC = () => {
{fallbackText(hero.eyebrow, aboutContent.hero.eyebrow)}
</span>
</div>
<h1 style={{ fontFamily: FF, fontSize: isMobile ? 'clamp(1.6rem, 6vw, 5rem)' : 'clamp(2.8rem, 6vw, 5rem)', fontWeight: 900, color: '#fff', lineHeight: 0.95, letterSpacing: '-0.03em', textTransform: 'uppercase', margin: '0 0 28px' }}>
<h1 className="type-display text-role-inverse" style={{ textTransform: 'uppercase', margin: '0 0 28px' }}>
{fallbackText(hero.heading, aboutContent.hero.heading).split('\n').map((line, index) => (
<React.Fragment key={`${line}-${index}`}>
{index > 0 && <br />}
@@ -177,7 +127,7 @@ const About: React.FC = () => {
</React.Fragment>
))}
</h1>
<p style={{ fontFamily: FB, fontSize: 18, color: 'rgba(255,255,255,0.65)', lineHeight: 1.7, maxWidth: 560, margin: '0 0 40px', fontWeight: 300 }}>
<p className="type-body text-role-inverse-secondary" style={{ maxWidth: 560, margin: '0 0 40px' }}>
{fallbackText(hero.description, aboutContent.hero.description)}
</p>
<div style={{ display: 'flex', flexWrap: 'wrap', gap: 16 }}>
@@ -210,70 +160,78 @@ 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: CREAM, 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: '#4A8FC9', textTransform: 'uppercase', letterSpacing: '0.32em', fontWeight: 700, display: 'block', marginBottom: 20 }}>{fallbackText(prize.eyebrow, aboutContent.prize.eyebrow)}</span>
<h2 style={{ fontFamily: FF, fontSize: 'clamp(2.1rem, 3.4vw, 3.1rem)', fontWeight: 900, color: '#101828', textTransform: 'uppercase', letterSpacing: '-0.025em', lineHeight: 1.04, margin: '0 0 20px' }}>
<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>
<h2 className="type-section-title text-role-heading" style={{ textTransform: 'uppercase', margin: '0 0 20px' }}>
<Lines text={fallbackText(prize.heading, aboutContent.prize.heading)} />
</h2>
</>
)}
<div style={{ width: 36, height: 2, background: GOLD, marginBottom: 28 }} />
{paragraphs(prize.body, aboutContent.prize.body).map((text, index, arr) => (
<p key={index} style={{ fontFamily: FB, fontSize: 19, color: 'rgba(16,24,40,0.8)', lineHeight: 1.8, maxWidth: 440, marginBottom: index === arr.length - 1 ? (isMobile ? 0 : 44) : 16 }}>
<p key={index} className="type-body text-role-body" style={{ maxWidth: 440, marginBottom: index === arr.length - 1 ? (isMobile ? 0 : 44) : 16 }}>
{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: GOLD, 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, ${CREAM} 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 style={{ fontFamily: FF, fontSize: 'clamp(1.9rem, 8vw, 2.6rem)', fontWeight: 900, color: '#fff', textTransform: 'uppercase', letterSpacing: '-0.025em', lineHeight: 1.04, 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%)'
@@ -286,53 +244,39 @@ const About: React.FC = () => {
{isMobile && (
<div style={{ position: 'absolute', left: 0, right: 0, bottom: 0, padding: '0 24px 24px', zIndex: 2 }}>
<span style={{ fontFamily: FF, fontSize: 10, color: '#EFBF04', textTransform: 'uppercase', letterSpacing: '0.32em', fontWeight: 700, display: 'block', marginBottom: 12 }}>{fallbackText(mittelstand.eyebrow, aboutContent.mittelstand.eyebrow)}</span>
<h2 style={{ fontFamily: FF, fontSize: 'clamp(1.9rem, 8vw, 2.6rem)', fontWeight: 900, color: '#fff', textTransform: 'uppercase', letterSpacing: '-0.025em', lineHeight: 1.04, margin: 0, textShadow: '0 2px 18px rgba(3,9,58,0.6)' }}>
<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(mittelstand.heading, aboutContent.mittelstand.heading)} />
</h2>
</div>
)}
</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 style={{ fontFamily: FF, fontSize: 'clamp(2rem, 3.2vw, 2.9rem)', fontWeight: 900, color: '#fff', textTransform: 'uppercase', letterSpacing: '-0.025em', lineHeight: 1.04, 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} style={{ fontFamily: FB, fontSize: 18, color: 'rgba(255,255,255,0.5)', lineHeight: 1.8, maxWidth: 440, marginBottom: index === arr.length - 1 ? 40 : 16 }}>
{text}
</p>
))}
</div>
</div>
</section>
<section style={{ background: '#111D55' }}>
<div style={{ padding: isMobile ? '48px 24px 32px' : '72px 80px 48px', display: 'flex', flexDirection: isMobile ? 'column' : 'row', alignItems: isMobile ? 'flex-start' : 'flex-end', gap: isMobile ? 16 : 0, justifyContent: 'space-between', maxWidth: 1600, margin: '0 auto' }}>
<div>
<span style={{ fontFamily: FF, fontSize: 10, fontWeight: 700, letterSpacing: '0.3em', textTransform: 'uppercase', color: '#EFBF04', display: 'block', marginBottom: 12 }}>{fallbackText(highlights.eyebrow, aboutContent.highlights.eyebrow)}</span>
<h2 style={{ fontFamily: FF, fontSize: 'clamp(2rem, 4vw, 3rem)', fontWeight: 900, color: '#fff', textTransform: 'uppercase', letterSpacing: '-0.02em', margin: 0, lineHeight: 1 }}>
<Lines text={fallbackText(highlights.heading, aboutContent.highlights.heading)} />
</h2>
</div>
<Link
to={fallbackText(highlights.cta?.url, aboutContent.highlights.cta.url)}
style={{ fontFamily: FF, fontSize: 15, fontWeight: 700, letterSpacing: '0.1em', textTransform: 'uppercase', color: GOLD, textDecoration: 'none', display: 'flex', alignItems: 'center', gap: 6, paddingBottom: 4, borderBottom: `1px solid ${GOLD}`, whiteSpace: 'nowrap' }}
<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' }}
>
{fallbackText(highlights.cta?.label, aboutContent.highlights.cta.label)} <ChevronRight size={14} />
</Link>
</div>
<div style={{ display: 'grid', gridTemplateColumns: isMobile ? undefined : 'repeat(4, 1fr)', gridAutoFlow: isMobile ? 'column' : undefined, gridTemplateRows: isMobile ? 'repeat(2, 1fr)' : undefined, gridAutoColumns: isMobile ? '66vw' : undefined, overflowX: isMobile ? 'auto' : undefined, scrollSnapType: isMobile ? 'x mandatory' : undefined, gap: isMobile ? 8 : 0, padding: isMobile ? '0 16px 8px' : 0, WebkitOverflowScrolling: 'touch', scrollbarWidth: 'none' }}>
{highlightWinners.map((w) => <HighlightCard key={w.id} winner={w} hoverLabel={fallbackText(highlights.hoverLabel, aboutContent.highlights.hoverLabel)} />)}
<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>
<HomeWinnersSection content={homeWinners} />
<TestimonialsSection data={testimonials} />
<section id="geschichte" style={{ overflow: 'hidden' }}>
@@ -341,7 +285,7 @@ const About: React.FC = () => {
{!isMobile && <div style={{ position: 'absolute', right: -24, bottom: -40, fontFamily: FF, fontSize: 'clamp(160px, 20vw, 240px)', fontWeight: 900, color: 'rgba(239,191,4,0.04)', lineHeight: 1, userSelect: 'none', pointerEvents: 'none', letterSpacing: '-0.04em' }}>{fallbackText(history.watermark, aboutContent.history.watermark)}</div>}
<span style={{ fontFamily: FF, fontSize: 10, color: GOLD, textTransform: 'uppercase', letterSpacing: '0.32em', fontWeight: 700, display: 'block', marginBottom: 16 }}>{fallbackText(history.eyebrow, aboutContent.history.eyebrow)}</span>
<h2 style={{ fontFamily: FF, fontSize: 'clamp(2rem, 3vw, 2.8rem)', fontWeight: 900, color: '#fff', textTransform: 'uppercase', letterSpacing: '-0.03em', lineHeight: 1.0, margin: '0 0 52px' }}>
<h2 className="type-section-title text-role-inverse" style={{ textTransform: 'uppercase', margin: '0 0 52px' }}>
{fallbackText(history.heading, aboutContent.history.heading).split('\n').map((line, index) => (
<React.Fragment key={`${line}-${index}`}>
{index > 0 && <br />}
@@ -365,15 +309,15 @@ const About: React.FC = () => {
</div>
<div style={{ background: '#fff', padding: isMobile ? '56px 24px' : '88px 80px', display: 'flex', flexDirection: 'column', justifyContent: 'center' }}>
<span style={{ fontFamily: FF, fontSize: 10, color: '#4A8FC9', textTransform: 'uppercase', letterSpacing: '0.32em', fontWeight: 700, display: 'block', marginBottom: 16 }}>{fallbackText(goals.eyebrow, aboutContent.goals.eyebrow)}</span>
<h2 style={{ fontFamily: FF, fontSize: 'clamp(2rem, 3vw, 2.8rem)', fontWeight: 900, color: NAVY, textTransform: 'uppercase', letterSpacing: '-0.03em', lineHeight: 1.0, margin: '0 0 40px' }}>
<span style={{ fontFamily: FF, fontSize: 10, color: 'var(--text-eyebrow-light)', textTransform: 'uppercase', letterSpacing: '0.32em', fontWeight: 700, display: 'block', marginBottom: 16 }}>{fallbackText(goals.eyebrow, aboutContent.goals.eyebrow)}</span>
<h2 className="type-section-title text-role-heading" style={{ textTransform: 'uppercase', margin: '0 0 40px' }}>
<Lines text={fallbackText(goals.heading, aboutContent.goals.heading)} />
</h2>
<div>
{fallbackArray(goals.items, aboutContent.goals.items).map((item, i, arr) => (
<div key={`${item.num}-${i}`} style={{ display: 'flex', gap: 24, alignItems: 'flex-start', padding: '24px 0', borderTop: '1px solid rgba(17,29,85,0.1)', borderBottom: i === arr.length - 1 ? '1px solid rgba(17,29,85,0.1)' : 'none', cursor: 'default' }}>
<span style={{ fontFamily: FF, fontSize: 10, fontWeight: 700, color: GOLD, letterSpacing: '0.12em', paddingTop: 3, flexShrink: 0, minWidth: 24 }}>{fallbackText(item.num, '')}</span>
<span style={{ fontFamily: FF, fontSize: 10, fontWeight: 700, color: 'var(--text-brand-on-light)', letterSpacing: '0.12em', paddingTop: 3, flexShrink: 0, minWidth: 24 }}>{fallbackText(item.num, '')}</span>
<div>
<div style={{ fontFamily: FF, fontSize: 17, fontWeight: 800, color: NAVY, textTransform: 'uppercase', letterSpacing: '0.08em', marginBottom: 7 }}>{fallbackText(item.title, '')}</div>
<div style={{ fontFamily: FB, fontSize: 16, color: 'rgba(17,29,85,0.5)', lineHeight: 1.75 }}>{fallbackText(item.desc, '')}</div>
@@ -385,16 +329,16 @@ const About: React.FC = () => {
</div>
</section>
<section id="vorteile" style={{ background: CREAM, position: 'relative', overflow: 'hidden', isolation: 'isolate' }}>
<section id="vorteile" style={{ background: WHITE, position: 'relative', overflow: 'hidden', isolation: 'isolate' }}>
<MunichSkylineBg />
<div style={{ padding: isMobile ? '48px 24px 32px' : '88px 80px 56px', display: 'grid', gridTemplateColumns: isMobile ? '1fr' : '1fr 1fr', gap: isMobile ? 16 : 40, alignItems: 'flex-end', borderBottom: '1px solid #D0D5DD' }}>
<div>
<span style={{ fontFamily: FF, fontSize: 10, color: '#4A8FC9', textTransform: 'uppercase', letterSpacing: '0.32em', fontWeight: 700, display: 'block', marginBottom: 16 }}>{fallbackText(values.eyebrow, aboutContent.values.eyebrow)}</span>
<h2 style={{ fontFamily: FF, fontSize: 'clamp(2rem, 3.2vw, 2.9rem)', fontWeight: 900, color: '#101828', textTransform: 'uppercase', letterSpacing: '-0.025em', lineHeight: 1.04, margin: 0 }}>
<span style={{ fontFamily: FF, fontSize: 10, color: 'var(--text-eyebrow-light)', textTransform: 'uppercase', letterSpacing: '0.32em', fontWeight: 700, display: 'block', marginBottom: 16 }}>{fallbackText(values.eyebrow, aboutContent.values.eyebrow)}</span>
<h2 className="type-section-title text-role-heading" style={{ textTransform: 'uppercase', margin: 0 }}>
<Lines text={fallbackText(values.heading, aboutContent.values.heading)} />
</h2>
</div>
<p style={{ fontFamily: FB, fontSize: 18, color: 'rgba(16,24,40,0.5)', lineHeight: 1.8, margin: 0, maxWidth: 440 }}>
<p className="type-body text-role-secondary" style={{ margin: 0 }}>
{fallbackText(values.description, aboutContent.values.description)}
</p>
</div>
@@ -404,7 +348,7 @@ const About: React.FC = () => {
))}
</section>
<AwardsGridSection content={participationAwardsGrid} />
<AwardsGridSection content={participationAwardsGrid} featuredGoldeneBavaria />
<section style={{ background: NAVY, padding: isMobile ? '48px 24px' : '100px 80px', position: 'relative', overflow: 'hidden' }}>
<div style={{ position: 'absolute', top: 0, left: '50%', transform: 'translateX(-50%)', width: '60%', height: 1, background: `linear-gradient(to right, transparent, ${GOLD}, transparent)`, opacity: 0.3 }} />
@@ -416,10 +360,10 @@ const About: React.FC = () => {
<span style={{ fontFamily: FF, fontSize: 10, fontWeight: 700, letterSpacing: '0.3em', textTransform: 'uppercase', color: '#EFBF04' }}>{fallbackText(cta.eyebrow, aboutContent.cta.eyebrow)}</span>
<div style={{ width: 28, height: 1, background: GOLD, opacity: 0.6 }} />
</div>
<h2 style={{ fontFamily: FF, fontSize: 'clamp(2.2rem, 5vw, 3.5rem)', fontWeight: 900, color: '#fff', textTransform: 'uppercase', letterSpacing: '-0.02em', lineHeight: 1, margin: '0 0 24px' }}>
<h2 className="type-page-title text-role-inverse" style={{ textTransform: 'uppercase', margin: '0 0 24px' }}>
<Lines text={fallbackText(cta.heading, aboutContent.cta.heading)} />
</h2>
<p style={{ fontFamily: FB, fontSize: 18, color: 'rgba(255,255,255,0.55)', lineHeight: 1.8, marginBottom: 48, fontWeight: 300 }}>
<p className="type-body text-role-inverse-secondary" style={{ marginBottom: 48 }}>
{fallbackText(cta.description, aboutContent.cta.description)}
</p>
<Link
@@ -449,37 +393,6 @@ const About: React.FC = () => {
)
}
function HighlightCard({ winner, hoverLabel }: { winner: Winner; hoverLabel: string }) {
const [hovered, setHovered] = useState(false)
const isMobile = useIsMobile()
return (
<Link
to={`/preistraeger/${winner.slug}`}
style={{ textDecoration: 'none', display: 'block', position: 'relative', height: isMobile ? 220 : 280, overflow: 'hidden', scrollSnapAlign: isMobile ? 'start' : undefined }}
onMouseEnter={() => setHovered(true)}
onMouseLeave={() => setHovered(false)}
>
<Image unoptimized src={winner.img} alt={winner.name} style={{ width: '100%', height: '100%', objectFit: 'cover', display: 'block', transition: 'transform 0.6s ease', transform: hovered ? 'scale(1.07)' : 'scale(1)', filter: 'grayscale(15%)' }} />
<div style={{ position: 'absolute', inset: 0, background: 'linear-gradient(to top, rgba(0,0,0,0.78) 0%, rgba(0,0,0,0.1) 55%, transparent 100%)' }} />
<div style={{ position: 'absolute', inset: 0, background: 'rgba(0,0,0,0.2)', opacity: hovered ? 1 : 0, transition: 'opacity 0.25s' }} />
<div style={{ position: 'absolute', inset: 0, boxShadow: 'inset 0 0 0 1px rgba(255,255,255,0.06)' }} />
<div style={{ position: 'absolute', bottom: 0, left: 0, padding: '18px 20px' }}>
<div style={{ display: 'flex', alignItems: 'center', gap: 5, marginBottom: 5 }}>
<Star size={10} style={{ color: GOLD, flexShrink: 0 }} fill={GOLD} />
<span style={{ fontFamily: FF, fontSize: 10, fontWeight: 700, color: GOLD, letterSpacing: '0.12em', textTransform: 'uppercase' }}>
{winner.year} · {winner.type}
</span>
</div>
<h3 style={{ fontFamily: FF, fontSize: 16, fontWeight: 700, color: '#fff', margin: '0 0 3px', lineHeight: 1.3, textShadow: '0 1px 4px rgba(0,0,0,0.5)' }}>{winner.name}</h3>
<p style={{ fontFamily: FF, fontSize: 14, color: 'rgba(255,255,255,0.75)', margin: 0, letterSpacing: '0.03em' }}>{winner.category}</p>
</div>
<div style={{ position: 'absolute', bottom: 16, right: 16, background: GOLD, color: '#101828', padding: '6px 14px', borderRadius: 999, fontSize: 10, fontFamily: FF, fontWeight: 800, letterSpacing: '0.1em', textTransform: 'uppercase', opacity: hovered ? 1 : 0, transform: hovered ? 'translateY(0)' : 'translateY(6px)', transition: 'all 0.25s' }}>
{hoverLabel}
</div>
</Link>
)
}
function ValueRow({ item, last }: { item: { num?: string | null; title?: string | null }; last: boolean }) {
const [hovered, setHovered] = useState(false)
const isMobileRow = useIsMobile()
@@ -493,7 +406,7 @@ function ValueRow({ item, last }: { item: { num?: string | null; title?: string
<div style={{ fontFamily: FF, fontSize: 'clamp(2rem, 3vw, 2.8rem)', fontWeight: 900, color: hovered ? GOLD : 'rgba(16,24,40,0.1)', lineHeight: 1, letterSpacing: '-0.04em', transition: 'color 0.2s' }}>{fallbackText(item.num, '')}</div>
</div>
<div>
<h3 style={{ fontFamily: FF, fontSize: 'clamp(1.3rem, 2vw, 1.8rem)', fontWeight: 900, color: '#101828', textTransform: 'uppercase', letterSpacing: '-0.02em', lineHeight: 1.1, margin: 0 }}>{fallbackText(item.title, '')}</h3>
<h3 className="type-card-title text-role-heading" style={{ textTransform: 'uppercase', margin: 0 }}>{fallbackText(item.title, '')}</h3>
</div>
</div>
)

View File

@@ -96,7 +96,7 @@ export default function BlogDetail() {
if (!article) {
return (
<div style={{ minHeight: '60vh', display: 'flex', flexDirection: 'column', alignItems: 'center', justifyContent: 'center', gap: 24, padding: '80px 24px', background: PAGE_BG }}>
<h2 style={{ fontFamily: FF, fontSize: '2rem', fontWeight: 900, color: NAVY }}>{fallbackText(notFound.title, blogDetailContent.notFound.title)}</h2>
<h2 className="type-section-title text-role-heading">{fallbackText(notFound.title, blogDetailContent.notFound.title)}</h2>
<Link to={fallbackText(notFound.backUrl, blogDetailContent.notFound.backUrl)} style={{ fontFamily: FF, fontSize: 16, fontWeight: 700, color: NAVY, textDecoration: 'none', display: 'flex', alignItems: 'center', gap: 6 }}>
<ArrowLeft size={14} /> {fallbackText(notFound.backLabel, blogDetailContent.notFound.backLabel)}
</Link>
@@ -155,16 +155,11 @@ export default function BlogDetail() {
</div>
<motion.h1
className="type-page-title text-role-inverse"
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.5 }}
style={{
fontFamily: FF,
fontSize: isMobile ? 'clamp(1.6rem, 6vw, 2.4rem)' : 'clamp(2rem, 5vw, 3.8rem)',
fontWeight: 900,
color: '#fff',
lineHeight: 1.05,
letterSpacing: '-0.03em',
textTransform: 'uppercase',
margin: '0 0 28px',
maxWidth: 760,
@@ -189,14 +184,12 @@ export default function BlogDetail() {
{/* ── ARTICLE BODY ─────────────────────────────────────────────────── */}
<section style={{ background: PAGE_BG, padding: isMobile ? '48px 0 0' : '80px 0 0' }}>
<div style={{ maxWidth: 1100, margin: '0 auto', padding: isMobile ? '0 24px' : '0 80px', display: 'grid', gridTemplateColumns: isMobile ? '1fr' : '1fr 320px', gap: isMobile ? 40 : 80 }}>
<div style={{ maxWidth: 1100, margin: '0 auto', padding: isMobile ? '0 24px' : '0 80px', display: 'grid', gridTemplateColumns: isMobile ? 'minmax(0, 1fr)' : 'minmax(0, 1fr) 320px', gap: isMobile ? 40 : 80 }}>
{/* LEFT – article text */}
<article>
<article style={{ minWidth: 0 }}>
{/* Lead */}
<p style={{
fontFamily: FB, fontSize: 19, fontWeight: 300,
color: INK, lineHeight: 1.75,
<p className="type-lead text-role-body" style={{
borderLeft: `4px solid ${GOLD}`, paddingLeft: 28,
marginBottom: 56,
}}>
@@ -214,18 +207,15 @@ export default function BlogDetail() {
style={{ marginBottom: 48 }}
>
{section.heading && (
<h2 style={{
fontFamily: FF, fontSize: 'clamp(1.1rem, 1.8vw, 1.35rem)',
fontWeight: 900, color: NAVY,
textTransform: 'uppercase', letterSpacing: '-0.01em',
<h2 className="type-subtitle text-role-heading" style={{
textTransform: 'uppercase',
marginBottom: 16,
}}>
{section.heading}
</h2>
)}
<p style={{
fontFamily: FB, fontSize: 18,
color: 'rgba(58,58,58,0.8)', lineHeight: 1.8, margin: 0,
<p className="type-body text-role-body" style={{
margin: 0,
}}>
{section.body}
</p>
@@ -249,7 +239,7 @@ export default function BlogDetail() {
</article>
{/* RIGHT – sticky sidebar */}
<aside>
<aside style={{ minWidth: 0 }}>
<div style={{ position: isMobile ? 'static' : 'sticky', top: isMobile ? undefined : 100 }}>
{/* Author card */}
@@ -268,10 +258,10 @@ export default function BlogDetail() {
{/* CTA */}
<div style={{ background: '#fff', border: '1px solid rgba(3,9,58,0.1)', padding: '28px' }}>
<div style={{ width: 32, height: 2, background: GOLD, marginBottom: 16 }} />
<h4 style={{ fontFamily: FF, fontSize: 15, fontWeight: 900, color: NAVY, textTransform: 'uppercase', letterSpacing: '-0.01em', marginBottom: 12 }}>
<h4 className="type-card-title text-role-heading" style={{ textTransform: 'uppercase', marginBottom: 12 }}>
{fallbackText(sidebarCta.heading, blogDetailContent.sidebarCta.heading)}
</h4>
<p style={{ fontFamily: FB, fontSize: 16, color: 'rgba(58,58,58,0.55)', lineHeight: 1.7, marginBottom: 20 }}>
<p className="type-small text-role-secondary" style={{ marginBottom: 20 }}>
{fallbackText(sidebarCta.text, blogDetailContent.sidebarCta.text)}
</p>
{sidebarCtaIsExternal ? (
@@ -358,11 +348,11 @@ function RelatedCard({ article, bordered, isMobile, readMoreLabel }: { article:
<span style={{ fontFamily: FF, fontSize: 9, color: GOLD, textTransform: 'uppercase', letterSpacing: '0.22em', fontWeight: 700, marginBottom: 10 }}>
{article.cat}
</span>
<h3 style={{ fontFamily: FF, fontSize: 'clamp(1rem, 1.4vw, 1.15rem)', fontWeight: 700, color: '#fff', textTransform: 'uppercase', letterSpacing: '-0.01em', lineHeight: 1.25, marginBottom: 12 }}>
<h3 className="type-card-title text-role-inverse" style={{ textTransform: 'uppercase', marginBottom: 12 }}>
{article.title}
</h3>
<div style={{ width: hovered ? 64 : 24, height: 1, background: 'rgba(239,191,4,0.5)', transition: 'width 0.35s', marginBottom: 14 }} />
<p style={{ fontFamily: FB, fontSize: 16, color: 'rgba(255,255,255,0.38)', lineHeight: 1.7, flex: 1, marginBottom: 16 }}>
<p className="type-small text-role-inverse-muted" style={{ flex: 1, marginBottom: 16 }}>
{article.excerpt}
</p>
<span style={{ fontFamily: FF, fontSize: 10, fontWeight: 700, textTransform: 'uppercase', letterSpacing: '0.12em', color: GOLD, display: 'inline-flex', alignItems: 'center', gap: 5 }}>

View File

@@ -21,18 +21,18 @@ export default function CmsGenericPage() {
return (
<main style={{ minHeight: '70vh', background: CREAM, padding: '140px 24px 80px' }}>
<div style={{ maxWidth: 960, margin: '0 auto' }}>
<div style={{ color: GOLD, fontFamily: FF, fontWeight: 800, fontSize: 12, letterSpacing: '0.18em', textTransform: 'uppercase', marginBottom: 20 }}>
<div style={{ color: 'var(--text-brand-on-light)', fontFamily: FF, fontWeight: 700, fontSize: 12, letterSpacing: '0.18em', textTransform: 'uppercase', marginBottom: 20 }}>
{route?.collection || siteSettings.genericPageKicker}
</div>
<h1 style={{ color: NAVY, fontFamily: FF, fontSize: 'clamp(2.25rem, 7vw, 5rem)', lineHeight: 1, fontWeight: 900, textTransform: 'uppercase', margin: 0 }}>
<h1 className="type-page-title text-role-heading" style={{ textTransform: 'uppercase', margin: 0 }}>
{title}
</h1>
{description ? (
<p style={{ color: 'rgba(58,58,58,0.78)', fontFamily: FB, fontSize: 22, lineHeight: 1.6, marginTop: 28, maxWidth: 760 }}>
<p className="type-lead text-role-body" style={{ marginTop: 28, maxWidth: 760 }}>
{description}
</p>
) : (
<p style={{ color: 'rgba(58,58,58,0.65)', fontFamily: FB, fontSize: 18, lineHeight: 1.7, marginTop: 28, maxWidth: 760 }}>
<p className="type-body text-role-secondary" style={{ marginTop: 28, maxWidth: 760 }}>
{siteSettings.genericPageDescription}
</p>
)}

View File

@@ -11,7 +11,7 @@ import { contactContent } from '@/spa/contactContent';
const NAVY = '#111D55';
const GOLD = '#EFBF04';
const CREAM = '#EFE5E3';
const WHITE = '#fff';
const INK = '#3A3A3A';
type ContactCms = NonNullable<Page['contact']>;
@@ -53,7 +53,7 @@ const Contact: React.FC = () => {
const faqItems = fallbackArray<FaqItem>(faq.items, contactContent.faq.items);
return (
<div style={{ background: CREAM }}>
<div style={{ background: WHITE }}>
{/* ── Hero ───────────────────────────────────────────────────────── */}
<section style={{ position: 'relative', height: '68vh', minHeight: 520, overflow: 'hidden', display: 'flex', alignItems: 'flex-end' }}>
@@ -68,13 +68,13 @@ const Contact: React.FC = () => {
<div style={{ position: 'absolute', bottom: 0, left: 0, right: 0, height: 160, background: `linear-gradient(to top, ${NAVY}, transparent)` }} />
<div style={{ position: 'relative', zIndex: 2, width: '100%', paddingBottom: isMobile ? '3rem' : '5rem', paddingLeft: isMobile ? 24 : 80, paddingRight: isMobile ? 24 : 80 }}>
<p style={{ fontSize: 11, fontWeight: 800, letterSpacing: '0.28em', textTransform: 'uppercase', color: GOLD, marginBottom: 16 }}>
<p className="type-eyebrow text-role-brand-dark" style={{ marginBottom: 16 }}>
{fallbackText(hero.eyebrow, contactContent.hero.eyebrow)}
</p>
<h1 style={{ fontSize: isMobile ? 'clamp(2rem, 10vw, 3rem)' : 'clamp(3rem, 7vw, 6rem)', fontWeight: 900, lineHeight: 1.0, color: '#fff', letterSpacing: '-0.03em', marginBottom: 24, maxWidth: 640 }}>
<h1 className="type-display text-role-inverse" style={{ marginBottom: 24, maxWidth: 640 }}>
<Lines text={fallbackText(hero.heading, contactContent.hero.heading)} />
</h1>
<p style={{ fontSize: 18, color: 'rgba(255,255,255,0.72)', maxWidth: 480, lineHeight: 1.65, fontWeight: 400 }}>
<p className="type-body text-role-inverse-secondary" style={{ maxWidth: 480 }}>
{fallbackText(hero.description, contactContent.hero.description)}
</p>
</div>
@@ -89,11 +89,11 @@ const Contact: React.FC = () => {
{/* Left – info panel */}
<div style={{ padding: isMobile ? '48px 24px' : '88px 56px 88px 64px', display: 'flex', flexDirection: 'column', justifyContent: 'center', position: 'relative' }}>
<p style={{ fontSize: 10, fontWeight: 800, letterSpacing: '0.28em', textTransform: 'uppercase', color: 'rgba(255,255,255,0.4)', marginBottom: 20 }}>
<p className="type-eyebrow text-role-inverse-muted" style={{ marginBottom: 20 }}>
{fallbackText(info.eyebrow, contactContent.info.eyebrow)}
</p>
<div style={{ width: 36, height: 2, background: GOLD, marginBottom: 20 }} />
<h2 style={{ fontSize: 'clamp(1.6rem, 3vw, 2.4rem)', fontWeight: 900, color: '#fff', lineHeight: 1.15, letterSpacing: '-0.03em', marginBottom: 52 }}>
<h2 className="type-section-title text-role-inverse" style={{ marginBottom: 52 }}>
<Lines text={fallbackText(info.heading, contactContent.info.heading)} />
</h2>
@@ -120,7 +120,7 @@ const Contact: React.FC = () => {
</div>
<div style={{ marginTop: 52, paddingTop: 28, borderTop: '1px solid rgba(255,255,255,0.10)' }}>
<p style={{ fontSize: 15, color: 'rgba(255,255,255,0.40)', lineHeight: 1.65 }}>
<p className="type-small text-role-inverse-muted">
{fallbackText(info.nextAwardLabel, contactContent.info.nextAwardLabel)} <strong style={{ color: '#fff' }}>{fallbackText(info.nextAwardDate, contactContent.info.nextAwardDate)}</strong>
</p>
</div>
@@ -158,7 +158,7 @@ const Contact: React.FC = () => {
)}
<div style={{ padding: isMobile ? '36px 20px' : '32px 48px 32px 40px', flex: isMobile ? 'none' : 1, display: 'flex', flexDirection: 'column', minHeight: isMobile ? 480 : 0, position: 'relative', zIndex: 1 }}>
<span style={{ fontFamily: '"IBM Plex Sans", sans-serif', fontSize: 10, color: 'rgba(17,29,85,0.5)', textTransform: 'uppercase', letterSpacing: '0.32em', fontWeight: 700, display: 'block', marginBottom: 8 }}>{fallbackText(info.formEyebrow, contactContent.info.formEyebrow)}</span>
<h3 style={{ fontFamily: '"IBM Plex Sans", sans-serif', fontSize: 'clamp(1.2rem, 1.8vw, 1.6rem)', fontWeight: 900, color: '#111D55', letterSpacing: '-0.025em', textTransform: 'uppercase', lineHeight: 1.1, marginBottom: 24 }}>
<h3 className="type-card-title text-role-heading" style={{ textTransform: 'uppercase', marginBottom: 24 }}>
{fallbackText(info.formTitle, contactContent.info.formTitle)}
</h3>
<KontaktForm theme="gold" content={info.formCopy || undefined} />
@@ -210,17 +210,17 @@ const Contact: React.FC = () => {
</section>
{/* ── FAQ ────────────────────────────────────────────────────────── */}
<section id="faq" style={{ background: CREAM, padding: isMobile ? '56px 0 72px' : '96px 0 112px' }}>
<section id="faq" style={{ background: WHITE, padding: isMobile ? '56px 0 72px' : '96px 0 112px' }}>
<div className="container mx-auto px-6">
<div style={{ display: 'grid', gridTemplateColumns: isMobile ? '1fr' : '1fr 2fr', gap: isMobile ? 40 : 80 }}>
{/* Left – sticky label */}
<div style={isMobile ? undefined : { position: 'sticky', top: 96, alignSelf: 'start' }}>
<p style={{ fontSize: 10, fontWeight: 800, letterSpacing: '0.28em', textTransform: 'uppercase', color: '#4A8FC9', marginBottom: 20 }}>
<p className="type-eyebrow text-role-eyebrow" style={{ marginBottom: 20 }}>
{fallbackText(faq.eyebrow, contactContent.faq.eyebrow)}
</p>
<div style={{ width: 28, height: 2, background: GOLD, marginBottom: 20 }} />
<h2 style={{ fontSize: isMobile ? 'clamp(1.8rem, 8vw, 2.4rem)' : 'clamp(2rem, 3.5vw, 2.8rem)', fontWeight: 900, color: NAVY, letterSpacing: '-0.03em', lineHeight: 1.1 }}>
<h2 className="type-section-title text-role-heading">
<Lines text={fallbackText(faq.heading, contactContent.faq.heading)} />
</h2>
</div>

View File

@@ -1,7 +1,7 @@
import React, { useState } from 'react'
import React from 'react'
import { useParams, Link } from '@/spa/router'
import { motion } from 'framer-motion'
import { Calendar, MapPin, Clock, Bell, BellOff, ArrowLeft, ChevronRight } from 'lucide-react'
import { Calendar, MapPin, Clock, Bell, ArrowLeft, ChevronRight } from 'lucide-react'
import { getEventBySlug, EventUpdate, type BmpEvent, type EventEckdaten } from '@/spa/data/events'
import { useCmsRoute } from '@/spa/cmsRoute'
import { docImageUrl, mediaUrl } from '@/spa/cmsMediaField'
@@ -11,7 +11,7 @@ import Image from '@/spa/components/ui/UnoptimizedImage'
const NAVY = '#111D55'
const GOLD = '#EFBF04'
const CREAM = '#EFE5E3'
const WHITE = '#fff'
type EventDetailCms = Omit<Partial<typeof eventDetailContent>, 'updateStyles' | 'statusStyles'> & {
fallbackImage?: unknown
@@ -124,12 +124,11 @@ export default function EventDetail() {
const sections = detail.sections || eventDetailContent.sections
const updatesCopy = detail.updatesCopy || eventDetailContent.updates
const bottomCta = detail.bottomCta || eventDetailContent.bottomCta
const [subscribed, setSubscribed] = useState(false)
const isMobile = useIsMobile()
if (!event) {
return (
<div style={{ paddingTop: 60, minHeight: '100vh', display: 'flex', alignItems: 'center', justifyContent: 'center', background: CREAM, flexDirection: 'column', gap: 24 }}>
<div style={{ paddingTop: 60, minHeight: '100vh', display: 'flex', alignItems: 'center', justifyContent: 'center', background: WHITE, flexDirection: 'column', gap: 24 }}>
<div style={{ fontSize: 64, fontWeight: 900, color: `${NAVY}20`, letterSpacing: '-0.04em' }}>404</div>
<div style={{ fontSize: 20, fontWeight: 700, color: NAVY }}>{fallbackText(notFound.title, eventDetailContent.notFound.title)}</div>
<Link to={fallbackText(notFound.backUrl, eventDetailContent.notFound.backUrl)} style={{ fontSize: 15, color: NAVY, textDecoration: 'none', display: 'flex', alignItems: 'center', gap: 6, fontWeight: 600, borderBottom: `2px solid ${GOLD}`, paddingBottom: 2 }}>
@@ -143,7 +142,7 @@ export default function EventDetail() {
const statusStyle = statusStyles[event.status] ?? statusStyles.geplant
return (
<div style={{ paddingTop: 60, background: CREAM, minHeight: '100vh', fontFamily: '"Inter", sans-serif' }}>
<div style={{ background: WHITE, minHeight: '100vh', fontFamily: '"Inter", sans-serif' }}>
{/* ── Hero ─────────────────────────────────────────────────────────── */}
<section style={{ position: 'relative', height: isMobile ? 'auto' : '65vh', minHeight: isMobile ? 520 : 480, overflow: 'hidden' }}>
@@ -176,10 +175,10 @@ export default function EventDetail() {
{event.category}
</span>
</div>
<h1 style={{ fontSize: isMobile ? 'clamp(1.8rem, 8vw, 2.8rem)' : 'clamp(2rem, 5vw, 4rem)', fontWeight: 900, color: '#fff', letterSpacing: '-0.03em', lineHeight: 1.05, marginBottom: 12, fontFamily: '"IBM Plex Sans", sans-serif' }}>
<h1 className="type-page-title text-role-inverse" style={{ marginBottom: 12 }}>
{event.title}
</h1>
<p style={{ fontSize: isMobile ? 14 : 16, color: 'rgba(255,255,255,0.65)', maxWidth: 520, lineHeight: 1.6 }}>
<p className="type-small text-role-inverse-secondary" style={{ maxWidth: 520 }}>
{event.subtitle}
</p>
</div>
@@ -211,17 +210,17 @@ export default function EventDetail() {
<div>
{/* Description */}
<div style={{ marginBottom: 56 }}>
<p style={{ fontSize: 11, fontWeight: 800, textTransform: 'uppercase', letterSpacing: '0.25em', color: GOLD, marginBottom: 16 }}>
<p className="type-eyebrow text-role-brand-light" style={{ marginBottom: 16 }}>
{fallbackText(sections.aboutEyebrow, eventDetailContent.sections.aboutEyebrow)}
</p>
{event.longDescription.split('\n\n').map((para, i) => (
<p key={i} style={{ fontSize: 17, color: 'rgba(58,58,58,0.75)', lineHeight: 1.8, marginBottom: 20 }}>{para}</p>
<p key={i} className="type-body text-role-body" style={{ marginBottom: 20 }}>{para}</p>
))}
</div>
{/* Eckdaten */}
<div>
<p style={{ fontSize: 11, fontWeight: 800, textTransform: 'uppercase', letterSpacing: '0.25em', color: GOLD, marginBottom: 24 }}>
<p className="type-eyebrow text-role-brand-light" style={{ marginBottom: 24 }}>
{fallbackText(sections.factsEyebrow, eventDetailContent.sections.factsEyebrow)}
</p>
<div style={{ display: 'grid', gridTemplateColumns: isMobile ? '1fr' : '1fr 1fr', gap: 2 }}>
@@ -275,7 +274,7 @@ export default function EventDetail() {
{/* Right – Live Updates Feed (sticky on desktop, static on mobile) */}
<div style={isMobile ? undefined : { position: 'sticky', top: 80 }}>
{/* Header */}
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', marginBottom: 20 }}>
<div style={{ display: 'flex', alignItems: 'center', marginBottom: 20 }}>
<div style={{ display: 'flex', alignItems: 'center', gap: 10 }}>
<Bell size={18} color={NAVY} />
<span style={{ fontSize: 17, fontWeight: 800, color: NAVY, fontFamily: '"IBM Plex Sans", sans-serif' }}>
@@ -286,32 +285,8 @@ export default function EventDetail() {
borderRadius: 20, padding: '2px 8px',
}}>{event.updates.length}</span>
</div>
<button
onClick={() => setSubscribed(s => !s)}
style={{
display: 'flex', alignItems: 'center', gap: 6, cursor: 'pointer',
background: subscribed ? NAVY : 'transparent',
color: subscribed ? GOLD : NAVY,
border: `1.5px solid ${NAVY}`, borderRadius: 8,
padding: '8px 14px', fontSize: 12, fontWeight: 700,
transition: 'all 0.2s', fontFamily: 'inherit',
}}
>
{subscribed ? <><BellOff size={13} /> {fallbackText(updatesCopy.unsubscribeLabel, eventDetailContent.updates.unsubscribeLabel)}</> : <><Bell size={13} /> {fallbackText(updatesCopy.subscribeLabel, eventDetailContent.updates.subscribeLabel)}</>}
</button>
</div>
{subscribed && (
<motion.div
initial={{ opacity: 0, y: -8 }}
animate={{ opacity: 1, y: 0 }}
style={{ background: '#ECFDF5', border: '1px solid #A7F3D0', borderRadius: 10, padding: '10px 14px', marginBottom: 16, fontSize: 14, color: '#065F46', fontWeight: 500 }}
>
{fallbackText(updatesCopy.subscribedMessage, eventDetailContent.updates.subscribedMessage)}
</motion.div>
)}
{/* Timeline */}
<div style={{ maxHeight: isMobile ? 'none' : 560, overflowY: isMobile ? 'visible' : 'auto', paddingRight: 4 }}>
{event.updates.map((update, i) => (
@@ -326,12 +301,12 @@ export default function EventDetail() {
<section style={{ background: NAVY, padding: isMobile ? '48px 24px' : '64px 80px', marginTop: 40 }}>
<div style={{ maxWidth: 1400, margin: '0 auto', display: 'flex', flexDirection: isMobile ? 'column' : 'row', justifyContent: 'space-between', alignItems: isMobile ? 'flex-start' : 'center', gap: isMobile ? 24 : 0 }}>
<div>
<p style={{ fontSize: 11, fontWeight: 800, textTransform: 'uppercase', letterSpacing: '0.25em', color: GOLD, marginBottom: 10 }}>
<p className="type-eyebrow text-role-brand-dark" style={{ marginBottom: 10 }}>
{fallbackText(bottomCta.eyebrow, eventDetailContent.bottomCta.eyebrow)}
</p>
<h3 style={{ fontSize: 28, fontWeight: 900, color: '#fff', letterSpacing: '-0.03em', fontFamily: '"IBM Plex Sans", sans-serif' }}>
<h2 className="type-section-title text-role-inverse">
{fallbackText(bottomCta.heading, eventDetailContent.bottomCta.heading)}
</h3>
</h2>
</div>
<Link
to={fallbackText(bottomCta.url, eventDetailContent.bottomCta.url)}

View File

@@ -84,11 +84,11 @@ export default function FormularUpload() {
<span style={{ fontFamily: FF, fontSize: 10, fontWeight: 700, textTransform: 'uppercase', letterSpacing: '0.35em', color: HELLBLAU, display: 'block', marginBottom: 16 }}>
{fallbackText(hero.eyebrow, formularUploadContent.hero.eyebrow)}
</span>
<h1 style={{ fontFamily: FF, fontSize: isMobile ? 'clamp(1.6rem, 4vw, 3.6rem)' : 'clamp(2.2rem, 4vw, 3.6rem)', fontWeight: 900, color: '#fff', textTransform: 'uppercase', letterSpacing: '-0.025em', lineHeight: 1.0, margin: '0 0 20px' }}>
<h1 className="type-page-title text-role-inverse" style={{ textTransform: 'uppercase', margin: '0 0 20px' }}>
<Lines text={fallbackText(hero.heading, formularUploadContent.hero.heading)} />
</h1>
<div style={{ width: 48, height: 3, background: GOLD, marginBottom: 24 }} />
<p style={{ fontFamily: FF, fontSize: 16, color: 'rgba(255,255,255,0.55)', lineHeight: 1.75, maxWidth: 520, margin: 0 }}>
<p className="type-body text-role-inverse-secondary" style={{ maxWidth: 520, margin: 0 }}>
{fallbackText(hero.description, formularUploadContent.hero.description)}
</p>
</div>
@@ -106,13 +106,13 @@ export default function FormularUpload() {
{/* Breadcrumb */}
<div style={{ background: '#fff', borderBottom: `1px solid ${BORDER}`, padding: isMobile ? '16px 24px' : '16px 80px' }}>
<span style={{ fontFamily: FF, fontSize: 12, color: HELLBLAU, fontWeight: 700, letterSpacing: '0.04em' }}>{fallbackText(breadcrumb.label, formularUploadContent.breadcrumb.label)}</span>
<span style={{ fontFamily: FF, fontSize: 12, color: 'var(--text-eyebrow-light)', fontWeight: 700, letterSpacing: '0.04em' }}>{fallbackText(breadcrumb.label, formularUploadContent.breadcrumb.label)}</span>
</div>
{/* ── ERKLÄRUNG ───────────────────────────────────── */}
<div style={{ background: GRAU, padding: isMobile ? '48px 24px' : '64px 80px' }}>
<p style={{ fontFamily: FF, fontSize: 11, color: '#888', textTransform: 'uppercase', letterSpacing: '0.12em', marginBottom: 8 }}>{fallbackText(requirements.eyebrow, formularUploadContent.requirements.eyebrow)}</p>
<h2 style={{ fontFamily: FF, fontSize: 'clamp(1.4rem, 2.2vw, 2rem)', fontWeight: 900, color: DARK, textTransform: 'uppercase', letterSpacing: '-0.02em', marginBottom: 40 }}>
<p className="type-eyebrow text-role-eyebrow" style={{ marginBottom: 8 }}>{fallbackText(requirements.eyebrow, formularUploadContent.requirements.eyebrow)}</p>
<h2 className="type-section-title text-role-heading" style={{ textTransform: 'uppercase', marginBottom: 40 }}>
<Lines text={fallbackText(requirements.heading, formularUploadContent.requirements.heading)} />
</h2>
@@ -125,7 +125,7 @@ export default function FormularUpload() {
<div style={{ width: 40, height: 40, background: DARK, display: 'flex', alignItems: 'center', justifyContent: 'center', marginBottom: 20 }}>
<Icon size={18} color={GOLD} />
</div>
<h3 style={{ fontFamily: FF, fontSize: 14, fontWeight: 700, color: DARK, textTransform: 'uppercase', letterSpacing: '0.06em', marginBottom: 16 }}>{fallbackText(card.title, fallbackCard.title)}</h3>
<h3 className="type-small text-role-heading" style={{ fontWeight: 700, textTransform: 'uppercase', marginBottom: 16 }}>{fallbackText(card.title, fallbackCard.title)}</h3>
<ul style={{ listStyle: 'none', padding: 0, margin: 0, display: 'flex', flexDirection: 'column', gap: 10 }}>
{lines(card.items, fallbackCard.items).map((item, j) => (
<li key={j} style={{ display: 'flex', alignItems: 'flex-start', gap: 10, fontFamily: FF, fontSize: 14, color: '#555', lineHeight: 1.5 }}>
@@ -142,8 +142,8 @@ export default function FormularUpload() {
{/* ── UPLOAD-BEREICH ──────────────────────────────── */}
<div style={{ background: NAVY, padding: isMobile ? '48px 24px' : '72px 80px' }}>
<p style={{ fontFamily: FF, fontSize: 11, color: HELLBLAU, fontWeight: 700, textTransform: 'uppercase', letterSpacing: '0.25em', marginBottom: 8 }}>{fallbackText(upload.eyebrow, formularUploadContent.upload.eyebrow)}</p>
<h2 style={{ fontFamily: FF, fontSize: 'clamp(1.4rem, 2.2vw, 2rem)', fontWeight: 900, color: '#fff', textTransform: 'uppercase', letterSpacing: '-0.02em', marginBottom: 40 }}>
<p className="type-eyebrow text-role-inverse-secondary" style={{ marginBottom: 8 }}>{fallbackText(upload.eyebrow, formularUploadContent.upload.eyebrow)}</p>
<h2 className="type-section-title text-role-inverse" style={{ textTransform: 'uppercase', marginBottom: 40 }}>
<Lines text={fallbackText(upload.heading, formularUploadContent.upload.heading)} />
</h2>
@@ -152,8 +152,8 @@ export default function FormularUpload() {
<div style={{ width: 64, height: 64, background: GOLD, display: 'flex', alignItems: 'center', justifyContent: 'center', margin: '0 auto 24px' }}>
<CheckCircle size={28} color={DARK} />
</div>
<h3 style={{ fontFamily: FF, fontSize: 22, fontWeight: 900, color: '#fff', textTransform: 'uppercase', marginBottom: 12 }}>{fallbackText(success.heading, formularUploadContent.success.heading)}</h3>
<p style={{ fontFamily: FF, fontSize: 15, color: 'rgba(255,255,255,0.45)', maxWidth: 440, margin: '0 auto 32px', lineHeight: 1.7 }}>
<h3 className="type-card-title text-role-inverse" style={{ textTransform: 'uppercase', marginBottom: 12 }}>{fallbackText(success.heading, formularUploadContent.success.heading)}</h3>
<p className="type-small text-role-inverse-secondary" style={{ maxWidth: 440, margin: '0 auto 32px' }}>
{fallbackText(success.description, formularUploadContent.success.description)}
</p>
<Link to={fallbackText(success.linkUrl, formularUploadContent.success.linkUrl)} style={{ fontFamily: FF, fontSize: 12, fontWeight: 700, textTransform: 'uppercase', letterSpacing: '0.12em', color: DARK, background: GOLD, padding: '14px 32px', textDecoration: 'none', display: 'inline-flex', alignItems: 'center', gap: 8 }}>
@@ -181,10 +181,10 @@ export default function FormularUpload() {
}}
>
<Upload size={32} color={dragging ? GOLD : 'rgba(255,255,255,0.3)'} style={{ margin: '0 auto 16px' }} />
<p style={{ fontFamily: FF, fontSize: 15, fontWeight: 700, color: '#fff', marginBottom: 8 }}>
<p className="type-small text-role-inverse" style={{ fontWeight: 700, marginBottom: 8 }}>
{fallbackText(upload.dropTitle, formularUploadContent.upload.dropTitle)}
</p>
<p style={{ fontFamily: FF, fontSize: 12, color: 'rgba(255,255,255,0.35)', marginBottom: 20 }}>
<p className="type-caption text-role-inverse-muted" style={{ marginBottom: 20 }}>
{fallbackText(upload.dropDescription, formularUploadContent.upload.dropDescription)}
</p>
<div style={{ display: 'flex', gap: 8, justifyContent: 'center', flexWrap: 'wrap' }}>
@@ -222,7 +222,7 @@ export default function FormularUpload() {
<Info size={14} color={GOLD} style={{ marginTop: 2, flexShrink: 0 }} />
<span style={{ fontFamily: FF, fontSize: 12, fontWeight: 700, color: '#fff', textTransform: 'uppercase', letterSpacing: '0.06em' }}>{fallbackText(upload.noteTitle, formularUploadContent.upload.noteTitle)}</span>
</div>
<p style={{ fontFamily: FF, fontSize: 12, color: 'rgba(255,255,255,0.4)', lineHeight: 1.7, margin: 0 }}>
<p className="type-caption text-role-inverse-muted" style={{ margin: 0 }}>
{fallbackText(upload.note, formularUploadContent.upload.note)}
</p>
</div>
@@ -244,7 +244,7 @@ export default function FormularUpload() {
{files.length ? `${files.length} ${fallbackText(upload.selectedSubmitPrefix, formularUploadContent.upload.selectedSubmitPrefix)}${files.length > 1 ? fallbackText(upload.selectedSubmitPluralSuffix, formularUploadContent.upload.selectedSubmitPluralSuffix) : ''} ${fallbackText(upload.selectedSubmitAction, formularUploadContent.upload.selectedSubmitAction)}` : fallbackText(upload.emptySubmitLabel, formularUploadContent.upload.emptySubmitLabel)}
</button>
<p style={{ fontFamily: FF, fontSize: 11, color: 'rgba(255,255,255,0.2)', lineHeight: 1.6, margin: 0 }}>
<p className="type-caption text-role-inverse-muted" style={{ margin: 0 }}>
{fallbackText(upload.privacyNote, formularUploadContent.upload.privacyNote)}
</p>
</div>
@@ -257,7 +257,7 @@ export default function FormularUpload() {
{/* ── CTA ─────────────────────────────────────────── */}
<div style={{ padding: isMobile ? '48px 24px' : '64px 80px', borderTop: `1px solid ${BORDER}`, display: 'flex', flexDirection: isMobile ? 'column' : 'row', alignItems: isMobile ? 'flex-start' : 'center', justifyContent: 'space-between', flexWrap: 'wrap', gap: 24 }}>
<div>
<p style={{ fontFamily: FF, fontSize: 12, color: '#888', marginBottom: 6 }}>{fallbackText(cta.eyebrow, formularUploadContent.cta.eyebrow)}</p>
<p className="type-caption text-role-muted" style={{ marginBottom: 6 }}>{fallbackText(cta.eyebrow, formularUploadContent.cta.eyebrow)}</p>
<Link to={fallbackText(cta.contactUrl, formularUploadContent.cta.contactUrl)} style={{ fontFamily: FF, fontSize: 15, fontWeight: 700, color: NAVY, textDecoration: 'none', display: 'flex', alignItems: 'center', gap: 6 }}>
{fallbackText(cta.contactLabel, formularUploadContent.cta.contactLabel)} <ArrowRight size={14} />
</Link>

View File

@@ -1,23 +1,20 @@
import React, { useEffect, useRef, useState } from 'react';
import { Button } from '@/spa/components/ui/button';
import { Play, ChevronLeft, ChevronRight, X, Star } from 'lucide-react';
import { Play, ChevronRight, X } from 'lucide-react';
import { Link } from '@/spa/router';
import { PartnerTicker } from '@/spa/components/ui/partner-ticker';
import { WINNERS } from '@/spa/data/winners';
import BewerbungsForm from '@/spa/components/forms/BewerbungsForm';
import { NewsletterInterestForm } from '@/spa/components/forms/NewsletterInterestForm';
import { ApplicationFormSection } from '@/spa/components/forms/ApplicationFormSection';
import HomeWinnersSection from '@/spa/components/HomeWinnersSection';
import TestimonialsSection from '@/spa/components/TestimonialsSection';
import MunichSkylineBg from '@/spa/components/ui/munich-skyline-bg';
import { useIsMobile } from '@/spa/hooks/useIsMobile';
import Image from '@/spa/components/ui/UnoptimizedImage'
import type { SpaApplicationPhaseData, SpaApplicationPhaseItem } from '@/spa/applicationPhase'
import { resolveCmsRelationship, useApplicationPhase, useCmsCollection, useCmsRoute, useNewsletterForm } from '@/spa/cmsRoute'
import { useApplicationPhase, useCmsCollection, useCmsRoute } from '@/spa/cmsRoute'
import { mediaAlt, mediaUrl } from '@/spa/cmsMediaField'
import { homeContent } from '@/spa/homeContent'
import { netzwerkContent } from '@/spa/netzwerkContent'
import { usePreistraegerPlaceholderImage } from '@/spa/preistraegerPlaceholder'
import { PartnerLogoMark, type PartnerLogoData } from '@/spa/components/ui/partner-logo'
import { newsletterFormCopyForPhase, newsletterPhaseForApplicationPhase } from '@/spa/newsletterForm'
const FF = '"IBM Plex Sans", sans-serif';
@@ -37,7 +34,6 @@ type HomeHeroCms = {
videoLabel?: string | null;
};
type HomeSectionCms = CmsRecord & {
awardCaption?: string | null;
awardImage?: unknown;
awardImageAlt?: string | null;
benefits?: unknown;
@@ -81,7 +77,6 @@ type HomeTestimonialsCms = {
type HomeCms = {
application?: HomeSectionCms;
benefits?: HomeSectionCms;
form?: Partial<typeof homeContent.form>;
hero?: HomeHeroCms;
intro?: HomeSectionCms;
quickCheck?: HomeSectionCms;
@@ -95,20 +90,6 @@ type RenderedStatusPhase = Omit<SpaApplicationPhaseItem, 'accent' | 'bg' | 'cta'
cta?: { label: string; to: string } | null;
membershipCta?: { label: string; to: string } | null;
};
type WinnerCardData = CmsRecord & {
id?: string | number;
slug?: string;
title?: string;
name?: string;
img?: string;
image?: unknown;
spaPath?: string;
year?: string | number;
awardType?: string;
type?: string;
category?: string;
};
const fallbackText = (value: unknown, fallback: string) =>
typeof value === 'string' && value.length > 0 ? value : fallback;
@@ -116,7 +97,6 @@ const fallbackArray = <T,>(value: unknown, fallback: readonly T[]) =>
Array.isArray(value) && value.length > 0 ? (value as T[]) : fallback;
const HIDE_MEMBERSHIP_HOME_HERO_CTA = true;
const APPLICATION_PHASE_OPEN = '0';
const STATUS_SECTION_BACKGROUND = '#111D55';
const LEGACY_STATUS_SECTION_BACKGROUNDS = new Set(['#020A1E', '#060D1C', '#06090F']);
@@ -136,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[]) {
@@ -171,63 +151,10 @@ function sortedPartners<T extends PartnerLogoData>(partners: T[]) {
.map(({ partner }) => partner);
}
function HomeWinnerCard({
winner,
fallbackTitle,
fallbackImage,
hoverLabel,
horizontalScroll = false,
}: {
winner: WinnerCardData;
fallbackTitle: string;
fallbackImage: string;
hoverLabel: string;
horizontalScroll?: boolean;
}) {
const [hovered, setHovered] = useState(false);
const isMobile = useIsMobile();
const staticWinner = WINNERS.find(
(item) =>
String(item.id) === String(winner?.id) ||
item.slug === winner?.slug ||
item.name === winner?.title ||
item.name === winner?.name,
);
const title = fallbackText(winner?.title, fallbackText(winner?.name, staticWinner?.name || fallbackTitle));
const imageSrc = winner?.img || mediaUrl(winner?.image, fallbackImage);
const href = winner?.spaPath || (winner?.slug ? `/preistraeger/${winner.slug}` : staticWinner ? `/preistraeger/${staticWinner.slug}` : '/preistraeger');
return (
<Link
to={href}
style={{ textDecoration: 'none', display: 'block', position: 'relative', height: isMobile ? 220 : 280, overflow: 'hidden', scrollSnapAlign: isMobile || horizontalScroll ? 'start' : undefined }}
onMouseEnter={() => setHovered(true)}
onMouseLeave={() => setHovered(false)}
>
<Image unoptimized src={imageSrc} alt={title} style={{ width: '100%', height: '100%', objectFit: 'cover', display: 'block', transition: 'transform 0.5s ease', transform: hovered ? 'scale(1.07)' : 'scale(1)', filter: 'grayscale(15%)' }} />
<div style={{ position: 'absolute', inset: 0, background: 'linear-gradient(to top, rgba(0,0,0,0.75) 0%, rgba(0,0,0,0.1) 55%, transparent 100%)' }} />
<div style={{ position: 'absolute', inset: 0, background: 'rgba(0,0,0,0.2)', opacity: hovered ? 1 : 0, transition: 'opacity 0.25s' }} />
<div style={{ position: 'absolute', inset: 0, boxShadow: 'inset 0 0 0 1px rgba(255,255,255,0.06)' }} />
<div style={{ position: 'absolute', bottom: 0, left: 0, padding: '18px 20px' }}>
<div style={{ display: 'flex', alignItems: 'center', gap: 5, marginBottom: 5 }}>
<Star size={10} style={{ color: '#EFBF04', flexShrink: 0 }} fill="#EFBF04" />
<span style={{ fontFamily: '"IBM Plex Sans", sans-serif', fontSize: 10, fontWeight: 700, color: '#EFBF04', letterSpacing: '0.12em', textTransform: 'uppercase' }}>{winner?.year || staticWinner?.year} · {winner?.awardType || winner?.type || staticWinner?.type}</span>
</div>
<h3 style={{ fontFamily: '"IBM Plex Sans", sans-serif', fontSize: 16, fontWeight: 700, color: '#fff', margin: '0 0 3px', lineHeight: 1.3 }}>{title}</h3>
<p style={{ fontFamily: '"IBM Plex Sans", sans-serif', fontSize: 14, color: 'rgba(255,255,255,0.75)', margin: 0 }}>{winner?.category || staticWinner?.category}</p>
<div style={{ marginTop: 10, display: 'inline-block', background: '#EFBF04', color: '#101828', padding: '5px 14px', borderRadius: 999, fontSize: 10, fontFamily: '"IBM Plex Sans", sans-serif', fontWeight: 800, letterSpacing: '0.1em', textTransform: 'uppercase', opacity: hovered ? 1 : 0, transform: hovered ? 'translateY(0)' : 'translateY(6px)', transition: 'all 0.25s' }}>
{hoverLabel}
</div>
</div>
</Link>
);
}
const Home: React.FC = () => {
const [showVideo, setShowVideo] = useState(false);
const videoRef = useRef<HTMLVideoElement>(null);
const winnersScrollRef = useRef<HTMLDivElement>(null);
const isMobile = useIsMobile();
const placeholderImage = usePreistraegerPlaceholderImage();
const home = (useCmsRoute()?.doc?.home || {}) as HomeCms;
const cmsPartners = useCmsCollection('partners') as PartnerLogoData[];
const networkPartners = sortedPartners(cmsPartners)
@@ -241,44 +168,16 @@ const Home: React.FC = () => {
const intro = home.intro || {};
const winnersSection = home.winners || {};
const benefits = home.benefits || {};
const application = home.application || {};
const form = home.form || {};
const videoModal = home.videoModal || {};
const applicationPhase = useApplicationPhase();
const newsletterForm = useNewsletterForm();
const activeApplicationPhase = applicationPhase?.activePhase || APPLICATION_PHASE_OPEN;
const showApplicationForm = activeApplicationPhase === APPLICATION_PHASE_OPEN;
const newsletterCopy = newsletterFormCopyForPhase(newsletterForm, activeApplicationPhase);
const newsletterPhase = newsletterPhaseForApplicationPhase(activeApplicationPhase);
const cmsPreistraeger = useCmsCollection('preistraeger');
const selectedWinners = fallbackArray(winnersSection.featured, []);
const heroSecondaryCta = {
label: fallbackText(hero.secondaryCta?.label, homeContent.hero.secondaryCta.label),
url: fallbackText(hero.secondaryCta?.url, homeContent.hero.secondaryCta.url),
};
const showHeroSecondaryCta = !HIDE_MEMBERSHIP_HOME_HERO_CTA || !isMembershipCta(heroSecondaryCta);
const selectedWinnerDocs = selectedWinners
.map((selected) => resolveCmsRelationship(selected, cmsPreistraeger))
.filter(Boolean)
const eligibleWinners = (cmsPreistraeger.length ? cmsPreistraeger : WINNERS)
.filter((winner) => Number((winner as { year?: string | number }).year) === 2025)
const selectedWinnerIDs = new Set(
selectedWinnerDocs.map((winner) => String((winner as WinnerCardData).id || (winner as WinnerCardData).slug)),
)
const remainingWinners = selectedWinnerDocs.length >= 8
? eligibleWinners.filter((winner) => !selectedWinnerIDs.has(String((winner as WinnerCardData).id || (winner as WinnerCardData).slug)))
: []
const homeWinners = selectedWinnerDocs.length
? [...selectedWinnerDocs, ...remainingWinners]
: eligibleWinners;
const scrollDesktopWinners = !isMobile && homeWinners.length > 8;
const scrollWinnerGrid = isMobile || scrollDesktopWinners;
const quickCriteria = fallbackArray(quickCheck.criteria, homeContent.quickCheck.criteria);
const introStats = fallbackArray(intro.stats, homeContent.intro.stats);
const benefitItems = fallbackArray(benefits.items, homeContent.benefits.items);
const applicationBenefits = fallbackArray(application.benefits, homeContent.application.benefits);
const finalSectionBenefits = showApplicationForm ? applicationBenefits : newsletterCopy.benefits;
const winnerFallbackImage = placeholderImage;
const videoTitle = fallbackText(videoModal.title, homeContent.videoModal.title);
const videoSrc = mediaUrl(videoModal.video, '');
@@ -290,16 +189,6 @@ const Home: React.FC = () => {
});
}, [showVideo, videoSrc]);
const scrollWinners = (direction: -1 | 1) => {
const container = winnersScrollRef.current;
if (!container) return;
container.scrollBy({
left: direction * Math.max(container.clientWidth * 0.8, 320),
behavior: 'smooth',
});
};
return (
<div className="animate-fade-in">
{/* Hero Section */}
@@ -322,8 +211,7 @@ const Home: React.FC = () => {
</div>
<h1
className="font-display font-bold text-white mb-6 leading-[0.9] tracking-tight"
style={{ fontSize: isMobile ? 'clamp(1.75rem, 8vw, 3rem)' : 'clamp(3.25rem, 6.5vw, 6rem)' }}
className="type-display text-role-inverse mb-6"
>
<span style={{ display: 'block', whiteSpace: 'nowrap' }}>
{fallbackText(hero.headingLine1, homeContent.hero.headingLine1)}
@@ -333,7 +221,7 @@ const Home: React.FC = () => {
</span>
</h1>
<p className="text-white/70 mb-12 font-light leading-relaxed" style={{ fontSize: isMobile ? '1.0625rem' : '1.375rem', maxWidth: isMobile ? '100%' : '36rem', marginBottom: isMobile ? 28 : undefined }}>
<p className="type-lead text-role-inverse-secondary mb-12" style={{ maxWidth: isMobile ? '100%' : '36rem', marginBottom: isMobile ? 28 : undefined }}>
{fallbackText(hero.description, homeContent.hero.description)}
</p>
@@ -380,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>
@@ -390,72 +281,7 @@ const Home: React.FC = () => {
{/* Status Phase Slider */}
<StatusSlider data={applicationPhase} />
{/* Winners Grid – neue Preisträger-Übersicht */}
<section style={{ background: '#111D55' }}>
<div style={{ padding: isMobile ? '48px 24px 32px' : '72px 80px 48px', display: 'flex', flexDirection: isMobile ? 'column' : 'row', alignItems: isMobile ? 'flex-start' : 'flex-end', justifyContent: 'space-between', gap: isMobile ? 16 : 0 }}>
<div>
<span style={{ fontFamily: '"IBM Plex Sans", sans-serif', fontSize: 10, fontWeight: 700, letterSpacing: '0.3em', textTransform: 'uppercase', color: '#EFBF04', display: 'block', marginBottom: 12 }}>{fallbackText(winnersSection.eyebrow, homeContent.winners.eyebrow)}</span>
<h2 style={{ fontFamily: '"IBM Plex Sans", sans-serif', fontSize: 'clamp(2rem, 4vw, 3rem)', fontWeight: 900, color: '#fff', textTransform: 'uppercase', letterSpacing: '-0.02em', margin: 0, lineHeight: 1 }}>
<Lines text={fallbackText(winnersSection.heading, homeContent.winners.heading)} />
</h2>
</div>
<div style={{ display: 'flex', alignItems: 'center', gap: 20 }}>
{scrollDesktopWinners && (
<div style={{ display: 'flex', alignItems: 'center', gap: 8 }}>
<button
type="button"
aria-label="Vorherige Preisträger anzeigen"
onClick={() => scrollWinners(-1)}
style={{ width: 42, height: 42, border: '1px solid rgba(255,255,255,0.28)', background: 'transparent', color: '#fff', display: 'inline-flex', alignItems: 'center', justifyContent: 'center', cursor: 'pointer' }}
>
<ChevronLeft size={18} />
</button>
<button
type="button"
aria-label="Weitere Preisträger anzeigen"
onClick={() => scrollWinners(1)}
style={{ width: 42, height: 42, border: '1px solid rgba(255,255,255,0.28)', background: 'transparent', color: '#fff', display: 'inline-flex', alignItems: 'center', justifyContent: 'center', cursor: 'pointer' }}
>
<ChevronRight size={18} />
</button>
</div>
)}
<Link
to={fallbackText(winnersSection.cta?.url, homeContent.winners.cta.url)}
style={{ fontFamily: '"IBM Plex Sans", sans-serif', fontSize: 15, fontWeight: 700, letterSpacing: '0.1em', textTransform: 'uppercase', color: '#EFBF04', textDecoration: 'none', display: 'flex', alignItems: 'center', gap: 6, paddingBottom: 4, borderBottom: '1px solid #EFBF04', whiteSpace: 'nowrap' }}
>
{fallbackText(winnersSection.cta?.label, homeContent.winners.cta.label)} <ChevronRight size={14} />
</Link>
</div>
</div>
<div ref={winnersScrollRef} style={{
display: 'grid',
gridTemplateColumns: scrollWinnerGrid ? undefined : 'repeat(4, 1fr)',
gridAutoFlow: scrollWinnerGrid ? 'column' : undefined,
gridTemplateRows: scrollWinnerGrid ? `repeat(2, ${isMobile ? '1fr' : '280px'})` : undefined,
gridAutoColumns: isMobile ? '66vw' : scrollDesktopWinners ? '25vw' : undefined,
overflowX: scrollWinnerGrid ? 'auto' : undefined,
scrollSnapType: scrollWinnerGrid ? 'x mandatory' : undefined,
overscrollBehaviorX: scrollWinnerGrid ? 'contain' : undefined,
gap: isMobile ? 8 : 0,
padding: isMobile ? '0 16px 8px' : scrollDesktopWinners ? '0 0 12px' : 0,
WebkitOverflowScrolling: 'touch',
scrollbarWidth: scrollDesktopWinners ? 'thin' : 'none',
scrollbarColor: scrollDesktopWinners ? '#EFBF04 rgba(255,255,255,0.14)' : undefined,
}}>
{homeWinners.map((w) => (
<HomeWinnerCard
key={(w as WinnerCardData).id || (w as WinnerCardData).slug}
winner={w as WinnerCardData}
fallbackTitle={fallbackText(winnersSection.cardFallbackTitle, homeContent.winners.cardFallbackTitle)}
fallbackImage={winnerFallbackImage}
hoverLabel={fallbackText(winnersSection.hoverLabel, homeContent.winners.hoverLabel)}
horizontalScroll={scrollDesktopWinners}
/>
))}
</div>
</section>
<HomeWinnersSection content={winnersSection} />
{/* Schnell-Check Section */}
<section style={{ overflow: 'hidden', position: 'relative', isolation: 'isolate' }}>
@@ -466,14 +292,14 @@ const Home: React.FC = () => {
{/* Eyebrow + heading – desktop only (on mobile these are overlaid on the image) */}
{!isMobile && (
<>
<span style={{ fontFamily: '"IBM Plex Sans", sans-serif', fontSize: 10, color: '#4A8FC9', textTransform: 'uppercase', letterSpacing: '0.3em', fontWeight: 700, display: 'block', marginBottom: 16 }}>{fallbackText(quickCheck.eyebrow, homeContent.quickCheck.eyebrow)}</span>
<h2 style={{ fontFamily: '"IBM Plex Sans", sans-serif', fontSize: 'clamp(1.8rem, 2.8vw, 2.5rem)', fontWeight: 900, color: '#101828', textTransform: 'uppercase', letterSpacing: '-0.02em', lineHeight: 1.05, margin: '0 0 20px' }}>
<span style={{ fontFamily: '"IBM Plex Sans", sans-serif', fontSize: 10, color: 'var(--text-eyebrow-light)', textTransform: 'uppercase', letterSpacing: '0.3em', fontWeight: 700, display: 'block', marginBottom: 16 }}>{fallbackText(quickCheck.eyebrow, homeContent.quickCheck.eyebrow)}</span>
<h2 className="type-section-title text-role-heading" style={{ textTransform: 'uppercase', margin: '0 0 20px' }}>
<Lines text={fallbackText(quickCheck.heading, homeContent.quickCheck.heading)} />
</h2>
</>
)}
<div style={{ width: 40, height: 2, background: '#EFBF04', marginBottom: 28 }} />
<p style={{ fontFamily: '"IBM Plex Sans", sans-serif', fontSize: 17, color: 'rgba(16,24,40,0.6)', lineHeight: 1.7, marginBottom: 32 }}>
<p className="type-body text-role-secondary" style={{ marginBottom: 32 }}>
{fallbackText(quickCheck.body, homeContent.quickCheck.body)}
</p>
{/* Criteria list */}
@@ -490,7 +316,7 @@ const Home: React.FC = () => {
</div>
))}
</div>
<p style={{ fontFamily: '"IBM Plex Sans", sans-serif', fontSize: 14, color: 'rgba(16,24,40,0.55)', lineHeight: 1.6, marginTop: 16, marginBottom: 28 }}>
<p className="type-small text-role-secondary" style={{ marginTop: 16, marginBottom: 28 }}>
{fallbackText(quickCheck.note, homeContent.quickCheck.note)}
</p>
<Link
@@ -518,7 +344,7 @@ const Home: React.FC = () => {
{isMobile && (
<div style={{ position: 'absolute', left: 0, right: 0, bottom: 0, padding: '0 24px 26px', zIndex: 2 }}>
<span style={{ fontFamily: '"IBM Plex Sans", sans-serif', fontSize: 10, color: '#8FBEEC', textTransform: 'uppercase', letterSpacing: '0.3em', fontWeight: 700, display: 'block', marginBottom: 12 }}>{fallbackText(quickCheck.eyebrow, homeContent.quickCheck.eyebrow)}</span>
<h2 style={{ fontFamily: '"IBM Plex Sans", sans-serif', fontSize: 'clamp(1.9rem, 8vw, 2.5rem)', fontWeight: 900, color: '#fff', textTransform: 'uppercase', letterSpacing: '-0.02em', lineHeight: 1.05, margin: 0, textShadow: '0 2px 18px rgba(3,9,58,0.5)' }}>
<h2 className="type-section-title text-role-inverse" style={{ textTransform: 'uppercase', margin: 0, textShadow: '0 2px 18px rgba(3,9,58,0.5)' }}>
<Lines text={fallbackText(quickCheck.heading, homeContent.quickCheck.heading)} />
</h2>
</div>
@@ -548,7 +374,7 @@ const Home: React.FC = () => {
{isMobile && (
<div style={{ position: 'absolute', left: 0, right: 0, bottom: 0, padding: '0 24px 26px', zIndex: 2 }}>
<span style={{ fontFamily: '"IBM Plex Sans", sans-serif', fontSize: 10, color: '#8FBEEC', textTransform: 'uppercase', letterSpacing: '0.3em', fontWeight: 700, display: 'block', marginBottom: 12 }}>{fallbackText(intro.eyebrow, homeContent.intro.eyebrow)}</span>
<h2 style={{ fontFamily: '"IBM Plex Sans", sans-serif', fontSize: 'clamp(1.9rem, 8vw, 2.5rem)', fontWeight: 900, color: '#fff', textTransform: 'uppercase', letterSpacing: '-0.02em', lineHeight: 1.05, margin: 0, textShadow: '0 2px 18px rgba(3,9,58,0.5)' }}>
<h2 className="type-section-title text-role-inverse" style={{ textTransform: 'uppercase', margin: 0, textShadow: '0 2px 18px rgba(3,9,58,0.5)' }}>
<Lines text={fallbackText(intro.heading, homeContent.intro.heading)} />
</h2>
</div>
@@ -570,8 +396,8 @@ const Home: React.FC = () => {
<div style={{ background: '#fff', padding: isMobile ? '32px 24px 40px' : '80px 72px', display: 'flex', flexDirection: 'column', justifyContent: 'center' }}>
{!isMobile && (
<>
<span style={{ fontFamily: '"IBM Plex Sans", sans-serif', fontSize: 10, color: '#4A8FC9', textTransform: 'uppercase', letterSpacing: '0.3em', fontWeight: 700, display: 'block', marginBottom: 16 }}>{fallbackText(intro.eyebrow, homeContent.intro.eyebrow)}</span>
<h2 style={{ fontFamily: '"IBM Plex Sans", sans-serif', fontSize: 'clamp(2rem, 3.2vw, 2.8rem)', fontWeight: 900, color: '#101828', textTransform: 'uppercase', letterSpacing: '-0.02em', lineHeight: 1.05, margin: '0 0 20px' }}>
<span style={{ fontFamily: '"IBM Plex Sans", sans-serif', fontSize: 10, color: 'var(--text-eyebrow-light)', textTransform: 'uppercase', letterSpacing: '0.3em', fontWeight: 700, display: 'block', marginBottom: 16 }}>{fallbackText(intro.eyebrow, homeContent.intro.eyebrow)}</span>
<h2 className="type-section-title text-role-heading" style={{ textTransform: 'uppercase', margin: '0 0 20px' }}>
<Lines text={fallbackText(intro.heading, homeContent.intro.heading)} />
</h2>
</>
@@ -582,16 +408,16 @@ const Home: React.FC = () => {
{introStats.map(({ value: v, label: l }) => (
<div key={l} style={{ flex: 1 }}>
<div style={{ fontFamily: '"IBM Plex Sans", sans-serif', fontSize: 26, fontWeight: 900, color: '#101828', lineHeight: 1 }}>{v}</div>
<div style={{ fontFamily: '"IBM Plex Sans", sans-serif', fontSize: 9, fontWeight: 700, letterSpacing: '0.16em', textTransform: 'uppercase', color: '#4A8FC9', marginTop: 6 }}>{l}</div>
<div style={{ fontFamily: '"IBM Plex Sans", sans-serif', fontSize: 9, fontWeight: 700, letterSpacing: '0.16em', textTransform: 'uppercase', color: 'var(--text-eyebrow-light)', marginTop: 6 }}>{l}</div>
</div>
))}
</div>
)}
<div style={{ width: 40, height: 2, background: '#EFBF04', marginBottom: 28, display: isMobile ? 'none' : 'block' }} />
<blockquote style={{ fontFamily: '"IBM Plex Sans", sans-serif', fontSize: 18, fontStyle: 'italic', color: '#101828', lineHeight: 1.7, borderLeft: '3px solid #EFBF04', paddingLeft: 20, margin: '0 0 24px' }}>
<blockquote className="type-body text-role-heading" style={{ fontStyle: 'italic', borderLeft: '3px solid #EFBF04', paddingLeft: 20, margin: '0 0 24px' }}>
{fallbackText(intro.quote, homeContent.intro.quote)}
</blockquote>
<p style={{ fontFamily: '"IBM Plex Sans", sans-serif', fontSize: 17, color: 'rgba(16,24,40,0.6)', lineHeight: 1.7, margin: '0 0 36px' }}>
<p className="type-body text-role-secondary" style={{ margin: '0 0 36px' }}>
{fallbackText(intro.body, homeContent.intro.body)}
</p>
<Link
@@ -619,8 +445,8 @@ const Home: React.FC = () => {
<div style={{ background: '#fff', padding: isMobile ? '32px 24px 44px' : '80px 72px', display: 'flex', flexDirection: 'column', justifyContent: 'center', order: isMobile ? 2 : 0 }}>
{!isMobile && (
<>
<span style={{ fontFamily: FF, fontSize: 10, color: '#4A8FC9', textTransform: 'uppercase', letterSpacing: '0.3em', fontWeight: 700, display: 'block', marginBottom: 16 }}>{fallbackText(benefits.eyebrow, homeContent.benefits.eyebrow)}</span>
<h2 style={{ fontFamily: FF, fontSize: 'clamp(2rem, 3vw, 2.6rem)', fontWeight: 900, color: '#101828', textTransform: 'uppercase', letterSpacing: '-0.02em', lineHeight: 1.05, margin: '0 0 48px' }}>
<span style={{ fontFamily: FF, fontSize: 10, color: 'var(--text-eyebrow-light)', textTransform: 'uppercase', letterSpacing: '0.3em', fontWeight: 700, display: 'block', marginBottom: 16 }}>{fallbackText(benefits.eyebrow, homeContent.benefits.eyebrow)}</span>
<h2 className="type-section-title text-role-heading" style={{ textTransform: 'uppercase', margin: '0 0 48px' }}>
<Lines text={fallbackText(benefits.heading, homeContent.benefits.heading)} />
</h2>
</>
@@ -635,7 +461,7 @@ const Home: React.FC = () => {
onMouseEnter={e => (e.currentTarget.style.background = 'rgba(17,29,85,0.04)')}
onMouseLeave={e => (e.currentTarget.style.background = 'transparent')}
>
<span style={{ fontFamily: FF, fontSize: 11, fontWeight: 700, color: '#EFBF04', letterSpacing: '0.1em', paddingTop: 2 }}>{item.num}</span>
<span style={{ fontFamily: FF, fontSize: 11, fontWeight: 700, color: 'var(--text-brand-on-light)', letterSpacing: '0.1em', paddingTop: 2 }}>{item.num}</span>
<div>
<div style={{ fontFamily: FF, fontSize: 17, fontWeight: 700, color: '#101828', textTransform: 'uppercase', letterSpacing: '0.08em', marginBottom: 5 }}>{item.title}</div>
<div style={{ fontFamily: FF, fontSize: 16, color: 'rgba(16,24,40,0.55)', lineHeight: 1.6 }}>{item.desc}</div>
@@ -659,7 +485,7 @@ const Home: React.FC = () => {
{isMobile && (
<div style={{ position: 'absolute', left: 0, right: 0, bottom: 0, padding: '0 24px 26px', zIndex: 2 }}>
<span style={{ fontFamily: FF, fontSize: 10, color: '#8FBEEC', textTransform: 'uppercase', letterSpacing: '0.3em', fontWeight: 700, display: 'block', marginBottom: 12 }}>{fallbackText(benefits.eyebrow, homeContent.benefits.eyebrow)}</span>
<h2 style={{ fontFamily: FF, fontSize: 'clamp(1.9rem, 8vw, 2.4rem)', fontWeight: 900, color: '#fff', textTransform: 'uppercase', letterSpacing: '-0.02em', lineHeight: 1.05, margin: 0, textShadow: '0 2px 18px rgba(3,9,58,0.5)' }}>
<h2 className="type-section-title text-role-inverse" style={{ textTransform: 'uppercase', margin: 0, textShadow: '0 2px 18px rgba(3,9,58,0.5)' }}>
<Lines text={fallbackText(benefits.heading, homeContent.benefits.heading)} />
</h2>
</div>
@@ -675,92 +501,7 @@ const Home: React.FC = () => {
</div>
</section>
{/* CTA Section – with phase-aware embedded form */}
<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">
{/* Subtle radial glow behind left copy */}
<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' }}>
{/* Left – pitch copy */}
<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(application.eyebrow, homeContent.application.eyebrow) : newsletterCopy.eyebrow}</span>
<h2 style={{ fontFamily: FF, fontSize: isMobile ? 'clamp(1.8rem, 8vw, 2.6rem)' : 'clamp(1.8rem, 2.8vw, 2.6rem)', fontWeight: 900, color: '#fff', textTransform: 'uppercase', letterSpacing: '-0.03em', lineHeight: 1.0, marginBottom: 16 }}>
<Lines text={showApplicationForm ? fallbackText(application.heading, homeContent.application.heading) : newsletterCopy.heading} />
</h2>
<div style={{ width: 32, height: 2, background: '#EFBF04', marginBottom: 20 }} />
<p style={{ fontFamily: FF, fontSize: 15, color: 'rgba(255,255,255,0.6)', lineHeight: 1.7, marginBottom: 32 }}>
{showApplicationForm ? fallbackText(application.body, homeContent.application.body) : newsletterCopy.body}
</p>
{/* Benefit rows */}
<div style={{ borderTop: '1px solid rgba(255,255,255,0.08)' }}>
{finalSectionBenefits.map(item => (
<div key={item.num} style={{ display: 'grid', gridTemplateColumns: '24px 1fr', gap: '0 14px', padding: '13px 0', borderBottom: '1px solid rgba(255,255,255,0.06)', alignItems: 'center' }}>
<span style={{ fontFamily: FF, fontSize: 9, fontWeight: 700, color: 'rgba(239,191,4,0.55)', letterSpacing: '0.1em' }}>{item.num}</span>
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'baseline', gap: 8 }}>
<span style={{ fontFamily: FF, fontSize: 14, fontWeight: 700, color: '#fff', textTransform: 'uppercase', letterSpacing: '0.08em' }}>{item.label}</span>
<span style={{ fontFamily: FF, fontSize: 14, color: 'rgba(255,255,255,0.45)', textAlign: 'right' }}>{item.desc}</span>
</div>
</div>
))}
</div>
</div>
{/* Divider */}
{!isMobile && <div style={{ background: 'rgba(255,255,255,0.07)' }} />}
{/* Right – gold premium panel */}
<div style={{ display: 'flex', flexDirection: 'column', overflow: 'hidden', minHeight: 0, position: 'relative', background: 'linear-gradient(160deg,#DDB84A 0%,#C9A227 52%,#A87800 100%)' }}>
{/* Premium award crosshatch pattern */}
<svg style={{ position: 'absolute', inset: 0, width: '100%', height: '100%', pointerEvents: 'none', zIndex: 0 }} aria-hidden="true">
<defs>
<pattern id="bmpCross" width="18" height="18" patternUnits="userSpaceOnUse">
<path d="M0,0 L18,18 M18,0 L0,18" stroke="rgba(17,29,85,0.055)" strokeWidth="0.65" />
</pattern>
</defs>
<rect width="100%" height="100%" fill="url(#bmpCross)" />
</svg>
{/* Inner vignette – edges slightly deeper gold */}
<div style={{ position: 'absolute', inset: 0, background: 'radial-gradient(ellipse at center, transparent 55%, rgba(100,70,0,0.25) 100%)', pointerEvents: 'none', zIndex: 0 }} />
{/* Award ceremony photo */}
{!isMobile && (
<div style={{ height: 220, position: 'relative', overflow: 'hidden', flexShrink: 0, zIndex: 1 }}>
<Image unoptimized
src={mediaUrl(application.awardImage, `/images/${homeContent.application.awardImageFilename}`)}
alt={mediaAlt(application.awardImage, fallbackText(application.awardImageAlt, homeContent.application.awardImageAlt))}
style={{ width: '100%', height: '100%', objectFit: 'cover', objectPosition: 'center 25%', filter: 'sepia(0.18) brightness(0.92)' }}
/>
{/* Blend photo into gold panel */}
<div style={{ position: 'absolute', inset: 0, background: 'linear-gradient(to bottom, rgba(168,120,0,0.1) 0%, rgba(168,120,0,0.25) 50%, rgba(168,120,0,0.92) 88%, #A87800 100%)' }} />
<div style={{ position: 'absolute', bottom: 0, left: 0, right: 0, height: 2, background: 'rgba(17,29,85,0.35)' }} />
<div style={{ position: 'absolute', bottom: 14, left: 40, display: 'flex', alignItems: 'center', gap: 8 }}>
<div style={{ width: 5, height: 5, borderRadius: '50%', background: 'rgba(17,29,85,0.7)' }} />
<span style={{ fontFamily: FF, fontSize: 10, fontWeight: 700, color: 'rgba(17,29,85,0.75)', textTransform: 'uppercase', letterSpacing: '0.2em' }}>{fallbackText(application.awardCaption, homeContent.application.awardCaption)}</span>
</div>
</div>
)}
{/* Form area */}
<div style={{ padding: isMobile ? '32px 24px' : '24px 48px 32px 40px', flex: isMobile ? 'none' : 1, height: isMobile && showApplicationForm ? '82svh' : undefined, display: 'flex', flexDirection: 'column', minHeight: 0, position: 'relative', zIndex: 1 }}>
<span style={{ fontFamily: FF, fontSize: 10, color: 'rgba(17,29,85,0.5)', textTransform: 'uppercase', letterSpacing: '0.32em', fontWeight: 700, display: 'block', marginBottom: 8 }}>{showApplicationForm ? fallbackText(application.formEyebrow, homeContent.application.formEyebrow) : newsletterCopy.formEyebrow}</span>
<h3 style={{ fontFamily: FF, fontSize: 'clamp(1.2rem, 1.8vw, 1.6rem)', fontWeight: 900, color: '#111D55', textTransform: 'uppercase', letterSpacing: '-0.02em', lineHeight: 1.1, marginBottom: 24 }}>{showApplicationForm ? fallbackText(application.formTitle, homeContent.application.formTitle) : newsletterCopy.formTitle}</h3>
{showApplicationForm ? <BewerbungsForm theme="gold" content={form} /> : <NewsletterInterestForm copy={newsletterCopy} phase={newsletterPhase} source="homepage" />}
</div>
</div>
</div>
{/* Footnote row */}
<div style={{ position: 'relative', zIndex: 1, padding: isMobile ? '16px 24px 20px' : '14px 56px 18px', display: 'flex', flexWrap: 'wrap', justifyContent: 'center', textAlign: 'center', borderTop: '1px solid rgba(255,255,255,0.06)', flexShrink: 0 }}>
<span style={{ fontFamily: FF, fontSize: 15, color: 'rgba(255,255,255,0.45)', marginRight: isMobile ? 6 : 10 }}>{showApplicationForm ? fallbackText(application.footnote, homeContent.application.footnote) : newsletterCopy.footnote}</span>
<span style={{ fontFamily: FF, fontSize: 15, fontWeight: 700, color: 'rgba(239,191,4,0.8)' }}>{showApplicationForm ? fallbackText(application.footnoteStrong, homeContent.application.footnoteStrong) : newsletterCopy.footnoteStrong}</span>
</div>
<div style={{ height: 2, background: 'linear-gradient(to right, #EFBF04, rgba(239,191,4,0.25), transparent)', flexShrink: 0 }} />
</section>
<ApplicationFormSection source="homepage" />
{/* Video Modal */}
{showVideo && (
@@ -787,8 +528,8 @@ const Home: React.FC = () => {
) : (
<div className="text-center">
<Play size={80} className="text-accent mx-auto mb-6 opacity-40" />
<p className="text-white/40 uppercase tracking-widest text-sm">{videoTitle}</p>
<p className="text-white/60 mt-4 text-xs italic font-body">{fallbackText(videoModal.description, homeContent.videoModal.description)}</p>
<p className="type-small text-role-inverse-muted" style={{ textTransform: 'uppercase', letterSpacing: '0.1em' }}>{videoTitle}</p>
<p className="type-caption text-role-inverse-secondary" style={{ marginTop: 16, fontStyle: 'italic' }}>{fallbackText(videoModal.description, homeContent.videoModal.description)}</p>
<Button type="button" onClick={() => setShowVideo(false)} className="mt-8" variant="outline">{fallbackText(videoModal.closeLabel, homeContent.videoModal.closeLabel)}</Button>
</div>
)}
@@ -801,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;
@@ -827,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
@@ -864,7 +606,7 @@ function StatusSlideCard({
<div
style={{
width: '100vw',
height: '100%',
minHeight: isMobile ? 360 : 480,
flexShrink: 0,
position: 'relative',
background: phase.bg,
@@ -888,10 +630,10 @@ function StatusSlideCard({
{/* Headline + body + CTA */}
<div>
<h2 style={{ fontFamily: FF, fontSize: isMobile ? 'clamp(1.8rem, 8vw, 2.6rem)' : 'clamp(2.4rem, 3.8vw, 3.8rem)', fontWeight: 900, color: '#fff', textTransform: 'uppercase', letterSpacing: '-0.03em', lineHeight: 0.94, 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 style={{ fontFamily: '"Inter", sans-serif', fontSize: 17, color: '#C7CAD1', lineHeight: 1.8, marginBottom: 32, maxWidth: isMobile ? 520 : 760 }}>
<p className="type-body text-role-inverse-secondary" style={{ marginBottom: 32, maxWidth: isMobile ? 520 : 760 }}>
{phase.body}
</p>
{phase.cta ? (

View File

@@ -15,7 +15,7 @@ const GOLD = '#EFBF04';
const CREAM = '#EFE5E3';
const FF = '"IBM Plex Sans", sans-serif';
const FB = '"Inter", sans-serif';
const BLUE = '#4A8FC9';
const BLUE = 'var(--text-eyebrow-light)';
// ── Benefit card with hover accent line ──────────────────────────────────────
@@ -133,7 +133,7 @@ function BeitragsrechnerSection({ isMobile, content }: { isMobile: boolean; cont
<span style={{ fontFamily: FF, fontSize: 10, fontWeight: 700, textTransform: 'uppercase', letterSpacing: '0.32em', color: BLUE, display: 'block', marginBottom: 16 }}>
{fallbackText(content.eyebrow, fallback.eyebrow)}
</span>
<h2 style={{ fontFamily: FF, fontSize: 'clamp(2rem, 4vw, 3rem)', fontWeight: 900, color: '#101828', textTransform: 'uppercase', letterSpacing: '-0.025em', lineHeight: 1.03, margin: '0 0 20px' }}>
<h2 className="type-section-title text-role-heading" style={{ textTransform: 'uppercase', margin: '0 0 20px' }}>
{fallbackText(content.heading, fallback.heading)}
</h2>
<div style={{ width: 40, height: 2, background: GOLD, marginBottom: 48 }} />
@@ -524,7 +524,7 @@ function FAQSection({ isMobile, content }: { isMobile: boolean; content: Partial
<span style={{ fontFamily: FF, fontSize: 10, fontWeight: 700, textTransform: 'uppercase', letterSpacing: '0.32em', color: BLUE, display: 'block', marginBottom: 16 }}>
{fallbackText(content.eyebrow, mitgliedWerdenContent.faq.eyebrow)}
</span>
<h2 style={{ fontFamily: FF, fontSize: 'clamp(2rem, 4vw, 3rem)', fontWeight: 900, color: '#101828', textTransform: 'uppercase', letterSpacing: '-0.025em', lineHeight: 1.03, margin: '0 0 20px' }}>
<h2 className="type-section-title text-role-heading" style={{ textTransform: 'uppercase', margin: '0 0 20px' }}>
{fallbackText(content.heading, mitgliedWerdenContent.faq.heading)}
</h2>
<div style={{ width: 40, height: 2, background: GOLD, marginBottom: 48 }} />
@@ -609,12 +609,9 @@ function QuoteCard({
}}
>
<p
className="type-body text-role-body"
style={{
fontFamily: FB,
fontSize: 18,
fontStyle: 'italic',
color: 'rgba(16,24,40,0.7)',
lineHeight: 1.75,
margin: '0 0 24px',
flex: 1,
}}
@@ -791,14 +788,9 @@ const MitgliedWerden: React.FC = () => {
{fallbackText(hero.eyebrow, mitgliedWerdenContent.hero.eyebrow)}
</span>
<h1
className="type-display text-role-inverse"
style={{
fontFamily: FF,
fontSize: isMobile ? 'clamp(2rem, 9vw, 2.6rem)' : 'clamp(2.8rem, 6vw, 5rem)',
fontWeight: 900,
color: '#fff',
textTransform: 'uppercase',
lineHeight: 0.95,
letterSpacing: '-0.03em',
margin: '0 0 28px',
}}
>
@@ -806,13 +798,9 @@ const MitgliedWerden: React.FC = () => {
</h1>
<div style={{ width: 48, height: 3, background: GOLD, marginBottom: 28 }} />
<p
className="type-body text-role-inverse-secondary"
style={{
fontFamily: FB,
fontSize: 18,
fontWeight: 300,
color: 'rgba(255,255,255,0.55)',
maxWidth: 520,
lineHeight: 1.75,
margin: 0,
}}
>
@@ -914,14 +902,9 @@ const MitgliedWerden: React.FC = () => {
{fallbackText(benefits.eyebrow, mitgliedWerdenContent.benefits.eyebrow)}
</span>
<h2
className="type-section-title text-role-heading"
style={{
fontFamily: FF,
fontSize: 'clamp(2rem, 4vw, 3rem)',
fontWeight: 900,
color: '#101828',
textTransform: 'uppercase',
letterSpacing: '-0.025em',
lineHeight: 1.03,
margin: '0 0 20px',
}}
>
@@ -977,14 +960,9 @@ const MitgliedWerden: React.FC = () => {
{fallbackText(about.eyebrow, mitgliedWerdenContent.about.eyebrow)}
</span>
<h2
className="type-section-title text-role-heading"
style={{
fontFamily: FF,
fontSize: 'clamp(2rem, 3.5vw, 3rem)',
fontWeight: 900,
color: '#101828',
textTransform: 'uppercase',
letterSpacing: '-0.025em',
lineHeight: 1.03,
margin: '0 0 20px',
}}
>
@@ -994,11 +972,8 @@ const MitgliedWerden: React.FC = () => {
{aboutParagraphs.map((paragraph, index) => (
<p
key={`${paragraph}-${index}`}
className="type-body text-role-secondary"
style={{
fontFamily: FB,
fontSize: 18,
color: 'rgba(16,24,40,0.55)',
lineHeight: 1.85,
margin: index === aboutParagraphs.length - 1 ? '0 0 40px' : index === 0 ? '0 0 20px' : '0 0 40px',
}}
>
@@ -1128,14 +1103,9 @@ const MitgliedWerden: React.FC = () => {
{fallbackText(formIntro.eyebrow, mitgliedWerdenContent.formIntro.eyebrow)}
</span>
<h2
className="type-section-title text-role-inverse"
style={{
fontFamily: FF,
fontSize: 'clamp(2rem, 3.2vw, 2.8rem)',
fontWeight: 900,
color: '#fff',
textTransform: 'uppercase',
letterSpacing: '-0.025em',
lineHeight: 1.04,
margin: '0 0 20px',
}}
>
@@ -1143,11 +1113,8 @@ const MitgliedWerden: React.FC = () => {
</h2>
<div style={{ width: 36, height: 2, background: GOLD, marginBottom: 28 }} />
<p
className="type-body text-role-inverse-secondary"
style={{
fontFamily: FB,
fontSize: 18,
color: 'rgba(255,255,255,0.5)',
lineHeight: 1.8,
maxWidth: 400,
marginBottom: 48,
}}
@@ -1261,14 +1228,9 @@ const MitgliedWerden: React.FC = () => {
{fallbackText(testimonials.eyebrow, mitgliedWerdenContent.testimonials.eyebrow)}
</span>
<h2
className="type-section-title text-role-heading"
style={{
fontFamily: FF,
fontSize: 'clamp(2rem, 4vw, 3rem)',
fontWeight: 900,
color: '#101828',
textTransform: 'uppercase',
letterSpacing: '-0.025em',
lineHeight: 1.03,
margin: '0 0 20px',
}}
>
@@ -1317,14 +1279,9 @@ const MitgliedWerden: React.FC = () => {
{fallbackText(bottomCta.eyebrow, mitgliedWerdenContent.bottomCta.eyebrow)}
</span>
<h2
className="type-section-title text-role-inverse"
style={{
fontFamily: FF,
fontSize: 'clamp(2rem, 4vw, 3.2rem)',
fontWeight: 900,
color: '#fff',
textTransform: 'uppercase',
letterSpacing: '-0.025em',
lineHeight: 1.03,
margin: '0 auto 20px',
maxWidth: 640,
}}

View File

@@ -6,34 +6,32 @@ 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';
const NAVY = '#111D55';
const GOLD = '#EFBF04';
const CREAM = '#E4E2E3';
type NetzwerkCms = Partial<typeof netzwerkContent> & {
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
@@ -62,40 +60,27 @@ 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, '')
const role = rawRole && organization && rawRole !== organization ? `${rawRole} / ${organization}` : fallbackText(rawRole, organization)
const image = member.image ?? member.imageUpload
return {
name: fallbackText(member.name, ''),
role,
bio: fallbackText(member.bio, fallbackText(member.quote, '')),
image: mediaUrl(member.image, juryImageFallback),
bio: fallbackText(member.bio, ''),
image: mediaUrl(image, juryImageFallback),
imageAlt: fallbackText(member.imageAlt, fallbackText(member.name, 'Jury-Mitglied')),
imageUpload: member.image,
imageUpload: image,
organization,
quote: fallbackText(member.quote, ''),
sortOrder: typeof member.sortOrder === 'number' ? member.sortOrder : undefined,
active: member.active !== false,
}
}
const sortedJuryMembers = (members: JuryMember[]) =>
members
.map((member, index) => ({ member, index }))
.sort((a, b) => Number(a.member.sortOrder ?? a.index) - Number(b.member.sortOrder ?? b.index))
.map(({ member }) => member)
const hasJuryText = (value: unknown) => typeof value === 'string' && value.trim().length > 0
function Lines({ text, highlight }: { text: string; highlight?: string }) {
return (
<>
@@ -161,7 +146,8 @@ function PersonCard({ imgSrc, imgAlt, name, titleLine1, titleLine2, institution,
const JuryCard: React.FC<{ member: JuryMember }> = ({ member }) => {
const [hovered, setHovered] = useState(false);
const isMobileCard = useIsMobile();
const quote = fallbackText(member.quote, '');
const bio = hasJuryText(member.bio) ? member.bio : '';
const quote = hasJuryText(member.quote) ? member.quote : '';
return (
<div
onMouseEnter={() => setHovered(true)}
@@ -176,7 +162,9 @@ const JuryCard: React.FC<{ member: JuryMember }> = ({ member }) => {
<div style={{ fontFamily: FF, fontSize: isMobileCard ? 16 : 17, fontWeight: 700, color: '#fff', lineHeight: 1.15, marginBottom: 5, minWidth: 0, overflowWrap: 'anywhere', wordBreak: 'break-word' }}>{member.name}</div>
<div style={{ fontFamily: FF, fontSize: isMobileCard ? 9 : 10, color: GOLD, textTransform: 'uppercase', letterSpacing: isMobileCard ? '0.1em' : '0.14em', fontWeight: 700, lineHeight: 1.35, marginBottom: 10, minWidth: 0, overflowWrap: 'anywhere', wordBreak: 'break-word' }}>{member.role}</div>
<div style={{ width: hovered ? '100%' : 20, height: 1, background: GOLD, opacity: 0.4, transition: 'width 0.4s ease', marginBottom: 10 }} />
<div style={{ fontFamily: FB, fontSize: isMobileCard ? 14 : 15, color: 'rgba(255,255,255,0.55)', lineHeight: 1.55, minWidth: 0, overflowWrap: 'anywhere', wordBreak: 'break-word' }}>{member.bio}</div>
{bio ? (
<div style={{ fontFamily: FB, fontSize: isMobileCard ? 14 : 15, color: 'rgba(255,255,255,0.55)', lineHeight: 1.55, minWidth: 0, overflowWrap: 'anywhere', wordBreak: 'break-word' }}>{bio}</div>
) : null}
{quote ? (
<div style={{ borderLeft: `2px solid ${GOLD}`, color: 'rgba(255,255,255,0.72)', fontFamily: FB, fontSize: isMobileCard ? 13 : 14, fontStyle: 'italic', lineHeight: 1.5, marginTop: 14, paddingLeft: 10, minWidth: 0, overflowWrap: 'anywhere', wordBreak: 'break-word' }}>
{quote}
@@ -192,7 +180,8 @@ const JuryCard: React.FC<{ member: JuryMember }> = ({ member }) => {
const JuryChairCard: React.FC<{ member: JuryMember; badge: string }> = ({ member, badge }) => {
const [hovered, setHovered] = useState(false);
const isMobileCard = useIsMobile();
const quote = fallbackText(member.quote, '');
const bio = hasJuryText(member.bio) ? member.bio : '';
const quote = hasJuryText(member.quote) ? member.quote : '';
return (
<div
onMouseEnter={() => setHovered(true)}
@@ -218,7 +207,9 @@ const JuryChairCard: React.FC<{ member: JuryMember; badge: string }> = ({ member
<div style={{ fontFamily: FF, fontSize: isMobileCard ? 26 : 34, fontWeight: 900, color: '#fff', textTransform: 'uppercase', letterSpacing: '-0.02em', lineHeight: 1.05, marginBottom: 10 }}>{member.name}</div>
<div style={{ fontFamily: FF, fontSize: 13, color: GOLD, textTransform: 'uppercase', letterSpacing: '0.18em', fontWeight: 700, marginBottom: 24 }}>{member.role}</div>
<div style={{ width: 48, height: 2, background: GOLD, opacity: 0.5, marginBottom: 24 }} />
<div style={{ fontFamily: FB, fontSize: isMobileCard ? 18 : 21, color: 'rgba(255,255,255,0.6)', lineHeight: 1.7, maxWidth: 620 }}>{member.bio}</div>
{bio ? (
<div style={{ fontFamily: FB, fontSize: isMobileCard ? 18 : 21, color: 'rgba(255,255,255,0.6)', lineHeight: 1.7, maxWidth: 620 }}>{bio}</div>
) : null}
{quote ? (
<div style={{ borderLeft: `3px solid ${GOLD}`, color: 'rgba(255,255,255,0.78)', fontFamily: FB, fontSize: isMobileCard ? 17 : 19, fontStyle: 'italic', lineHeight: 1.65, marginTop: 24, maxWidth: 620, paddingLeft: 18 }}>
{quote}
@@ -233,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 }}>
@@ -265,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>
);
};
@@ -301,7 +311,7 @@ function PartnerModal({ partner, copy, onClose }: { partner: Partner; copy: Part
}}
>
{/* ── Hero ── */}
<div style={{ position: 'relative', height: 220, flexShrink: 0, overflow: 'hidden' }}>
<div style={{ position: 'relative', height: 220, flexShrink: 0, overflow: 'visible' }}>
{/* Background */}
{partner.heroImg
? <Image unoptimized src={partner.heroImg} alt="" style={{ position: 'absolute', inset: 0, width: '100%', height: '100%', objectFit: 'cover' }} />
@@ -364,15 +374,12 @@ function PartnerModal({ partner, copy, onClose }: { partner: Partner; copy: Part
{/* ── Content ── */}
<div style={{ padding: '72px 32px 32px' }}>
<h2 style={{
fontFamily: FF, fontSize: 'clamp(1.5rem, 3vw, 2rem)',
fontWeight: 900, color: '#101828',
letterSpacing: '-0.025em', lineHeight: 1.05,
<h2 className="type-subtitle text-role-heading" style={{
margin: '0 0 10px',
}}>
{partner.name}
</h2>
<p style={{ fontFamily: FB, fontSize: 18, color: 'rgba(16,24,40,0.5)', lineHeight: 1.65, margin: '0 0 24px' }}>
<p className="type-body text-role-secondary" style={{ margin: '0 0 24px' }}>
{partner.desc}
</p>
@@ -403,7 +410,7 @@ function PartnerModal({ partner, copy, onClose }: { partner: Partner; copy: Part
{fallbackText(copy.aboutLabel, netzwerkContent.partners.modal.aboutLabel)}
</span>
</div>
<p style={{ fontFamily: FB, fontSize: 19, color: 'rgba(16,24,40,0.6)', lineHeight: 1.82, marginBottom: 36 }}>
<p className="type-body text-role-secondary" style={{ marginBottom: 36 }}>
{partner.fullDesc}
</p>
@@ -463,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 || {}) };
@@ -481,25 +487,35 @@ const Netzwerk: React.FC = () => {
const partnerModal = { ...netzwerkContent.partners.modal, ...((cms.partners as typeof netzwerkContent.partners | undefined)?.modal || {}) };
const sponsoring = { ...netzwerkContent.sponsoring, ...(cms.sponsoring || {}) };
const patronPeople = fallbackArray<PatronPerson>(patronage.people, netzwerkContent.patronage.people);
const juryCollectionMembers = sortedJuryMembers(cmsJuryMembers.map(normalizeJuryMember).filter((member) => member.active !== false && member.name));
const juryMembers = juryCollectionMembers.length
? juryCollectionMembers
: fallbackArray<JuryMember>(jury.members, netzwerkContent.jury.members);
const cmsJurySelection = cms.jury?.members;
const sourcedJuryDocs = selectJuryDocs(cmsJurySelection, cmsJuryMembers);
const normalizedJuryMembers = sourcedJuryDocs
.map(normalizeJuryMember)
.filter((member) => member.active !== false && member.name);
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;
@@ -524,10 +540,10 @@ const Netzwerk: React.FC = () => {
<div style={{ width: 40, height: 2, background: GOLD }} />
<span style={{ fontFamily: FF, fontSize: 10, fontWeight: 700, letterSpacing: '0.3em', textTransform: 'uppercase', color: GOLD }}>{fallbackText(hero.eyebrow, netzwerkContent.hero.eyebrow)}</span>
</div>
<h1 style={{ fontFamily: FF, fontSize: isMobile ? 'clamp(1.6rem, 6vw, 5rem)' : 'clamp(2.8rem, 6vw, 5rem)', fontWeight: 900, color: '#fff', lineHeight: 0.95, letterSpacing: '-0.03em', textTransform: 'uppercase', margin: '0 0 28px' }}>
<h1 className="type-display text-role-inverse" style={{ textTransform: 'uppercase', margin: '0 0 28px' }}>
<Lines text={fallbackText(hero.heading, netzwerkContent.hero.heading)} highlight={fallbackText(hero.highlight, netzwerkContent.hero.highlight)} />
</h1>
<p style={{ fontFamily: FB, fontSize: 18, color: 'rgba(255,255,255,0.65)', lineHeight: 1.7, maxWidth: 560, margin: 0, fontWeight: 300 }}>
<p className="type-body text-role-inverse-secondary" style={{ maxWidth: 560, margin: 0 }}>
{fallbackText(hero.description, netzwerkContent.hero.description)}
</p>
</div>
@@ -538,9 +554,9 @@ const Netzwerk: React.FC = () => {
<section id="schirmherrschaft" style={{ background: NAVY }}>
<div style={{ padding: isMobile ? '48px 24px 32px' : '80px 80px 56px' }}>
<span style={{ fontFamily: FF, fontSize: 10, fontWeight: 700, letterSpacing: '0.3em', textTransform: 'uppercase', color: GOLD, display: 'block', marginBottom: 16 }}>{fallbackText(patronage.eyebrow, netzwerkContent.patronage.eyebrow)}</span>
<h2 style={{ fontFamily: FF, fontSize: 'clamp(2rem, 4vw, 3rem)', fontWeight: 900, color: '#fff', textTransform: 'uppercase', letterSpacing: '-0.02em', lineHeight: 1, margin: '0 0 20px' }}>{fallbackText(patronage.heading, netzwerkContent.patronage.heading)}</h2>
<h2 className="type-section-title text-role-inverse" style={{ textTransform: 'uppercase', margin: '0 0 20px' }}>{fallbackText(patronage.heading, netzwerkContent.patronage.heading)}</h2>
<div style={{ width: 40, height: 2, background: GOLD, marginBottom: 14 }} />
<p style={{ fontFamily: FF, fontSize: 18, color: 'rgba(255,255,255,0.5)', margin: 0 }}>{fallbackText(patronage.description, netzwerkContent.patronage.description)}</p>
<p className="type-body text-role-inverse-secondary" style={{ margin: 0 }}>{fallbackText(patronage.description, netzwerkContent.patronage.description)}</p>
</div>
<div style={{ display: isMobile ? 'flex' : 'grid', gridTemplateColumns: isMobile ? undefined : '1fr 1fr', overflowX: isMobile ? 'auto' : undefined, scrollSnapType: isMobile ? 'x mandatory' : undefined, gap: isMobile ? 14 : 0, padding: isMobile ? '0 24px 28px' : 0, WebkitOverflowScrolling: 'touch', scrollbarWidth: 'none' }}>
@@ -569,7 +585,7 @@ const Netzwerk: React.FC = () => {
<div style={{ fontFamily: FF, fontSize: 17, color: 'rgba(255,255,255,0.4)', lineHeight: 1.6 }}>{fallbackText(patronage.greetingTitleLine2, netzwerkContent.patronage.greetingTitleLine2)}</div>
</div>
<div>
<blockquote style={{ fontFamily: FF, fontSize: 18, fontStyle: 'italic', color: 'rgba(255,255,255,0.8)', lineHeight: 1.75, margin: 0, paddingLeft: 24, borderLeft: `3px solid ${GOLD}` }}>
<blockquote className="type-body text-role-inverse-secondary" style={{ fontStyle: 'italic', margin: 0, paddingLeft: 24, borderLeft: `3px solid ${GOLD}` }}>
{fallbackText(patronage.greetingQuote, netzwerkContent.patronage.greetingQuote)}
</blockquote>
<div style={{ fontFamily: FF, fontSize: 11, color: 'rgba(255,255,255,0.35)', marginTop: 20, paddingLeft: 24 }}>
@@ -632,11 +648,11 @@ const Netzwerk: React.FC = () => {
/* Mobile: one cohesive navy module – copy + compact stat strip */
<div style={{ padding: '48px 24px', position: 'relative', zIndex: 1 }}>
<span style={{ fontFamily: FF, fontSize: 10, textTransform: 'uppercase', letterSpacing: '0.32em', fontWeight: 700, color: GOLD, display: 'block', marginBottom: 18 }}>{fallbackText(juryIntro.eyebrow, netzwerkContent.juryIntro.eyebrow)}</span>
<h2 style={{ fontFamily: FF, fontSize: 'clamp(1.7rem, 8vw, 2.2rem)', fontWeight: 900, color: '#fff', textTransform: 'uppercase', letterSpacing: '-0.02em', lineHeight: 1.08, margin: '0 0 22px' }}>
<h2 className="type-section-title text-role-inverse" style={{ textTransform: 'uppercase', margin: '0 0 22px' }}>
<Lines text={fallbackText(juryIntro.heading, netzwerkContent.juryIntro.heading)} />
</h2>
<div style={{ width: 40, height: 2, background: GOLD, marginBottom: 22 }} />
<p style={{ fontFamily: FB, fontSize: 16, color: 'rgba(255,255,255,0.55)', lineHeight: 1.75, margin: 0 }}>
<p className="type-small text-role-inverse-secondary" style={{ margin: 0 }}>
{fallbackText(juryIntro.description, netzwerkContent.juryIntro.description)}
</p>
{/* Stat strip */}
@@ -651,8 +667,8 @@ const Netzwerk: React.FC = () => {
</div>
) : (
<>
{/* Left – Cream, stats */}
<div style={{ background: CREAM, padding: '72px 64px', display: 'flex', flexDirection: 'column', justifyContent: 'center', position: 'relative', zIndex: 1 }}>
{/* Left – White, stats */}
<div style={{ background: '#fff', padding: '72px 64px', display: 'flex', flexDirection: 'column', justifyContent: 'center', position: 'relative', zIndex: 1 }}>
<div>
<div style={{ fontFamily: FF, fontSize: 'clamp(5rem, 8.5vw, 7.5rem)', fontWeight: 900, color: '#101828', letterSpacing: '-0.04em', lineHeight: 1 }}>{fallbackText(desktopStats[0]?.value, netzwerkContent.juryIntro.desktopStats[0].value)}</div>
<div style={{ fontFamily: FF, fontSize: 11, textTransform: 'uppercase', letterSpacing: '0.15em', color: 'rgba(16,24,40,0.4)', marginTop: 4 }}>{fallbackText(desktopStats[0]?.label, netzwerkContent.juryIntro.desktopStats[0].label)}</div>
@@ -671,11 +687,11 @@ const Netzwerk: React.FC = () => {
{/* Right – Navy, editorial copy */}
<div style={{ background: NAVY, padding: '72px 64px', display: 'flex', flexDirection: 'column', justifyContent: 'center', position: 'relative', zIndex: 1 }}>
<span style={{ fontFamily: FF, fontSize: 10, textTransform: 'uppercase', letterSpacing: '0.32em', fontWeight: 700, color: GOLD, marginBottom: 20 }}>{fallbackText(juryIntro.eyebrow, netzwerkContent.juryIntro.eyebrow)}</span>
<h2 style={{ fontFamily: FF, fontSize: 'clamp(1.6rem, 2.5vw, 2.2rem)', fontWeight: 900, color: '#fff', textTransform: 'uppercase', letterSpacing: '-0.02em', lineHeight: 1.08, margin: '0 0 24px' }}>
<h2 className="type-section-title text-role-inverse" style={{ textTransform: 'uppercase', margin: '0 0 24px' }}>
<Lines text={fallbackText(juryIntro.heading, netzwerkContent.juryIntro.heading)} />
</h2>
<div style={{ width: 40, height: 2, background: GOLD }} />
<p style={{ marginTop: 24, fontFamily: FB, fontSize: 19, color: 'rgba(255,255,255,0.45)', lineHeight: 1.85 }}>
<p className="type-body text-role-inverse-secondary" style={{ marginTop: 24 }}>
{fallbackText(juryIntro.description, netzwerkContent.juryIntro.description)}
</p>
</div>
@@ -689,17 +705,19 @@ const Netzwerk: React.FC = () => {
<div style={{ padding: isMobile ? '48px 24px 32px' : '80px 80px 56px', display: 'grid', gridTemplateColumns: isMobile ? '1fr' : '1fr 1fr', gap: isMobile ? 16 : 48, alignItems: 'flex-end', borderBottom: '1px solid rgba(255,255,255,0.07)' }}>
<div>
<span style={{ fontFamily: FF, fontSize: 10, textTransform: 'uppercase', letterSpacing: '0.32em', fontWeight: 700, color: GOLD, display: 'block', marginBottom: 16 }}>{fallbackText(jury.eyebrow, netzwerkContent.jury.eyebrow)}</span>
<h2 style={{ fontFamily: FF, fontSize: 'clamp(2rem, 3.5vw, 3rem)', fontWeight: 900, color: '#fff', textTransform: 'uppercase', letterSpacing: '-0.025em', lineHeight: 0.95, margin: 0 }}><Lines text={fallbackText(jury.heading, netzwerkContent.jury.heading)} /></h2>
<h2 className="type-section-title text-role-inverse" style={{ textTransform: 'uppercase', margin: 0 }}><Lines text={fallbackText(jury.heading, netzwerkContent.jury.heading)} /></h2>
</div>
<p style={{ fontFamily: FB, fontSize: 19, color: 'rgba(255,255,255,0.4)', lineHeight: 1.8, margin: 0 }}>
<p className="type-body text-role-inverse-secondary" style={{ margin: 0 }}>
{fallbackText(jury.description, netzwerkContent.jury.description)}
</p>
</div>
{/* ── Prominenter Vorsitz-Bereich ─────────────────────────────────── */}
{/* TODO: echten Jury-Vorsitzenden bestätigen (Name & Foto sind Platzhalter) */}
<div style={{ padding: isMobile ? '28px 24px 8px' : '64px 80px 16px' }}>
<JuryChairCard member={juryMembers[0]} badge={fallbackText(jury.chairBadge, netzwerkContent.jury.chairBadge)} />
</div>
{juryMembers.length ? (
<div style={{ padding: isMobile ? '28px 24px 8px' : '64px 80px 16px' }}>
<JuryChairCard member={juryMembers[0]} badge={fallbackText(jury.chairBadge, netzwerkContent.jury.chairBadge)} />
</div>
) : null}
{/* ── Übrige Jury-Mitglieder ──────────────────────────────────────── */}
<div style={{ display: isMobile ? 'flex' : 'grid', gridTemplateColumns: isMobile ? undefined : 'repeat(auto-fill, minmax(260px, 1fr))', overflowX: isMobile ? 'auto' : undefined, scrollSnapType: isMobile ? 'x mandatory' : undefined, gap: isMobile ? 12 : 0, padding: isMobile ? '24px 24px 36px' : '0 0 56px', WebkitOverflowScrolling: 'touch', scrollbarWidth: 'none', borderTop: isMobile ? 'none' : '1px solid rgba(255,255,255,0.07)' }}>
@@ -714,7 +732,7 @@ const Netzwerk: React.FC = () => {
<section style={{ background: '#101828', overflow: 'hidden', display: 'grid', gridTemplateColumns: isMobile ? '1fr' : '55% 45%', borderTop: '1px solid rgba(255,255,255,0.07)' }}>
<div style={{ padding: isMobile ? '48px 24px' : 80, display: 'flex', flexDirection: 'column', justifyContent: 'center', background: '#101828' }}>
<span style={{ fontFamily: FF, fontSize: 10, textTransform: 'uppercase', letterSpacing: '0.32em', fontWeight: 700, color: GOLD, display: 'block', marginBottom: 16 }}>{fallbackText(expertise.eyebrow, netzwerkContent.expertise.eyebrow)}</span>
<h2 style={{ fontFamily: FF, fontSize: 'clamp(1.8rem, 3vw, 2.6rem)', fontWeight: 900, color: '#fff', textTransform: 'uppercase', letterSpacing: '-0.025em', lineHeight: 1.0, margin: '0 0 24px' }}><Lines text={fallbackText(expertise.heading, netzwerkContent.expertise.heading)} /></h2>
<h2 className="type-section-title text-role-inverse" style={{ textTransform: 'uppercase', margin: '0 0 24px' }}><Lines text={fallbackText(expertise.heading, netzwerkContent.expertise.heading)} /></h2>
<div style={{ width: 40, height: 2, background: GOLD, marginBottom: 40 }} />
<div>
{expertiseRows.map((row) => (
@@ -730,7 +748,7 @@ const Netzwerk: React.FC = () => {
</div>
<div style={{ padding: isMobile ? '48px 24px' : '80px 64px', display: 'flex', flexDirection: 'column', justifyContent: 'center', borderLeft: isMobile ? 'none' : '1px solid rgba(255,255,255,0.07)', borderTop: isMobile ? '1px solid rgba(255,255,255,0.07)' : 'none', overflow: 'hidden', background: '#101828' }}>
<span style={{ fontFamily: 'Georgia, serif', fontSize: 'clamp(60px, 8vw, 120px)', color: 'rgba(239,191,4,0.1)', lineHeight: 0.8, display: 'block', marginBottom: -20 }}>&ldquo;</span>
<p style={{ fontFamily: FF, fontSize: 'clamp(1.1rem, 1.8vw, 1.4rem)', fontWeight: 300, color: 'rgba(255,255,255,0.75)', lineHeight: 1.65, margin: '0 0 40px' }}>
<p className="type-lead text-role-inverse-secondary" style={{ margin: '0 0 40px' }}>
{fallbackText(expertise.quote, netzwerkContent.expertise.quote)}
</p>
<div style={{ display: 'flex', alignItems: 'center', gap: 16 }}>
@@ -742,31 +760,69 @@ const Netzwerk: React.FC = () => {
{/* ── 6. PARTNER & SPONSOREN ───────────────────────────────────────────── */}
<section id="partner" style={{ background: CREAM, overflow: 'hidden', position: 'relative', isolation: 'isolate' }}>
<section id="partner" style={{ background: '#fff', overflow: 'hidden', position: 'relative', isolation: 'isolate' }}>
<MunichSkylineBg />
<div style={{ padding: isMobile ? '48px 24px 32px' : '80px 80px 56px', display: 'flex', flexDirection: isMobile ? 'column' : 'row', gap: isMobile ? 12 : 0, justifyContent: 'space-between', alignItems: isMobile ? 'flex-start' : 'flex-end', borderBottom: '1px solid rgba(3,9,58,0.1)' }}>
<div style={{ padding: isMobile ? '48px 24px 32px' : '80px 80px 56px', display: 'grid', gridTemplateColumns: isMobile ? '1fr' : '1fr 1fr', gap: isMobile ? 12 : 40, alignItems: 'end', borderBottom: '1px solid rgba(3,9,58,0.1)' }}>
<div>
<span style={{ fontFamily: FF, fontSize: 10, textTransform: 'uppercase', letterSpacing: '0.32em', fontWeight: 700, color: '#4A8FC9', display: 'block', marginBottom: 16 }}>{fallbackText(partnerSection.eyebrow, netzwerkContent.partners.eyebrow)}</span>
<h2 style={{ fontFamily: FF, fontWeight: 900, color: '#101828', textTransform: 'uppercase', letterSpacing: '-0.025em', fontSize: 'clamp(2rem, 3.5vw, 3rem)', lineHeight: 0.95, margin: 0 }}><Lines text={fallbackText(partnerSection.heading, netzwerkContent.partners.heading)} /></h2>
<span style={{ fontFamily: FF, fontSize: 10, textTransform: 'uppercase', letterSpacing: '0.32em', fontWeight: 700, color: 'var(--text-eyebrow-light)', display: 'block', marginBottom: 16 }}>{fallbackText(partnerSection.eyebrow, netzwerkContent.partners.eyebrow)}</span>
<h2 className="type-section-title text-role-heading" style={{ textTransform: 'uppercase', margin: 0 }}><Lines text={fallbackText(partnerSection.heading, netzwerkContent.partners.heading)} /></h2>
</div>
<p style={{ fontFamily: FB, fontSize: 18, color: 'rgba(16,24,40,0.4)', maxWidth: isMobile ? '100%' : 220, textAlign: isMobile ? 'left' : 'right', lineHeight: 1.6, margin: 0 }}>
{visiblePartnerDescription(partnerSection.description)}
<p className="type-body text-role-secondary" style={{ margin: 0 }}>
{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>
@@ -775,17 +831,17 @@ const Netzwerk: React.FC = () => {
{/* ── 7. SPONSORING-FORM ───────────────────────────────────────────────── */}
<section id="sponsoring" style={{
background: NAVY, overflow: 'hidden', position: 'relative',
minHeight: isMobile ? 'auto' : 'calc(100vh - 60px)',
background: '#24366A', overflow: 'hidden', position: 'relative',
height: isMobile ? 'auto' : 'min(680px, calc(100vh - 100px))',
display: 'flex', flexDirection: 'column',
}}>
<div style={{ flex: 1, position: 'relative', zIndex: 1, display: 'grid', gridTemplateColumns: isMobile ? '1fr' : '4fr 1px 8fr', minHeight: 0, overflow: isMobile ? 'visible' : 'visible' }}>
<div style={{ flex: 1, position: 'relative', zIndex: 1, display: 'grid', gridTemplateColumns: isMobile ? '1fr' : '4fr 1px 8fr', minHeight: 0, overflow: 'hidden' }}>
{/* Left – pitch */}
<div style={{ padding: isMobile ? '32px 24px 24px' : '36px 36px 32px 52px', display: 'flex', flexDirection: 'column', justifyContent: 'center', overflow: 'visible' }}>
<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, textTransform: 'uppercase', letterSpacing: '0.32em', fontWeight: 700, color: GOLD, display: 'block', marginBottom: 8 }}>{fallbackText(sponsoring.eyebrow, netzwerkContent.sponsoring.eyebrow)}</span>
<h2 style={{ fontFamily: FF, fontWeight: 900, color: '#fff', textTransform: 'uppercase', letterSpacing: '-0.025em', fontSize: 'clamp(1.6rem, 2.4vw, 2.4rem)', lineHeight: 1.0, margin: '0 0 12px' }}><Lines text={fallbackText(sponsoring.heading, netzwerkContent.sponsoring.heading)} /></h2>
<h2 className="type-section-title text-role-inverse" style={{ textTransform: 'uppercase', margin: '0 0 12px' }}><Lines text={fallbackText(sponsoring.heading, netzwerkContent.sponsoring.heading)} /></h2>
<div style={{ width: 36, height: 2, background: GOLD, margin: '0 0 14px', flexShrink: 0 }} />
<p style={{ fontFamily: FB, fontSize: 'clamp(15px, 1.2vw, 17px)', color: 'rgba(255,255,255,0.75)', lineHeight: 1.65, marginBottom: 16 }}>
<p className="type-small text-role-inverse-secondary" style={{ marginBottom: 16 }}>
{fallbackText(sponsoring.description, netzwerkContent.sponsoring.description)}
</p>
{sponsorBenefits.map((item) => (
@@ -801,7 +857,7 @@ const Netzwerk: React.FC = () => {
{/* Center divider */}
{!isMobile && <div style={{ background: 'rgba(255,255,255,0.07)' }} />}
{/* Right – gold form panel */}
<div style={{ display: 'flex', flexDirection: 'column', overflow: 'visible', minHeight: 0, position: 'relative', background: 'linear-gradient(160deg,#DDB84A 0%,#C9A227 52%,#A87800 100%)' }}>
<div style={{ display: 'flex', flexDirection: 'column', overflow: 'hidden', minHeight: 0, position: 'relative', background: 'linear-gradient(160deg,#DDB84A 0%,#C9A227 52%,#A87800 100%)' }}>
<svg style={{ position: 'absolute', inset: 0, width: '100%', height: '100%', pointerEvents: 'none', zIndex: 0 }} aria-hidden="true">
<defs>
<pattern id="sponsoringCross" width="18" height="18" patternUnits="userSpaceOnUse">
@@ -827,8 +883,7 @@ const Netzwerk: React.FC = () => {
</div>
</div>
)}
<div style={{ padding: isMobile ? '28px 20px' : '24px 48px 32px 40px', flex: 1, display: 'flex', flexDirection: 'column', minHeight: isMobile ? 560 : 0, position: 'relative', zIndex: 1 }}>
<span style={{ fontFamily: FF, fontSize: 10, textTransform: 'uppercase', letterSpacing: '0.32em', fontWeight: 700, color: 'rgba(17,29,85,0.5)', display: 'block', marginBottom: 8 }}>{fallbackText(sponsoring.formEyebrow, netzwerkContent.sponsoring.formEyebrow)}</span>
<div id="sponsoring-form" style={{ padding: isMobile ? '32px 24px' : '24px 48px 32px 40px', flex: isMobile ? 'none' : 1, height: isMobile ? '82svh' : undefined, display: 'flex', flexDirection: 'column', minHeight: 0, position: 'relative', zIndex: 1, scrollMarginTop: 80 }}>
<SponsoringForm theme="gold" content={cms.sponsoringForm} />
</div>
</div>
@@ -846,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,
@@ -857,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

@@ -1,32 +1,23 @@
import React, { useRef } from 'react';
import { MapPin, Users, TrendingUp, Building2, ArrowRight, CheckCircle2, UserCheck, Send, Star } from 'lucide-react';
import { Link } from '@/spa/router';
import BewerbungsForm from '@/spa/components/forms/BewerbungsForm';
import { NewsletterInterestForm } from '@/spa/components/forms/NewsletterInterestForm';
import { ApplicationFormSection } from '@/spa/components/forms/ApplicationFormSection';
import AwardsGridSection, { type AwardsGridContent } from '@/spa/components/AwardsGridSection';
import WegZurAuszeichnungSection from '@/spa/components/WegZurAuszeichnungSection';
import MunichSkylineBg from '@/spa/components/ui/munich-skyline-bg';
import { useIsMobile } from '@/spa/hooks/useIsMobile';
import { mediaAlt, mediaUrl } from '@/spa/cmsMediaField';
import { useApplicationPhase, useCmsRoute, useNewsletterForm } from '@/spa/cmsRoute';
import { useCmsRoute } from '@/spa/cmsRoute';
import { participationContent } from '@/spa/participationContent';
import Image from '@/spa/components/ui/UnoptimizedImage'
import { newsletterFormCopyForPhase, newsletterPhaseForApplicationPhase } from '@/spa/newsletterForm';
const FF = '"IBM Plex Sans", sans-serif';
const FB = '"Inter", sans-serif';
const NAVY = '#111D55';
const GOLD = '#EFBF04';
const CREAM = '#E4E2E3';
type ParticipationCms = Partial<typeof participationContent> & {
hero?: Partial<typeof participationContent.hero> & { backgroundImage?: unknown };
awardsGrid?: AwardsGridContent;
applicationForm?: ParticipationApplicationFormCms;
};
type ParticipationApplicationFormCms = Partial<typeof participationContent.applicationForm> & {
image?: unknown;
};
const ICONS = {
@@ -45,8 +36,6 @@ const fallbackText = (value: unknown, fallback: string) =>
const fallbackArray = <T,>(value: unknown, fallback: readonly T[]) =>
Array.isArray(value) && value.length > 0 ? (value as T[]) : fallback;
const APPLICATION_PHASE_OPEN = '0';
const isFutureAwardApplicationWay = (value: { label?: unknown; title?: unknown }) => {
const label = typeof value.label === 'string' ? value.label.toLowerCase() : '';
const title = typeof value.title === 'string' ? value.title.toLowerCase() : '';
@@ -60,28 +49,19 @@ 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 applicationPhase = useApplicationPhase();
const newsletterForm = useNewsletterForm();
const hero = { ...participationContent.hero, ...(cms.hero || {}) };
const process = { ...participationContent.process, ...(cms.process || {}) };
const eligibility = { ...participationContent.eligibility, ...(cms.eligibility || {}) };
const applicationWays = { ...participationContent.applicationWays, ...(cms.applicationWays || {}) };
const datesBanner = { ...participationContent.datesBanner, ...(cms.datesBanner || {}) };
const awardsGrid = { ...participationContent.awardsGrid, ...(cms.awardsGrid || {}) };
const applicationForm = { ...participationContent.applicationForm, ...(cms.applicationForm || {}) };
const form = { ...participationContent.form, ...(cms.form || {}) };
const activeApplicationPhase = applicationPhase?.activePhase || APPLICATION_PHASE_OPEN;
const showApplicationForm = activeApplicationPhase === APPLICATION_PHASE_OPEN;
const newsletterCopy = newsletterFormCopyForPhase(newsletterForm, activeApplicationPhase);
const newsletterPhase = newsletterPhaseForApplicationPhase(activeApplicationPhase);
const eligibilityCriteria = fallbackArray(eligibility.criteria, participationContent.eligibility.criteria);
const eligibilityNotes = fallbackArray(eligibility.notes, participationContent.eligibility.notes);
const mobileDates = fallbackArray(datesBanner.mobileItems, participationContent.datesBanner.mobileItems);
const desktopDates = fallbackArray(datesBanner.desktopItems, participationContent.datesBanner.desktopItems);
const applicationFacts = fallbackArray(applicationForm.facts, participationContent.applicationForm.facts);
const finalSectionFacts = showApplicationForm ? applicationFacts : newsletterCopy.benefits;
return (
<div className="animate-fade-in">
@@ -101,11 +81,11 @@ const Participation: React.FC = () => {
<div style={{ width: 36, height: 2, background: GOLD }} />
<span style={{ fontFamily: FF, fontSize: 12, fontWeight: 700, letterSpacing: '0.28em', textTransform: 'uppercase', color: '#EFBF04' }}>{fallbackText(hero.eyebrow, participationContent.hero.eyebrow)}</span>
</div>
<h1 style={{ fontFamily: FF, fontSize: isMobile ? 'clamp(1.6rem, 6vw, 5rem)' : 'clamp(2.8rem, 6vw, 5rem)', fontWeight: 900, color: '#fff', lineHeight: 0.95, letterSpacing: '-0.03em', textTransform: 'uppercase', margin: '0 0 24px' }}>
<h1 className="type-display text-role-inverse" style={{ textTransform: 'uppercase', margin: '0 0 24px' }}>
<Lines text={fallbackText(hero.heading, participationContent.hero.heading)} />
</h1>
<div style={{ width: 48, height: 2, background: GOLD, marginBottom: 24 }} />
<p style={{ fontFamily: FB, fontSize: 18, color: 'rgba(255,255,255,0.6)', lineHeight: 1.7, maxWidth: 520, fontWeight: 300 }}>
<p className="type-body text-role-inverse-secondary" style={{ maxWidth: 520 }}>
{fallbackText(hero.description, participationContent.hero.description)}
</p>
</div>
@@ -115,14 +95,13 @@ const Participation: React.FC = () => {
<WegZurAuszeichnungSection content={process} />
{/* ── QUALIFICATIONS ───────────────────────────────────────────────────── */}
<section id="voraussetzungen" ref={qualSectionRef} style={{ background: CREAM, overflow: 'hidden', position: 'relative', isolation: 'isolate' }}>
<MunichSkylineBg />
<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: '#4A8FC9', textTransform: 'uppercase', letterSpacing: '0.32em', fontWeight: 700, display: 'block', marginBottom: 16 }}>{fallbackText(eligibility.eyebrow, participationContent.eligibility.eyebrow)}</span>
<h2 style={{ fontFamily: FF, fontSize: 'clamp(2rem, 3.5vw, 3rem)', fontWeight: 900, color: '#101828', textTransform: 'uppercase', letterSpacing: '-0.025em', lineHeight: 1.03, margin: 0 }}>
<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 }}>
<Lines text={fallbackText(eligibility.heading, participationContent.eligibility.heading)} />
</h2>
</div>
@@ -137,63 +116,67 @@ const Participation: React.FC = () => {
{fallbackText(eligibility.primaryCta?.label, participationContent.eligibility.primaryCta.label)} <ArrowRight size={13} />
</Link>
</div>
<p style={{ fontFamily: FB, fontSize: 18, color: 'rgba(16,24,40,0.5)', lineHeight: 1.8, margin: 0, textAlign: isMobile ? 'left' : 'right' }}>
<p className="type-body text-role-secondary" style={{ margin: 0, textAlign: isMobile ? 'left' : 'right' }}>
{fallbackText(eligibility.description, participationContent.eligibility.description)}
</p>
</div>
</div>
{/* 4 criteria as editorial rows */}
<div style={{ position: 'relative', zIndex: 1, 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 style={{ fontFamily: FB, fontSize: 17, color: 'rgba(16,24,40,0.6)', lineHeight: 1.7, 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>
@@ -202,15 +185,15 @@ const Participation: React.FC = () => {
/* Desktop: full banner */
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1px 1fr 1px 1fr 1px 1fr', maxWidth: 1200, margin: '0 auto' }}>
<div style={{ padding: '48px 56px' }}>
<h3 style={{ fontFamily: FF, fontSize: 18, fontWeight: 900, color: '#101828', textTransform: 'uppercase', letterSpacing: '-0.02em', marginBottom: 6 }}>{fallbackText(datesBanner.heading, participationContent.datesBanner.heading)}</h3>
<p style={{ fontFamily: FB, fontSize: 18, color: 'rgba(16,24,40,0.6)', margin: 0 }}>{fallbackText(datesBanner.description, participationContent.datesBanner.description)}</p>
<h3 className="type-card-title text-role-heading" style={{ textTransform: 'uppercase', marginBottom: 6 }}>{fallbackText(datesBanner.heading, participationContent.datesBanner.heading)}</h3>
<p className="type-body text-role-secondary" style={{ margin: 0 }}>{fallbackText(datesBanner.description, participationContent.datesBanner.description)}</p>
</div>
<div style={{ background: 'rgba(3,9,58,0.12)' }} />
{desktopDates.map((d, i) => (
<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>
@@ -222,129 +205,39 @@ const Participation: React.FC = () => {
{/* ── AWARDS GRID ─────────────────────────────────────────────────────── */}
<AwardsGridSection content={awardsGrid} featuredGoldeneBavaria />
{/* ── FORM SECTION ─────────────────────────────────────────────────────── */}
<section style={{ background: NAVY, 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={{ display: 'grid', gridTemplateColumns: isMobile ? '1fr' : '4fr 1px 8fr', flex: 1, minHeight: 0, overflow: isMobile ? 'visible' : 'hidden' }}>
{/* Left – copy */}
<div style={{ padding: isMobile ? '32px 24px 24px' : '36px 36px 32px 52px', display: 'flex', flexDirection: 'column', justifyContent: 'center', overflow: isMobile ? 'visible' : 'hidden' }}>
<span style={{ fontFamily: FF, fontSize: 10, color: '#EFBF04', textTransform: 'uppercase', letterSpacing: '0.32em', fontWeight: 700, display: 'block', marginBottom: 20 }}>{showApplicationForm ? fallbackText(applicationForm.eyebrow, participationContent.applicationForm.eyebrow) : newsletterCopy.eyebrow}</span>
<h2 style={{ fontFamily: FF, fontSize: 'clamp(2rem, 3.2vw, 2.8rem)', fontWeight: 900, color: '#fff', textTransform: 'uppercase', letterSpacing: '-0.025em', lineHeight: 1.04, margin: '0 0 20px' }}>
<Lines text={showApplicationForm ? fallbackText(applicationForm.heading, participationContent.applicationForm.heading) : newsletterCopy.heading} />
</h2>
<div style={{ width: 36, height: 2, background: GOLD, marginBottom: 28 }} />
<p style={{ fontFamily: FB, fontSize: 18, color: 'rgba(255,255,255,0.68)', lineHeight: 1.8, maxWidth: 400, marginBottom: 48 }}>
{showApplicationForm ? fallbackText(applicationForm.description, participationContent.applicationForm.description) : newsletterCopy.body}
</p>
<div style={{ borderTop: '1px solid rgba(255,255,255,0.08)' }}>
{finalSectionFacts.map((item, i) => (
<div key={`${fallbackText(item.num, String(i))}-${i}`} style={{ display: 'grid', gridTemplateColumns: '36px 1fr', gap: '0 16px', padding: '16px 0', borderBottom: '1px solid rgba(255,255,255,0.06)', alignItems: 'center' }}>
<span style={{ fontFamily: FF, fontSize: 10, fontWeight: 700, color: 'rgba(239,191,4,0.5)', letterSpacing: '0.1em' }}>{fallbackText(item.num, '')}</span>
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'baseline' }}>
<span style={{ fontFamily: FF, fontSize: 16, fontWeight: 700, color: '#fff', textTransform: 'uppercase', letterSpacing: '0.08em' }}>{fallbackText(item.label, '')}</span>
<span style={{ fontFamily: FB, fontSize: 16, color: 'rgba(255,255,255,0.62)' }}>{fallbackText(item.desc, '')}</span>
</div>
</div>
))}
</div>
</div>
{/* Divider */}
{!isMobile && <div style={{ background: 'rgba(255,255,255,0.07)' }} />}
{/* Right – gold form panel */}
<div style={{ display: 'flex', flexDirection: 'column', overflow: isMobile ? 'visible' : 'hidden', minHeight: 0, position: 'relative', background: 'linear-gradient(160deg,#DDB84A 0%,#C9A227 52%,#A87800 100%)' }}>
<svg style={{ position: 'absolute', inset: 0, width: '100%', height: '100%', pointerEvents: 'none', zIndex: 0 }} aria-hidden="true">
<defs>
<pattern id="participationCross" width="18" height="18" patternUnits="userSpaceOnUse">
<path d="M0,0 L18,18 M18,0 L0,18" stroke="rgba(17,29,85,0.055)" strokeWidth="0.65" />
</pattern>
<pattern id="participationDiamond" width="36" height="36" patternUnits="userSpaceOnUse">
<polygon points="18,2 34,18 18,34 2,18" fill="none" stroke="rgba(17,29,85,0.045)" strokeWidth="0.6" />
</pattern>
</defs>
<rect width="100%" height="100%" fill="url(#participationCross)" />
<rect width="100%" height="100%" fill="url(#participationDiamond)" />
</svg>
<div style={{ position: 'absolute', inset: 0, background: 'radial-gradient(ellipse at center, transparent 55%, rgba(100,70,0,0.25) 100%)', pointerEvents: 'none', zIndex: 0 }} />
{!isMobile && (
<div style={{ height: 220, position: 'relative', overflow: 'hidden', flexShrink: 0, zIndex: 1 }}>
<Image unoptimized src={mediaUrl(applicationForm.image, `/images/${participationContent.applicationForm.imageFilename}`)} alt={mediaAlt(applicationForm.image, fallbackText(applicationForm.imageAlt, participationContent.applicationForm.imageAlt))} style={{ width: '100%', height: '100%', objectFit: 'cover', objectPosition: 'center 30%', filter: 'sepia(0.18) brightness(0.92)' }} />
<div style={{ position: 'absolute', inset: 0, background: 'linear-gradient(to bottom, rgba(168,120,0,0.1) 0%, rgba(168,120,0,0.25) 50%, rgba(168,120,0,0.92) 88%, #A87800 100%)' }} />
<div style={{ position: 'absolute', bottom: 0, left: 0, right: 0, height: 2, background: 'rgba(17,29,85,0.35)' }} />
<div style={{ position: 'absolute', bottom: 16, left: 40, display: 'flex', alignItems: 'center', gap: 8 }}>
<div style={{ width: 5, height: 5, borderRadius: '50%', background: 'rgba(17,29,85,0.7)' }} />
<span style={{ fontFamily: FF, fontSize: 10, fontWeight: 700, color: 'rgba(17,29,85,0.75)', textTransform: 'uppercase', letterSpacing: '0.2em' }}>{fallbackText(applicationForm.imageCaption, participationContent.applicationForm.imageCaption)}</span>
</div>
</div>
)}
<div style={{ padding: isMobile ? '28px 20px' : '24px 48px 32px 40px', flex: 1, display: 'flex', flexDirection: 'column', minHeight: isMobile && showApplicationForm ? 560 : 0, position: 'relative', zIndex: 1 }}>
{showApplicationForm ? (
<>
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', gap: 16, marginBottom: 10 }}>
<span style={{ fontFamily: FF, fontSize: 10, color: 'rgba(17,29,85,0.5)', textTransform: 'uppercase', letterSpacing: '0.28em', fontWeight: 700 }}>{fallbackText(applicationForm.formEyebrow, participationContent.applicationForm.formEyebrow)}</span>
</div>
<h3 style={{ fontFamily: FF, fontSize: 'clamp(1.2rem, 1.8vw, 1.6rem)', fontWeight: 900, color: '#111D55', textTransform: 'uppercase', letterSpacing: '-0.02em', lineHeight: 1.1, marginBottom: 24 }}>{fallbackText(applicationForm.formTitle, participationContent.applicationForm.formTitle)}</h3>
<BewerbungsForm theme="gold" content={form} />
</>
) : (
<>
<span style={{ fontFamily: FF, fontSize: 10, color: 'rgba(17,29,85,0.5)', textTransform: 'uppercase', letterSpacing: '0.32em', fontWeight: 700, display: 'block', marginBottom: 8 }}>{newsletterCopy.formEyebrow}</span>
<h3 style={{ fontFamily: FF, fontSize: 'clamp(1.2rem, 1.8vw, 1.6rem)', fontWeight: 900, color: '#111D55', textTransform: 'uppercase', letterSpacing: '-0.02em', lineHeight: 1.1, marginBottom: 24 }}>{newsletterCopy.formTitle}</h3>
<NewsletterInterestForm copy={newsletterCopy} phase={newsletterPhase} source="participation" />
</>
)}
</div>
</div>
</div>
<div style={{ position: 'relative', zIndex: 1, padding: isMobile ? '16px 20px 20px' : '14px 56px 18px', display: 'flex', flexDirection: 'column', alignItems: 'center', gap: 8, justifyContent: 'center', textAlign: 'center', borderTop: '1px solid rgba(255,255,255,0.06)', flexShrink: 0 }}>
{showApplicationForm ? (
<>
<span style={{ fontFamily: FB, fontSize: 14, color: 'rgba(255,255,255,0.55)', lineHeight: 1.6, maxWidth: 520 }}>
{fallbackText(applicationForm.uploadPrompt, participationContent.applicationForm.uploadPrompt)}
</span>
</>
) : (
<div style={{ display: 'flex', flexWrap: 'wrap', justifyContent: 'center', gap: isMobile ? '0 6px' : '0 10px' }}>
<span style={{ fontFamily: FF, fontSize: 15, color: 'rgba(255,255,255,0.45)' }}>{newsletterCopy.footnote}</span>
<span style={{ fontFamily: FF, fontSize: 15, fontWeight: 700, color: 'rgba(239,191,4,0.8)' }}>{newsletterCopy.footnoteStrong}</span>
</div>
)}
</div>
<div style={{ height: 2, background: 'linear-gradient(to right, #EFBF04, rgba(239,191,4,0.25), transparent)', flexShrink: 0 }} />
</section>
<ApplicationFormSection source="participation" />
</div>
);
};
// ── 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>
);
}
@@ -360,17 +253,16 @@ function BewerbungswegeSection({ content }: { content?: ApplicationWaysContent }
(way) => !isFutureAwardApplicationWay(way),
);
return (
<section style={{ background: CREAM, position: 'relative', overflow: 'hidden', isolation: 'isolate' }}>
<MunichSkylineBg />
<section style={{ background: '#fff', position: 'relative', overflow: 'hidden', isolation: 'isolate' }}>
{/* Header */}
<div style={{ position: 'relative', zIndex: 1, padding: isMobile ? '48px 24px 32px' : '80px 80px 56px', borderBottom: '1px solid #D0D5DD' }}>
<span style={{ fontFamily: FF, fontSize: 10, color: '#4A8FC9', textTransform: 'uppercase', letterSpacing: '0.32em', fontWeight: 700, display: 'block', marginBottom: 16 }}>{fallbackText(section.eyebrow, participationContent.applicationWays.eyebrow)}</span>
<span style={{ fontFamily: FF, fontSize: 10, color: 'var(--text-eyebrow-light)', textTransform: 'uppercase', letterSpacing: '0.32em', fontWeight: 700, display: 'block', marginBottom: 16 }}>{fallbackText(section.eyebrow, participationContent.applicationWays.eyebrow)}</span>
<div style={{ display: 'grid', gridTemplateColumns: isMobile ? '1fr' : '1fr 1fr', gap: isMobile ? 16 : 48, alignItems: 'flex-end' }}>
<h2 style={{ fontFamily: FF, fontSize: 'clamp(2rem, 3.5vw, 3rem)', fontWeight: 900, color: '#101828', textTransform: 'uppercase', letterSpacing: '-0.025em', lineHeight: 1.03, margin: 0 }}>
<h2 className="type-section-title text-role-heading" style={{ textTransform: 'uppercase', margin: 0 }}>
<Lines text={fallbackText(section.heading, participationContent.applicationWays.heading)} />
</h2>
<p style={{ fontFamily: FB, fontSize: 18, color: 'rgba(16,24,40,0.55)', lineHeight: 1.8, margin: 0 }}>
<p className="type-body text-role-secondary" style={{ margin: 0 }}>
{fallbackText(section.description, participationContent.applicationWays.description)}
</p>
</div>
@@ -391,8 +283,8 @@ function BewerbungswegeSection({ content }: { content?: ApplicationWaysContent }
<Icon size={20} style={{ color: GOLD }} strokeWidth={1.6} />
</div>
<span style={{ fontFamily: FF, fontSize: 9, fontWeight: 700, letterSpacing: '0.28em', textTransform: 'uppercase', color: 'rgba(16,24,40,0.4)', display: 'block', marginBottom: 8 }}>{fallbackText(w.label, '')}</span>
<h3 style={{ fontFamily: FF, fontSize: '1.45rem', fontWeight: 900, color: '#101828', textTransform: 'uppercase', letterSpacing: '-0.02em', lineHeight: 1.08, margin: '0 0 10px' }}><Lines text={fallbackText(w.title, '')} /></h3>
<p style={{ fontFamily: FB, fontSize: 16, color: 'rgba(16,24,40,0.55)', lineHeight: 1.6, margin: '0 0 20px' }}>{fallbackText(w.desc, '')}</p>
<h3 className="type-card-title text-role-heading" style={{ textTransform: 'uppercase', margin: '0 0 10px' }}><Lines text={fallbackText(w.title, '')} /></h3>
<p className="type-small text-role-secondary" style={{ margin: '0 0 20px' }}>{fallbackText(w.desc, '')}</p>
<div style={{ width: 36, height: 2, background: GOLD }} />
</div>
{/* Detail list */}
@@ -429,10 +321,10 @@ function BewerbungswegeSection({ content }: { content?: ApplicationWaysContent }
<Icon size={18} style={{ color: GOLD }} strokeWidth={1.5} />
</div>
<span style={{ fontFamily: FF, fontSize: 9, fontWeight: 700, letterSpacing: '0.3em', textTransform: 'uppercase', color: 'rgba(16,24,40,0.4)', display: 'block', marginBottom: 10 }}>{fallbackText(w.label, '')}</span>
<h3 style={{ fontFamily: FF, fontSize: 'clamp(1.3rem, 2vw, 1.7rem)', fontWeight: 900, color: '#101828', textTransform: 'uppercase', letterSpacing: '-0.02em', lineHeight: 1.1, margin: '0 0 8px' }}>
<h3 className="type-card-title text-role-heading" style={{ textTransform: 'uppercase', margin: '0 0 8px' }}>
<Lines text={fallbackText(w.title, '')} />
</h3>
<p style={{ fontFamily: FB, fontSize: 18, color: 'rgba(16,24,40,0.5)', lineHeight: 1.7, margin: '0 0 32px' }}>
<p className="type-body text-role-secondary" style={{ margin: '0 0 32px' }}>
{fallbackText(w.desc, '')}
</p>

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';
@@ -15,10 +15,16 @@ const GOLD = '#EFBF04';
const BORDER = '#D0D5DD';
const GRAY = '#666666';
const BG_ALT = '#E4E2E3';
const SIMPLE_LIST_YEAR = 2023;
const ARCHIVE_START_YEAR = 2007;
const ARCHIVE_END_YEAR = 2023;
const ARCHIVE_PERIOD = 'archive-2007-2023' as const;
const ARCHIVE_RANGE_LABEL = `${ARCHIVE_START_YEAR}–${ARCHIVE_END_YEAR}`;
const ARCHIVE_TAB_LABEL = 'Bis 2023';
const GOLDENE_BAVARIA_GROUP = 'goldene-bavaria';
const PREISTRAEGER_GROUP = 'preistraeger';
type WinnerPeriod = number | typeof ARCHIVE_PERIOD;
type PreistraegerIndexCms = Partial<typeof preistraegerIndexContent> & {
hero?: Partial<typeof preistraegerIndexContent.hero> & { image?: unknown }
card?: Partial<typeof preistraegerIndexContent.card> & { fallbackImage?: unknown }
@@ -26,6 +32,7 @@ type PreistraegerIndexCms = Partial<typeof preistraegerIndexContent> & {
const fallbackText = (value: unknown, fallback: string) => typeof value === 'string' && value.length > 0 ? value : fallback;
const optionalNumber = (value: unknown) => {
if (value === null || value === undefined || value === '') return undefined;
const parsed = Number(value);
return Number.isFinite(parsed) ? parsed : undefined;
};
@@ -53,6 +60,18 @@ const sortWinnersForDisplay = (winners: Winner[]) =>
})
.map(({ winner }) => winner);
const isArchiveWinner = (winner: Winner) =>
winner.year === undefined || (winner.year >= ARCHIVE_START_YEAR && winner.year <= ARCHIVE_END_YEAR);
const sortArchiveWinners = (winners: Winner[]) =>
winners
.map((winner, index) => ({ winner, index }))
.sort((a, b) => {
const sortDiff = (a.winner.sortOrder ?? Number.POSITIVE_INFINITY) - (b.winner.sortOrder ?? Number.POSITIVE_INFINITY);
return sortDiff !== 0 ? sortDiff : a.index - b.index;
})
.map(({ winner }) => winner);
const splitWinnersForYear = (winners: Winner[], totalWinners: number, goldeneBavariaCount: number) => {
const visibleWinners = sortWinnersForDisplay(winners.filter((winner) => winner.showOnIndex !== false));
const maxGoldeneBavaria = Math.min(goldeneBavariaCount, totalWinners);
@@ -89,7 +108,7 @@ const normalizeWinner = (
slug: String(doc.slug || ''),
name: String(doc.title || fallbackWinner.name || preistraegerIndexContent.card.fallbackWinner.name),
category: String(doc.category || fallbackWinner.category || preistraegerIndexContent.card.fallbackWinner.category),
year: Number(doc.year || new Date().getFullYear()),
year: optionalNumber(doc.year),
type: String(
doc.awardType ||
(normalizeAwardGroup(doc.awardGroup) === GOLDENE_BAVARIA_GROUP ? 'Goldene Bavaria' : fallbackWinner.awardType) ||
@@ -132,19 +151,41 @@ 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 years = useMemo(() => [...new Set(winners.map((winner) => winner.year))].sort((a, b) => b - a), [winners]);
const [activeYear, setActiveYear] = useState<number | undefined>(undefined);
const selectedYear = activeYear ?? years[0] ?? new Date().getFullYear();
const periods = useMemo<WinnerPeriod[]>(() => {
const annualYears = [...new Set(
winners
.map((winner) => winner.year)
.filter((year): year is number => typeof year === 'number' && year > ARCHIVE_END_YEAR),
)].sort((a, b) => b - a);
const filtered = useMemo(() => winners.filter(w => w.year === selectedYear), [selectedYear, winners]);
const winnerGroups = useMemo(
() => splitWinnersForYear(filtered, totalWinners, goldeneBavariaCount),
[filtered, goldeneBavariaCount, totalWinners],
return winners.some(isArchiveWinner) ? [...annualYears, ARCHIVE_PERIOD] : annualYears;
}, [winners]);
const [activePeriod, setActivePeriod] = useState<WinnerPeriod | undefined>(undefined);
const selectedPeriod = activePeriod ?? periods[0] ?? ARCHIVE_PERIOD;
const isArchivePeriod = selectedPeriod === ARCHIVE_PERIOD;
const selectedPeriodLabel = isArchivePeriod ? ARCHIVE_RANGE_LABEL : String(selectedPeriod);
const filtered = useMemo(
() => winners.filter((winner) => isArchivePeriod ? isArchiveWinner(winner) : winner.year === selectedPeriod),
[isArchivePeriod, selectedPeriod, winners],
);
const winnerGroups = useMemo(
() => {
if (isArchivePeriod) {
const archiveWinners = sortArchiveWinners(filtered.filter((winner) => winner.showOnIndex !== false));
return {
goldeneBavariaWinners: [],
remainingWinners: archiveWinners,
displayedWinners: archiveWinners,
};
}
return splitWinnersForYear(filtered, totalWinners, goldeneBavariaCount);
},
[filtered, goldeneBavariaCount, isArchivePeriod, totalWinners],
);
const usesSimpleWinnerGrid = selectedYear === SIMPLE_LIST_YEAR;
return (
<div style={{ background: '#fff', minHeight: '100vh' }}>
@@ -159,9 +200,11 @@ export default function Preistraeger() {
<div style={{ position: 'absolute', bottom: 0, left: 0, right: 0, height: 2, background: 'linear-gradient(to right, #EFBF04, rgba(239,191,4,0.3), transparent)' }} />
</div>
{/* Breadcrumb */}
{/* Page title */}
<div style={{ background: '#fff', borderBottom: `1px solid ${BORDER}`, padding: isMobile ? '18px 24px' : '18px 80px' }}>
<span style={{ fontFamily: FF, fontSize: 16, color: GOLD, fontWeight: 600, letterSpacing: '0.04em' }}>{fallbackText(breadcrumb.label, preistraegerIndexContent.breadcrumb.label)}</span>
<h1 className="type-card-title text-role-brand-light" style={{ margin: 0 }}>
{fallbackText(breadcrumb.label, preistraegerIndexContent.breadcrumb.label)}
</h1>
</div>
{/* Year Filter Section */}
@@ -170,23 +213,23 @@ export default function Preistraeger() {
{/* Year Tabs */}
<div style={{ display: 'flex', flexWrap: isMobile ? 'nowrap' : 'wrap', gap: 0, borderBottom: `2px solid ${BORDER}`, overflowX: isMobile ? 'auto' : 'visible', WebkitOverflowScrolling: 'touch', maxWidth: '100%' }}>
{years.map(year => (
{periods.map(period => (
<button
key={year}
onClick={() => setActiveYear(year)}
key={period}
onClick={() => setActivePeriod(period)}
style={{
fontFamily: FF, fontSize: 16, fontWeight: 700,
padding: isMobile ? '12px 20px' : '12px 32px',
flexShrink: 0,
background: 'none', border: 'none', cursor: 'pointer',
color: selectedYear === year ? NAVY : GRAY,
borderBottom: selectedYear === year ? `2px solid ${GOLD}` : '2px solid transparent',
color: selectedPeriod === period ? NAVY : GRAY,
borderBottom: selectedPeriod === period ? `2px solid ${GOLD}` : '2px solid transparent',
marginBottom: -2,
letterSpacing: '0.04em',
transition: 'color 0.15s, border-color 0.15s',
}}
>
{year}
{period === ARCHIVE_PERIOD ? ARCHIVE_TAB_LABEL : period}
</button>
))}
</div>
@@ -196,7 +239,7 @@ export default function Preistraeger() {
<div style={{ padding: isMobile ? '12px 24px' : '12px 80px', background: BG_ALT, borderTop: `1px solid ${BORDER}`, borderBottom: `1px solid ${BORDER}`, display: 'flex', alignItems: 'center', gap: 12 }}>
<Trophy size={13} style={{ color: GOLD }} fill={GOLD} />
<span style={{ fontFamily: FF, fontSize: 15, color: GRAY }}>
<strong style={{ color: '#101828' }}>{selectedYear}</strong> – {winnerGroups.displayedWinners.length} {fallbackText(count.label, preistraegerIndexContent.count.label)}
<strong style={{ color: '#101828' }}>{selectedPeriodLabel}</strong> – {winnerGroups.displayedWinners.length} {fallbackText(count.label, preistraegerIndexContent.count.label)}
</span>
</div>
@@ -204,6 +247,8 @@ export default function Preistraeger() {
<div style={{ padding: isMobile ? '56px 24px' : '80px', textAlign: 'center', color: GRAY, fontFamily: FF }}>
{fallbackText(empty.message, preistraegerIndexContent.empty.message)}
</div>
) : isArchivePeriod ? (
<SimpleWinnerGrid winners={winnerGroups.displayedWinners} />
) : (
<>
{winnerGroups.goldeneBavariaWinners.length > 0 ? (
@@ -217,27 +262,23 @@ export default function Preistraeger() {
{winnerGroups.remainingWinners.length > 0 ? (
<section style={{ background: '#fff' }}>
<SectionIntro copy={winnersSection} isMobile={isMobile} />
{usesSimpleWinnerGrid ? (
<SimpleWinnerGrid winners={winnerGroups.remainingWinners} />
) : (
<div style={{
display: 'grid',
gridTemplateColumns: isMobile ? undefined : 'repeat(4, 1fr)',
gridAutoFlow: isMobile ? 'column' : undefined,
gridTemplateRows: isMobile ? 'repeat(2, 1fr)' : undefined,
gridAutoColumns: isMobile ? '74vw' : undefined,
overflowX: isMobile ? 'auto' : undefined,
scrollSnapType: isMobile ? 'x mandatory' : undefined,
gap: isMobile ? 8 : 0,
padding: isMobile ? '0 16px 8px' : 0,
WebkitOverflowScrolling: 'touch',
scrollbarWidth: 'none',
}}>
{winnerGroups.remainingWinners.map(w => (
<WinnerCard key={w.id} winner={w} hoverLabel={fallbackText(card.hoverLabel, preistraegerIndexContent.card.hoverLabel)} />
))}
</div>
)}
<div style={{
display: 'grid',
gridTemplateColumns: isMobile ? undefined : 'repeat(4, 1fr)',
gridAutoFlow: isMobile ? 'column' : undefined,
gridTemplateRows: isMobile ? 'repeat(2, 1fr)' : undefined,
gridAutoColumns: isMobile ? '74vw' : undefined,
overflowX: isMobile ? 'auto' : undefined,
scrollSnapType: isMobile ? 'x mandatory' : undefined,
gap: isMobile ? 8 : 0,
padding: isMobile ? '0 16px 8px' : 0,
WebkitOverflowScrolling: 'touch',
scrollbarWidth: 'none',
}}>
{winnerGroups.remainingWinners.map(w => (
<WinnerCard key={w.id} winner={w} hoverLabel={fallbackText(card.hoverLabel, preistraegerIndexContent.card.hoverLabel)} />
))}
</div>
</section>
) : null}
</>
@@ -245,7 +286,7 @@ export default function Preistraeger() {
{/* Bottom CTA */}
<div style={{ padding: isMobile ? '48px 24px' : '64px 80px', borderTop: `1px solid ${BORDER}`, textAlign: 'center' }}>
<p style={{ fontFamily: FF, fontSize: 17, color: GRAY, marginBottom: 20 }}>{fallbackText(cta.text, preistraegerIndexContent.cta.text)}</p>
<p className="type-body text-role-secondary" style={{ marginBottom: 20 }}>{fallbackText(cta.text, preistraegerIndexContent.cta.text)}</p>
<Link
to={fallbackText(cta.primaryCta?.url, preistraegerIndexContent.cta.primaryCta.url)}
style={{
@@ -267,13 +308,13 @@ export default function Preistraeger() {
function SectionIntro({ copy, isMobile }: { copy: typeof preistraegerIndexContent.winners; isMobile: boolean }) {
return (
<div style={{ padding: isMobile ? '40px 24px 22px' : '56px 80px 28px', maxWidth: 900 }}>
<span style={{ color: GOLD, fontFamily: FF, fontSize: 12, fontWeight: 800, textTransform: 'uppercase', letterSpacing: 0 }}>
<span className="type-eyebrow text-role-brand-light" style={{ textTransform: 'uppercase' }}>
{fallbackText(copy.eyebrow, preistraegerIndexContent.winners.eyebrow)}
</span>
<h2 style={{ color: NAVY, fontFamily: FF, fontSize: isMobile ? 28 : 42, fontWeight: 900, lineHeight: 1.05, margin: '10px 0 12px', textTransform: 'uppercase', letterSpacing: 0 }}>
<h2 className="type-section-title text-role-heading" style={{ margin: '10px 0 12px', textTransform: 'uppercase' }}>
{fallbackText(copy.heading, preistraegerIndexContent.winners.heading)}
</h2>
<p style={{ color: GRAY, fontFamily: FF, fontSize: 17, lineHeight: 1.6, margin: 0, maxWidth: 680 }}>
<p className="type-body text-role-secondary" style={{ margin: 0, maxWidth: 680 }}>
{fallbackText(copy.description, preistraegerIndexContent.winners.description)}
</p>
</div>
@@ -294,13 +335,13 @@ function GoldeneBavariaSection({
return (
<section style={{ background: NAVY, color: '#fff', borderTop: `1px solid ${BORDER}` }}>
<div style={{ padding: isMobile ? '44px 24px 26px' : '64px 80px 34px', maxWidth: 980 }}>
<span style={{ color: GOLD, fontFamily: FF, fontSize: 12, fontWeight: 800, textTransform: 'uppercase', letterSpacing: 0 }}>
<span className="type-eyebrow text-role-inverse" style={{ color: GOLD, textTransform: 'uppercase' }}>
{fallbackText(copy.eyebrow, preistraegerIndexContent.goldeneBavaria.eyebrow)}
</span>
<h2 style={{ color: '#fff', fontFamily: FF, fontSize: isMobile ? 30 : 48, fontWeight: 900, lineHeight: 1.02, margin: '10px 0 14px', textTransform: 'uppercase', letterSpacing: 0 }}>
<h2 className="type-section-title text-role-inverse" style={{ margin: '10px 0 14px', textTransform: 'uppercase' }}>
{fallbackText(copy.heading, preistraegerIndexContent.goldeneBavaria.heading)}
</h2>
<p style={{ color: 'rgba(255,255,255,0.74)', fontFamily: FF, fontSize: 18, lineHeight: 1.6, margin: 0, maxWidth: 760 }}>
<p className="type-body text-role-inverse-secondary" style={{ margin: 0, maxWidth: 760 }}>
{fallbackText(copy.description, preistraegerIndexContent.goldeneBavaria.description)}
</p>
</div>
@@ -342,13 +383,13 @@ function GoldeneBavariaCard({ winner, rank, hoverLabel }: { winner: Winner; rank
</div>
</div>
<div style={{ padding: isMobile ? '20px' : '24px' }}>
<span style={{ color: GOLD, fontFamily: FF, fontSize: 12, fontWeight: 800, textTransform: 'uppercase', letterSpacing: 0 }}>
<span className="type-eyebrow text-role-brand-light" style={{ textTransform: 'uppercase' }}>
{winner.year} · {winner.category}
</span>
<h3 style={{ color: NAVY, fontFamily: FF, fontSize: isMobile ? 23 : 26, fontWeight: 900, lineHeight: 1.12, margin: '10px 0 10px', overflowWrap: 'anywhere' }}>
<h3 className="type-card-title text-role-heading" style={{ margin: '10px 0 10px', overflowWrap: 'anywhere' }}>
{winner.name}
</h3>
<p style={{ color: GRAY, fontFamily: FF, fontSize: 15, lineHeight: 1.55, margin: 0, overflowWrap: 'anywhere' }}>
<p className="type-small text-role-secondary" style={{ margin: 0, overflowWrap: 'anywhere' }}>
{winner.shortDesc}
</p>
<span style={{ marginTop: 18, display: 'inline-flex', alignItems: 'center', gap: 7, color: NAVY, fontFamily: FF, fontSize: 14, fontWeight: 800, textTransform: 'uppercase', letterSpacing: 0 }}>
@@ -389,10 +430,10 @@ function WinnerCard({ winner, hoverLabel }: { winner: Winner; hoverLabel: string
{winner.year} · {winner.type}
</span>
</div>
<h3 style={{ fontFamily: FF, fontSize: 18, fontWeight: 700, color: '#fff', margin: 0, lineHeight: 1.3, textShadow: '0 1px 4px rgba(0,0,0,0.5)' }}>
<h3 className="type-card-title text-role-inverse" style={{ margin: 0, textShadow: '0 1px 4px rgba(0,0,0,0.5)' }}>
{winner.name}
</h3>
<p style={{ fontFamily: FF, fontSize: 16, color: 'rgba(255,255,255,0.82)', margin: '4px 0 0', lineHeight: 1.3 }}>
<p className="type-small text-role-inverse-secondary" style={{ margin: '4px 0 0' }}>
{winner.category}
</p>
@@ -436,6 +477,7 @@ function SimpleWinnerGrid({ winners }: { winners: Winner[] }) {
function SimpleWinnerCard({ winner }: { winner: Winner }) {
const isMobile = useIsMobile();
const hasDescription = winner.shortDesc.trim().length > 0;
const winnerMeta = [winner.year, winner.type].filter(Boolean).join(' · ');
return (
<article
@@ -450,16 +492,11 @@ function SimpleWinnerCard({ winner }: { winner: Winner }) {
}}
>
<div style={{ display: 'flex', alignItems: 'center', gap: 8, flexWrap: 'wrap', marginBottom: 18 }}>
<span style={{
color: GOLD,
fontFamily: FF,
fontSize: 12,
fontWeight: 700,
letterSpacing: '0.12em',
textTransform: 'uppercase',
}}>
{winner.year} · {winner.type}
</span>
{winnerMeta ? (
<span className="type-eyebrow text-role-brand-light" style={{ textTransform: 'uppercase' }}>
{winnerMeta}
</span>
) : null}
{winner.category ? (
<span style={{
border: `1px solid ${BORDER}`,
@@ -475,12 +512,7 @@ function SimpleWinnerCard({ winner }: { winner: Winner }) {
) : null}
</div>
<h3 style={{
color: NAVY,
fontFamily: FF,
fontSize: isMobile ? 20 : 22,
fontWeight: 700,
lineHeight: 1.18,
<h3 className="type-card-title text-role-heading" style={{
margin: 0,
overflowWrap: 'anywhere',
}}>
@@ -488,11 +520,7 @@ function SimpleWinnerCard({ winner }: { winner: Winner }) {
</h3>
{hasDescription ? (
<p style={{
color: GRAY,
fontFamily: FF,
fontSize: 15,
lineHeight: 1.55,
<p className="type-small text-role-secondary" style={{
margin: '16px 0 0',
overflowWrap: 'anywhere',
}}>

View File

@@ -1,7 +1,6 @@
import React from 'react';
import { Link, useParams, Navigate } from '@/spa/router';
import { Trophy, ArrowLeft, MapPin, Building2, Globe, ChevronRight, Download, ChevronLeft, X } from 'lucide-react';
import { WINNERS } from '@/spa/data/winners';
import { ArrowLeft, Globe, ChevronRight, Download, ChevronLeft, X } from 'lucide-react';
import { useCmsRoute } from '@/spa/cmsRoute';
import { docImageUrl, mediaAlt, mediaUrl } from '@/spa/cmsMediaField';
import { useIsMobile } from '@/spa/hooks/useIsMobile';
@@ -37,6 +36,12 @@ type PreistraegerGalleryCms = {
}
const fallbackText = (value: unknown, fallback: string) => typeof value === 'string' && value.length > 0 ? value : fallback;
const cmsText = (value: unknown) => typeof value === 'string' ? value.trim() : '';
const optionalNumber = (value: unknown) => {
if (value === null || value === undefined || value === '') return undefined;
const parsed = Number(value);
return Number.isFinite(parsed) ? parsed : undefined;
};
const mediaFilename = (media: unknown) => {
if (!media || typeof media !== 'object') return undefined;
@@ -60,29 +65,32 @@ export default function PreistraegerDetail() {
slug: String(cmsWinner.slug || slug),
name: String(cmsWinner.title || fallbackWinner.name || preistraegerDetailContent.fallbackWinner.name),
category: String(cmsWinner.category || fallbackWinner.category || preistraegerDetailContent.fallbackWinner.category),
year: Number(cmsWinner.year || new Date().getFullYear()),
year: optionalNumber(cmsWinner.year),
type: String(cmsWinner.awardType || (cmsWinnerIsGoldeneBavaria ? 'Goldene Bavaria' : fallbackWinner.awardType) || preistraegerDetailContent.fallbackWinner.awardType),
img: docImageUrl(cmsWinner, placeholderImage),
shortDesc: String(cmsWinner.shortDesc || cmsWinner.description || cmsWinner.meta?.description || ''),
longDesc: String(cmsWinner.longDesc || cmsWinner.description || cmsWinner.meta?.description || fallbackWinner.longDesc || preistraegerDetailContent.fallbackWinner.longDesc),
quote: String(cmsWinner.quote || fallbackWinner.quote || preistraegerDetailContent.fallbackWinner.quote),
quotePerson: String(cmsWinner.quotePerson || cmsWinner.title || fallbackWinner.quotePerson || preistraegerDetailContent.fallbackWinner.quotePerson),
quoteRole: String(cmsWinner.quoteRole || fallbackWinner.quoteRole || preistraegerDetailContent.fallbackWinner.quoteRole),
quote: cmsText(cmsWinner.quote),
quotePerson: cmsText(cmsWinner.quotePerson),
quoteRole: cmsText(cmsWinner.quoteRole),
location: String(cmsWinner.location || fallbackWinner.location || preistraegerDetailContent.fallbackWinner.location),
industry: String(cmsWinner.industry || fallbackWinner.industry || preistraegerDetailContent.fallbackWinner.industry),
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;
const breadcrumb = { ...preistraegerDetailContent.breadcrumb, ...(detail.breadcrumb || {}) };
const sections = { ...preistraegerDetailContent.sections, ...(detail.sections || {}) };
const sideImage = { ...preistraegerDetailContent.sideImage, ...(detail.sideImage || {}) };
const facts = { ...preistraegerDetailContent.facts, ...(detail.facts || {}) };
const websiteCta = { ...preistraegerDetailContent.websiteCta, ...(detail.websiteCta || {}) };
const backLink = { ...preistraegerDetailContent.backLink, ...(detail.backLink || {}) };
const juryTextBeforeName = cmsText(sections.juryTextBeforeName);
const juryTextAfterNameBeforeCategory = cmsText(sections.juryTextAfterNameBeforeCategory);
const juryTextAfterCategory = cmsText(sections.juryTextAfterCategory);
const showJuryText = Boolean(juryTextBeforeName || juryTextAfterNameBeforeCategory || juryTextAfterCategory);
const downloadHref = mediaUrl(cmsWinner?.downloadZip, '');
const downloadFilename = mediaFilename(cmsWinner?.downloadZip);
const downloadLabel = fallbackText(cmsWinner?.downloadLabel, 'Pressepaket herunterladen');
@@ -117,7 +125,7 @@ export default function PreistraegerDetail() {
{/* Breadcrumb */}
<div style={{ background: '#fff', borderBottom: `1px solid ${BORDER}`, padding: isMobile ? '16px 24px' : '16px 80px', display: 'flex', alignItems: 'center', gap: 8 }}>
<Link to={fallbackText(breadcrumb.url, preistraegerDetailContent.breadcrumb.url)} style={{ fontFamily: FF, fontSize: 16, color: GOLD, textDecoration: 'none', fontWeight: 600 }}>
<Link to={fallbackText(breadcrumb.url, preistraegerDetailContent.breadcrumb.url)} style={{ fontFamily: FF, fontSize: 16, color: 'var(--text-brand-on-light)', textDecoration: 'none', fontWeight: 600 }}>
{fallbackText(breadcrumb.label, preistraegerDetailContent.breadcrumb.label)}
</Link>
<ChevronRight size={14} style={{ color: GRAY }} />
@@ -134,17 +142,19 @@ export default function PreistraegerDetail() {
}}>
{winner.category}
</span>
<span style={{ fontFamily: FF, fontSize: 15, fontWeight: 700, color: GOLD }}>
{winner.year}
</span>
{typeof winner.year === 'number' ? (
<span style={{ fontFamily: FF, fontSize: 15, fontWeight: 700, color: 'var(--text-brand-on-light)' }}>
{winner.year}
</span>
) : null}
<span style={{ fontFamily: FF, fontSize: 15, color: GRAY, textTransform: 'uppercase', letterSpacing: '0.08em' }}>
{winner.type}
</span>
</div>
<h1 style={{ fontFamily: FF, fontSize: isMobile ? 'clamp(1.6rem, 5vw, 3rem)' : 'clamp(2rem, 5vw, 3rem)', fontWeight: 900, color: '#101828', textTransform: 'uppercase', letterSpacing: '-0.02em', margin: '0 0 12px' }}>
<h1 className="type-page-title text-role-heading" style={{ textTransform: 'uppercase', margin: '0 0 12px' }}>
{winner.name}
</h1>
<p style={{ fontFamily: FB, fontSize: 18, color: GRAY, margin: 0, maxWidth: 640, lineHeight: 1.6 }}>
<p className="type-body text-role-secondary" style={{ margin: 0, maxWidth: 640 }}>
{winner.shortDesc}
</p>
</div>
@@ -153,61 +163,52 @@ export default function PreistraegerDetail() {
<div style={{ padding: isMobile ? '48px 24px' : '64px 80px', display: 'grid', gridTemplateColumns: isMobile ? '1fr' : '55% 1fr', gap: isMobile ? 40 : 64 }}>
{/* Left: Text */}
<div>
<h2 style={{ fontFamily: FF, fontSize: 18, fontWeight: 800, color: '#101828', textTransform: 'uppercase', letterSpacing: '0.04em', marginBottom: 20 }}>
<h2 className="type-subtitle text-role-heading" style={{ textTransform: 'uppercase', marginBottom: 20 }}>
{fallbackText(sections.companyHeading, preistraegerDetailContent.sections.companyHeading)}
</h2>
<p style={{ fontFamily: FB, fontSize: 18, color: '#333', lineHeight: 1.8, marginBottom: 40 }}>
<p className="type-body text-role-body" style={{ marginBottom: 40 }}>
{winner.longDesc}
</p>
<h2 style={{ fontFamily: FF, fontSize: 18, fontWeight: 800, color: '#101828', textTransform: 'uppercase', letterSpacing: '0.04em', marginBottom: 20 }}>
<h2 className="type-subtitle text-role-heading" style={{ textTransform: 'uppercase', marginBottom: 20 }}>
{fallbackText(sections.juryHeading, preistraegerDetailContent.sections.juryHeading)}
</h2>
<p style={{ fontFamily: FB, fontSize: 18, color: '#333', lineHeight: 1.8, marginBottom: 40 }}>
{fallbackText(sections.juryTextBeforeName, preistraegerDetailContent.sections.juryTextBeforeName)} {winner.name} {fallbackText(sections.juryTextAfterNameBeforeCategory, preistraegerDetailContent.sections.juryTextAfterNameBeforeCategory)} <strong>{winner.category}</strong>. {fallbackText(sections.juryTextAfterCategory, preistraegerDetailContent.sections.juryTextAfterCategory)}
</p>
{showJuryText ? (
<p className="type-body text-role-body" style={{ marginBottom: 40 }}>
{juryTextBeforeName}
{juryTextAfterNameBeforeCategory ? (
<>{juryTextBeforeName ? ' ' : null}{winner.name} {juryTextAfterNameBeforeCategory} <strong>{winner.category}</strong>.</>
) : null}
{juryTextAfterCategory ? <>{juryTextBeforeName || juryTextAfterNameBeforeCategory ? ' ' : null}{juryTextAfterCategory}</> : null}
</p>
) : null}
{/* Quote */}
<div style={{ borderLeft: `4px solid ${GOLD}`, paddingLeft: 24, marginBottom: 40 }}>
<p style={{ fontFamily: FB, fontStyle: 'italic', fontSize: 18, color: '#101828', lineHeight: 1.7, marginBottom: 12 }}>
<span style={{ color: GOLD, fontSize: 28, lineHeight: 1, marginRight: 4, fontFamily: '"IBM Plex Sans"' }}>&ldquo;</span>
{winner.quote}
<span style={{ color: GOLD, fontSize: 28, lineHeight: 1, marginLeft: 2, fontFamily: '"IBM Plex Sans"' }}>&rdquo;</span>
</p>
<p style={{ fontFamily: FF, fontSize: 16, fontWeight: 700, color: GRAY, margin: 0, textTransform: 'uppercase', letterSpacing: '0.08em' }}>
– {winner.quotePerson}, {winner.quoteRole}
</p>
</div>
{winner.quote ? (
<div style={{ borderLeft: `4px solid ${GOLD}`, paddingLeft: 24, marginBottom: 40 }}>
<p className="type-body text-role-heading" style={{ fontStyle: 'italic', marginBottom: winner.quotePerson || winner.quoteRole ? 12 : 0 }}>
<span style={{ color: GOLD, fontSize: 28, lineHeight: 1, marginRight: 4, fontFamily: '"IBM Plex Sans"' }}>&ldquo;</span>
{winner.quote}
<span style={{ color: GOLD, fontSize: 28, lineHeight: 1, marginLeft: 2, fontFamily: '"IBM Plex Sans"' }}>&rdquo;</span>
</p>
{winner.quotePerson || winner.quoteRole ? (
<p className="type-small text-role-secondary" style={{ fontWeight: 700, margin: 0, textTransform: 'uppercase', letterSpacing: '0.08em' }}>
{winner.quotePerson ? <>– {winner.quotePerson}</> : null}
{winner.quotePerson && winner.quoteRole ? ', ' : null}
{winner.quoteRole}
</p>
) : null}
</div>
) : null}
</div>
{/* Right: Photo + Info box */}
{/* Right: Photo + actions */}
<div style={{ display: 'flex', flexDirection: 'column', gap: 32 }}>
{/* Photo */}
<div style={{ borderRadius: 0, overflow: 'hidden', aspectRatio: '4/3' }}>
<Image unoptimized src={mediaUrl(sideImage.image, `/${preistraegerDetailContent.sideImage.imageFilename}`)} alt={mediaAlt(sideImage.image, fallbackText(sideImage.imageAlt, preistraegerDetailContent.sideImage.imageAlt))} style={{ width: '100%', height: '100%', objectFit: 'cover', display: 'block', filter: 'brightness(0.95)' }} />
</div>
{/* Kenndaten */}
<div style={{ background: BG_ALT, borderLeft: `3px solid ${GOLD}`, padding: '20px 24px' }}>
<h3 style={{ fontFamily: FF, fontSize: 15, fontWeight: 700, color: GRAY, textTransform: 'uppercase', letterSpacing: '0.1em', marginBottom: 20 }}>
{fallbackText(facts.heading, preistraegerDetailContent.facts.heading)}
</h3>
<div style={{ display: 'flex', flexDirection: 'column', gap: 16 }}>
{[
{ label: fallbackText(facts.categoryLabel, preistraegerDetailContent.facts.categoryLabel), value: winner.category, icon: <Trophy size={14} style={{ color: GOLD }} /> },
{ label: fallbackText(facts.awardLabel, preistraegerDetailContent.facts.awardLabel), value: winner.type, icon: <Trophy size={14} style={{ color: GOLD }} /> },
{ label: fallbackText(facts.yearLabel, preistraegerDetailContent.facts.yearLabel), value: String(winner.year), icon: null },
{ label: fallbackText(facts.locationLabel, preistraegerDetailContent.facts.locationLabel), value: winner.location, icon: <MapPin size={14} style={{ color: GOLD }} /> },
{ label: fallbackText(facts.industryLabel, preistraegerDetailContent.facts.industryLabel), value: winner.industry, icon: <Building2 size={14} style={{ color: GOLD }} /> },
].map(row => (
<div key={row.label} style={{ display: 'flex', flexDirection: 'column', gap: 2 }}>
<span style={{ fontFamily: FF, fontSize: 11, color: GRAY, textTransform: 'uppercase', letterSpacing: '0.08em' }}>{row.label}</span>
<span style={{ fontFamily: FF, fontSize: 17, fontWeight: 700, color: '#101828', overflowWrap: 'anywhere' }}>{row.value}</span>
</div>
))}
</div>
</div>
{/* Website button */}
<a
href={`https://${winner.website}`}
@@ -252,11 +253,11 @@ export default function PreistraegerDetail() {
<section style={{ borderTop: `1px solid ${BORDER}`, background: '#fff' }}>
<div style={{ padding: isMobile ? '48px 24px' : '64px 80px' }}>
<div style={{ marginBottom: 28, maxWidth: 760 }}>
<h2 style={{ fontFamily: FF, fontSize: isMobile ? 26 : 34, fontWeight: 900, color: '#101828', textTransform: 'uppercase', letterSpacing: '0.02em', margin: '0 0 12px' }}>
<h2 className="type-section-title text-role-heading" style={{ textTransform: 'uppercase', margin: '0 0 12px' }}>
{fallbackText(gallery.heading, 'Bildergalerie')}
</h2>
{gallery.description ? (
<p style={{ fontFamily: FB, fontSize: 17, color: GRAY, lineHeight: 1.7, margin: 0 }}>
<p className="type-body text-role-secondary" style={{ margin: 0 }}>
{gallery.description}
</p>
) : null}
@@ -353,7 +354,7 @@ export default function PreistraegerDetail() {
style={{ maxWidth: '100%', maxHeight: activeGalleryImage.caption ? '76vh' : '84vh', objectFit: 'contain', display: 'block' }}
/>
{activeGalleryImage.caption ? (
<p style={{ fontFamily: FB, fontSize: 16, lineHeight: 1.6, color: '#fff', margin: 0, textAlign: 'center', overflowWrap: 'anywhere' }}>
<p className="type-small text-role-inverse" style={{ margin: 0, textAlign: 'center', overflowWrap: 'anywhere' }}>
{activeGalleryImage.caption}
</p>
) : null}

View File

@@ -2,27 +2,30 @@ 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';
const NAVY = '#111D55';
const GOLD = '#EFBF04';
const CREAM = '#E4E2E3';
const FF = '"IBM Plex Sans", sans-serif';
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,35 +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 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: String(event.spaPath || `/presse/events/${event.slug}`),
})
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: String(post.spaPath || `/presse/blog/${post.slug}`),
})
function Lines({ text }: { text: string }) {
return <>{text.split('\n').map((line, i) => <React.Fragment key={`${line}-${i}`}>{i > 0 && <br />}{line}</React.Fragment>)}</>
}
@@ -77,16 +51,20 @@ const fallbackCardImage = (item: PressEventItem | PressNewsItem) => mediaUrl(ite
function NewsCard({ item, idx, isMobile, total }: { item: PressNewsItem; idx: number; isMobile: boolean; total: number }) {
const [hovered, setHovered] = React.useState(false);
return (
<article
<Link
to={item.slug}
aria-label={item.title}
onMouseEnter={() => setHovered(true)}
onMouseLeave={() => setHovered(false)}
style={{
display: 'flex',
flexDirection: 'column',
color: 'inherit',
textDecoration: 'none',
borderLeft: isMobile ? 'none' : (idx > 0 ? '1px solid rgba(255,255,255,0.07)' : 'none'),
borderBottom: isMobile && idx < total - 1 ? '1px solid rgba(255,255,255,0.07)' : 'none',
}}
>
<article style={{ display: 'flex', flexDirection: 'column', flex: 1 }}>
{/* image */}
<div style={{ position: 'relative', overflow: 'hidden', height: 220 }}>
<Image unoptimized
@@ -128,14 +106,9 @@ function NewsCard({ item, idx, isMobile, total }: { item: PressNewsItem; idx: nu
{item.cat}
</span>
<h3
className="type-card-title text-role-inverse"
style={{
fontFamily: FF,
fontSize: 'clamp(1rem, 1.5vw, 1.2rem)',
fontWeight: 700,
color: '#fff',
textTransform: 'uppercase',
letterSpacing: '-0.01em',
lineHeight: 1.25,
marginBottom: 16,
}}
>
@@ -151,11 +124,8 @@ function NewsCard({ item, idx, isMobile, total }: { item: PressNewsItem; idx: nu
}}
/>
<p
className="type-body text-role-inverse-secondary"
style={{
fontFamily: FB,
fontSize: 18,
color: 'rgba(255,255,255,0.4)',
lineHeight: 1.7,
flex: 1,
marginBottom: 20,
}}
@@ -163,7 +133,8 @@ function NewsCard({ item, idx, isMobile, total }: { item: PressNewsItem; idx: nu
{item.excerpt}
</p>
</div>
</article>
</article>
</Link>
);
}
@@ -175,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">
@@ -252,13 +218,8 @@ const Press: React.FC = () => {
</span>
</div>
<h1
className="type-display text-role-inverse"
style={{
fontFamily: FF,
fontSize: isMobile ? 'clamp(2rem, 8vw, 2.8rem)' : 'clamp(2.8rem, 6vw, 5rem)',
fontWeight: 900,
color: '#fff',
lineHeight: 0.95,
letterSpacing: '-0.03em',
textTransform: 'uppercase',
margin: '0 0 24px',
overflowWrap: 'anywhere',
@@ -268,13 +229,9 @@ const Press: React.FC = () => {
</h1>
<div style={{ width: 48, height: 2, background: GOLD, marginBottom: 24 }} />
<p
className="type-body text-role-inverse-secondary"
style={{
fontFamily: FB,
fontSize: 18,
color: 'rgba(255,255,255,0.6)',
fontWeight: 300,
maxWidth: 520,
lineHeight: 1.7,
margin: 0,
}}
>
@@ -284,7 +241,7 @@ const Press: React.FC = () => {
</section>
{/* ── 2. EVENTS ───────────────────────────────────────────────────── */}
<section id="events" style={{ background: CREAM, overflow: 'hidden', position: 'relative', isolation: 'isolate' }}>
<section id="events" style={{ background: '#fff', overflow: 'hidden', position: 'relative', isolation: 'isolate' }}>
<MunichSkylineBg />
{/* Section header */}
<div
@@ -305,20 +262,16 @@ const Press: React.FC = () => {
fontWeight: 700,
textTransform: 'uppercase',
letterSpacing: '0.28em',
color: '#4A8FC9',
color: 'var(--text-eyebrow-light)',
marginBottom: 16,
}}
>
{fallbackText(eventsSection.eyebrow, pressIndexContent.events.eyebrow)}
</div>
<h2
className="type-section-title text-role-heading"
style={{
fontFamily: FF,
fontWeight: 900,
color: '#101828',
textTransform: 'uppercase',
letterSpacing: '-0.025em',
fontSize: 'clamp(2rem, 3.5vw, 3rem)',
margin: 0,
}}
>
@@ -327,11 +280,8 @@ const Press: React.FC = () => {
</div>
<div>
<p
className="type-body text-role-secondary"
style={{
fontFamily: FB,
fontSize: 18,
color: 'rgba(16,24,40,0.45)',
lineHeight: 1.8,
margin: 0,
}}
>
@@ -342,13 +292,17 @@ const Press: React.FC = () => {
{/* Event rows */}
{combinedEvents.map((event, idx) => (
<div
key={idx}
<Link
key={event.slug || idx}
to={event.slug}
aria-label={event.title}
style={{
display: 'grid',
gridTemplateColumns: isMobile ? '1fr' : '320px 1fr',
borderBottom: '1px solid rgba(3,9,58,0.08)',
background: 'transparent',
color: 'inherit',
textDecoration: 'none',
}}
>
{/* Left – image cell */}
@@ -361,8 +315,8 @@ const Press: React.FC = () => {
{/* Right – copy cell */}
<div style={{ padding: isMobile ? '24px 24px 32px' : '40px 56px 40px 48px', display: 'flex', flexDirection: 'column', justifyContent: 'space-between' }}>
<div style={{ fontFamily: FF, fontSize: 9, color: '#4A8FC9', textTransform: 'uppercase' as const, letterSpacing: '0.22em', fontWeight: 700, marginBottom: 10 }}>{event.cat}</div>
<h3 style={{ fontFamily: FF, fontSize: 'clamp(1.3rem, 2vw, 1.7rem)', fontWeight: 900, color: '#101828', textTransform: 'uppercase' as const, letterSpacing: '-0.02em', margin: '0 0 14px' }}>{event.title}</h3>
<div style={{ fontFamily: FF, fontSize: 9, color: 'var(--text-eyebrow-light)', textTransform: 'uppercase' as const, letterSpacing: '0.22em', fontWeight: 700, marginBottom: 10 }}>{event.cat}</div>
<h3 className="type-card-title text-role-heading" style={{ textTransform: 'uppercase' as const, margin: '0 0 14px' }}>{event.title}</h3>
<div style={{ display: 'flex', gap: isMobile ? 16 : 28, alignItems: 'center', flexWrap: 'wrap', marginBottom: 16 }}>
<div style={{ display: 'flex', alignItems: 'center', gap: 6, fontFamily: FB, fontSize: 18, color: 'rgba(16,24,40,0.5)' }}>
<Calendar size={14} color={GOLD} />{event.date}
@@ -372,9 +326,9 @@ const Press: React.FC = () => {
</div>
</div>
<div style={{ width: 24, height: 1, background: 'rgba(239,191,4,0.4)', marginBottom: 14 }} />
<p style={{ fontFamily: FB, fontSize: 18, color: 'rgba(16,24,40,0.5)', lineHeight: 1.7, flex: 1 }}>{event.desc}</p>
<p className="type-body text-role-secondary" style={{ flex: 1 }}>{event.desc}</p>
</div>
</div>
</Link>
))}
</section>
@@ -406,13 +360,9 @@ const Press: React.FC = () => {
{fallbackText(newsSection.eyebrow, pressIndexContent.news.eyebrow)}
</div>
<h2
className="type-section-title text-role-inverse"
style={{
fontFamily: FF,
fontWeight: 900,
color: '#fff',
textTransform: 'uppercase',
letterSpacing: '-0.025em',
fontSize: 'clamp(2rem, 3.5vw, 3rem)',
margin: 0,
}}
>
@@ -421,11 +371,8 @@ const Press: React.FC = () => {
</div>
<div>
<p
className="type-body text-role-inverse-secondary"
style={{
fontFamily: FB,
fontSize: 18,
color: 'rgba(255,255,255,0.4)',
lineHeight: 1.8,
margin: 0,
}}
>
@@ -437,18 +384,26 @@ const Press: React.FC = () => {
{/* 3-column news grid */}
<div style={{ display: 'grid', gridTemplateColumns: isMobile ? '1fr' : 'repeat(3, 1fr)' }}>
{combinedNews.map((item, idx) => (
<NewsCard key={idx} item={item} idx={idx} isMobile={isMobile} total={combinedNews.length} />
<NewsCard key={item.slug || idx} item={item} idx={idx} isMobile={isMobile} total={combinedNews.length} />
))}
</div>
</section>
{/* ── 4. PRESSE-MATERIAL & AKKREDITIERUNG ─────────────────────────── */}
<section id="downloads" style={{ background: CREAM, overflow: 'hidden', position: 'relative', isolation: 'isolate' }}>
{/* ── 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',
@@ -464,7 +419,7 @@ const Press: React.FC = () => {
fontWeight: 700,
textTransform: 'uppercase',
letterSpacing: '0.28em',
color: '#4A8FC9',
color: 'var(--text-eyebrow-light)',
marginBottom: 16,
}}
>
@@ -472,13 +427,9 @@ const Press: React.FC = () => {
</div>
<h2
className="type-section-title text-role-heading"
style={{
fontFamily: FF,
fontWeight: 900,
color: '#101828',
textTransform: 'uppercase',
letterSpacing: '-0.025em',
fontSize: 'clamp(1.8rem, 3vw, 2.6rem)',
margin: '0 0 20px',
}}
>
@@ -489,11 +440,8 @@ const Press: React.FC = () => {
<div style={{ width: 40, height: 2, background: GOLD, margin: '0 0 28px' }} />
<p
className="type-body text-role-secondary"
style={{
fontFamily: FB,
fontSize: 18,
color: 'rgba(16,24,40,0.5)',
lineHeight: 1.8,
maxWidth: 380,
marginBottom: 0,
}}
@@ -507,7 +455,7 @@ const Press: React.FC = () => {
style={{
fontFamily: FF,
fontSize: 10,
color: '#4A8FC9',
color: 'var(--text-eyebrow-light)',
textTransform: 'uppercase',
letterSpacing: '0.2em',
fontWeight: 700,
@@ -545,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

@@ -10,9 +10,6 @@ export const preistraegerDetailContent = {
category: 'Preisträger',
awardType: 'Auszeichnung',
longDesc: 'Dieser Preisträger wird aus Payload CMS geladen.',
quote: 'Diese Erfolgsgeschichte wird aktuell in Payload CMS gepflegt.',
quotePerson: 'BMP',
quoteRole: 'Preisträger',
location: 'Bayern',
industry: 'Mittelstand',
},
@@ -28,14 +25,6 @@ export const preistraegerDetailContent = {
imageFilename: 'gala-preistraeger.jpg',
imageAlt: 'Gala Preisverleihung',
},
facts: {
heading: 'Auf einen Blick',
categoryLabel: 'Kategorie',
awardLabel: 'Auszeichnung',
yearLabel: 'Jahr',
locationLabel: 'Standort',
industryLabel: 'Branche',
},
websiteCta: {
label: 'Website besuchen ↗',
},

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

@@ -0,0 +1,52 @@
import { describe, expect, it } from 'vitest'
import type { CmsRouteDoc } from '@/spa/cmsRoute'
import { resolveHomeWinners } from '@/spa/homeWinnerSelection'
const selected2026Winners: CmsRouteDoc[] = Array.from({ length: 16 }, (_, index) => ({
id: 65 + index,
title: `Winner ${65 + index}`,
year: 2026,
}))
const published2025Winners: CmsRouteDoc[] = Array.from({ length: 9 }, (_, index) => ({
id: 12 + index,
title: `Winner ${12 + index}`,
year: 2025,
}))
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])
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)
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([])
})
})

Some files were not shown because too many files have changed in this diff Show More