Compare commits
19 Commits
740e302c58
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
a1285957ef | ||
|
|
6915be534d | ||
|
|
07e9458aaf | ||
|
|
0a19abd0b0 | ||
|
|
3c28f6ff9c | ||
|
|
4bb265dac1 | ||
|
|
d7a720460f | ||
|
|
5b62abf8d4 | ||
|
|
f465839af0 | ||
|
|
adda9ddbd0 | ||
|
|
95037e9908 | ||
|
|
30efb4b36e | ||
|
|
ebecf66e6f | ||
|
|
c2dc42f243 | ||
|
|
e3c47c4500 | ||
|
|
ab47571139 | ||
|
|
8dc17c0fea | ||
|
|
b18bbbe36a | ||
|
|
fec1bab2e5 |
275
docs/client-feedback-deployment-readiness.md
Normal file
275
docs/client-feedback-deployment-readiness.md
Normal 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.
|
||||
393
docs/client-feedback-task-list.md
Normal file
393
docs/client-feedback-task-list.md
Normal 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 |
|
||||
73
docs/payload-field-inventory.md
Normal file
73
docs/payload-field-inventory.md
Normal 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.
|
||||
@@ -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: [
|
||||
{
|
||||
|
||||
@@ -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\"
|
||||
|
||||
@@ -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'),
|
||||
|
||||
@@ -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
|
||||
},
|
||||
|
||||
@@ -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,
|
||||
const results = await Promise.all(
|
||||
sitemapCollections.map(({ collection }) =>
|
||||
payload.find({
|
||||
collection,
|
||||
...getPublicPublishedQueryOptions(),
|
||||
depth: 0,
|
||||
limit: 1000,
|
||||
pagination: false,
|
||||
where: {
|
||||
_status: {
|
||||
equals: 'published',
|
||||
},
|
||||
},
|
||||
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'],
|
||||
{
|
||||
|
||||
@@ -11,6 +11,7 @@ import { normalizeFooterData, normalizeHeaderData } from '@/spa/cmsNavigation'
|
||||
import type { CmsRouteCollection, CmsRouteDoc } from '@/spa/cmsRoute'
|
||||
import { normalizeNewsletterFormData, type SpaNewsletterFormData } from '@/spa/newsletterForm'
|
||||
import { normalizeSiteSettings, type SpaSiteSettingsData } from '@/spa/siteSettings'
|
||||
import { getPublicationQueryOptions } from '@/utilities/publicationQuery'
|
||||
|
||||
export const dynamic = 'force-dynamic'
|
||||
|
||||
@@ -43,8 +44,7 @@ async function findRouteDoc(payload: Awaited<ReturnType<typeof getPayload>>, pat
|
||||
const result = await payload.find({
|
||||
collection: target.collection,
|
||||
depth: 1,
|
||||
draft,
|
||||
overrideAccess: draft,
|
||||
...getPublicationQueryOptions(draft),
|
||||
limit: 1,
|
||||
pagination: false,
|
||||
where: {
|
||||
@@ -63,8 +63,7 @@ async function findList(payload: Awaited<ReturnType<typeof getPayload>>, collect
|
||||
const result = await payload.find({
|
||||
collection,
|
||||
depth: 1,
|
||||
draft,
|
||||
overrideAccess: draft,
|
||||
...getPublicationQueryOptions(draft),
|
||||
limit: 300,
|
||||
pagination: false,
|
||||
...(collection === 'partners' || collection === 'jury-mitglieder' ? { sort: 'sortOrder' } : {}),
|
||||
@@ -77,8 +76,7 @@ async function findSupportPages(payload: Awaited<ReturnType<typeof getPayload>>,
|
||||
const result = await payload.find({
|
||||
collection: 'pages',
|
||||
depth: 1,
|
||||
draft,
|
||||
overrideAccess: draft,
|
||||
...getPublicationQueryOptions(draft),
|
||||
limit: 10,
|
||||
pagination: false,
|
||||
where: {
|
||||
|
||||
@@ -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: {
|
||||
|
||||
46
src/collections/Events/hooks/revalidateEvent.ts
Normal file
46
src/collections/Events/hooks/revalidateEvent.ts
Normal 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
|
||||
}
|
||||
@@ -100,7 +100,7 @@ export const aboutFields: Field[] = [
|
||||
defaultValue: aboutContent.prize.facts,
|
||||
fields: statFields,
|
||||
},
|
||||
text('imageLabel', 'Desktop image label', aboutContent.prize.imageLabel),
|
||||
text('imageLabel', 'Image caption', aboutContent.prize.imageLabel),
|
||||
],
|
||||
},
|
||||
{
|
||||
@@ -123,52 +123,9 @@ export const aboutFields: Field[] = [
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
name: 'highlights',
|
||||
label: '04 · Preisträger highlights',
|
||||
type: 'group',
|
||||
admin: {
|
||||
...sectionAdmin('Legacy data retained for compatibility. The rendered section now reuses the homepage Preisträger card grid.'),
|
||||
hidden: true,
|
||||
},
|
||||
fields: [
|
||||
uploadField('fallbackImage', 'Fallback winner image', `Current frontend fallback image: /images/${aboutContent.highlights.fallbackImageFilename}`),
|
||||
text('eyebrow', 'Eyebrow', aboutContent.highlights.eyebrow),
|
||||
textarea('heading', 'Heading', aboutContent.highlights.heading),
|
||||
ctaGroup('cta', 'CTA', aboutContent.highlights.cta.label, aboutContent.highlights.cta.url),
|
||||
text('hoverLabel', 'Card hover label', aboutContent.highlights.hoverLabel),
|
||||
{
|
||||
name: 'fallbackWinner',
|
||||
label: 'Fallback winner text',
|
||||
type: 'group',
|
||||
fields: [
|
||||
text('name', 'Name fallback', aboutContent.highlights.fallbackWinner.name),
|
||||
text('category', 'Category fallback', aboutContent.highlights.fallbackWinner.category),
|
||||
text('awardType', 'Award type fallback', aboutContent.highlights.fallbackWinner.awardType),
|
||||
],
|
||||
},
|
||||
{
|
||||
name: 'year',
|
||||
label: 'Fallback winner year',
|
||||
type: 'number',
|
||||
defaultValue: aboutContent.highlights.year,
|
||||
},
|
||||
{
|
||||
name: 'selectedWinners',
|
||||
label: 'Selected winners',
|
||||
type: 'relationship',
|
||||
relationTo: 'preistraeger',
|
||||
hasMany: true,
|
||||
maxRows: 8,
|
||||
admin: {
|
||||
description: 'Choose up to 8 Preisträger to show. If empty, the frontend falls back to winners from the selected year.',
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
name: 'testimonials',
|
||||
label: '05 · Testimonials',
|
||||
label: '04 · Testimonials',
|
||||
type: 'group',
|
||||
admin: sectionAdmin('Testimonial heading and slides. Upload images when available, or keep the migrated URL fallback.'),
|
||||
fields: [
|
||||
@@ -199,7 +156,7 @@ export const aboutFields: Field[] = [
|
||||
},
|
||||
{
|
||||
name: 'history',
|
||||
label: '06 · Geschichte & Meilensteine',
|
||||
label: '05 · Geschichte & Meilensteine',
|
||||
type: 'group',
|
||||
admin: sectionAdmin('Navy timeline section.'),
|
||||
fields: [
|
||||
@@ -219,7 +176,7 @@ export const aboutFields: Field[] = [
|
||||
},
|
||||
{
|
||||
name: 'goals',
|
||||
label: '07 · Zielsetzung',
|
||||
label: '06 · Zielsetzung',
|
||||
type: 'group',
|
||||
admin: sectionAdmin('Cream values list next to the history timeline.'),
|
||||
fields: [
|
||||
@@ -237,7 +194,7 @@ export const aboutFields: Field[] = [
|
||||
},
|
||||
{
|
||||
name: 'values',
|
||||
label: '08 · Vorteile & Werte',
|
||||
label: '07 · Vorteile & Werte',
|
||||
type: 'group',
|
||||
admin: sectionAdmin('Cream editorial rows before the final CTA.'),
|
||||
fields: [
|
||||
@@ -256,7 +213,7 @@ export const aboutFields: Field[] = [
|
||||
},
|
||||
{
|
||||
name: 'cta',
|
||||
label: '09 · Final CTA',
|
||||
label: '08 · Final CTA',
|
||||
type: 'group',
|
||||
admin: sectionAdmin('Bottom navy CTA section.'),
|
||||
fields: [
|
||||
|
||||
@@ -33,33 +33,6 @@ const sectionAdmin = (description: string) => ({
|
||||
initCollapsed: true,
|
||||
})
|
||||
|
||||
const iconOptions = [
|
||||
{ label: 'Alert circle', value: 'alertCircle' },
|
||||
{ label: 'Arrow right', value: 'arrowRight' },
|
||||
{ label: 'Building', value: 'building2' },
|
||||
{ label: 'Check', value: 'check' },
|
||||
{ label: 'Check circle', value: 'checkCircle2' },
|
||||
{ label: 'File text', value: 'fileText' },
|
||||
{ label: 'Map pin', value: 'mapPin' },
|
||||
{ label: 'Search', value: 'search' },
|
||||
{ label: 'Send', value: 'send' },
|
||||
{ label: 'Star', value: 'star' },
|
||||
{ label: 'Trending up', value: 'trendingUp' },
|
||||
{ label: 'Trophy', value: 'trophy' },
|
||||
{ label: 'User', value: 'user' },
|
||||
{ label: 'User check', value: 'userCheck' },
|
||||
{ label: 'User plus', value: 'userPlus' },
|
||||
{ label: 'Users', value: 'users' },
|
||||
]
|
||||
|
||||
const iconField = (defaultValue = 'star'): Field => ({
|
||||
name: 'icon',
|
||||
label: 'Icon',
|
||||
type: 'select',
|
||||
defaultValue,
|
||||
options: iconOptions,
|
||||
})
|
||||
|
||||
export const homeFields: Field[] = [
|
||||
{
|
||||
name: 'home',
|
||||
@@ -219,144 +192,9 @@ export const homeFields: Field[] = [
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
name: 'application',
|
||||
label: '07 · Application form section',
|
||||
type: 'group',
|
||||
admin: { ...sectionAdmin('Legacy page-level form section. The frontend now uses Formulare > Bewerbungsformular.'), hidden: true },
|
||||
fields: [
|
||||
text('eyebrow', 'Eyebrow', homeContent.application.eyebrow),
|
||||
textarea('heading', 'Heading', homeContent.application.heading),
|
||||
textarea('body', 'Body copy', homeContent.application.body),
|
||||
{ name: 'benefits', label: 'Benefit rows', type: 'array', defaultValue: homeContent.application.benefits, fields: [text('num', 'Number'), text('label', 'Label'), text('desc', 'Description')] },
|
||||
uploadField('awardImage', 'Award image', `Current frontend image: /images/${homeContent.application.awardImageFilename}`),
|
||||
text('awardImageAlt', 'Award image alt text', homeContent.application.awardImageAlt),
|
||||
text('awardCaption', 'Award image caption', homeContent.application.awardCaption),
|
||||
text('formEyebrow', 'Form eyebrow', homeContent.application.formEyebrow),
|
||||
text('formTitle', 'Form title', homeContent.application.formTitle),
|
||||
text('footnote', 'Footnote normal text', homeContent.application.footnote),
|
||||
text('footnoteStrong', 'Footnote highlighted text', homeContent.application.footnoteStrong),
|
||||
],
|
||||
},
|
||||
{
|
||||
name: 'form',
|
||||
label: '08 · Embedded application form copy and logic labels',
|
||||
type: 'group',
|
||||
admin: { ...sectionAdmin('Legacy page-level form fields. The frontend now uses Formulare > Bewerbungsformular.'), hidden: true },
|
||||
fields: [
|
||||
text('stepCompleteLabel', 'Completed step label', homeContent.form.stepCompleteLabel),
|
||||
text('backLabel', 'Back button label', homeContent.form.backLabel),
|
||||
text('nextLabel', 'Next button label', homeContent.form.nextLabel),
|
||||
text('submitLabel', 'Submit button label', homeContent.form.submitLabel),
|
||||
text('yesLabel', 'Yes option label', homeContent.form.yesLabel),
|
||||
text('noLabel', 'No option label', homeContent.form.noLabel),
|
||||
text('requiredSuffix', 'Required field suffix', homeContent.form.requiredSuffix),
|
||||
{
|
||||
name: 'validation',
|
||||
label: 'Validation messages',
|
||||
type: 'group',
|
||||
fields: [
|
||||
text('choose', 'Choose message', homeContent.form.validation.choose),
|
||||
text('required', 'Required message', homeContent.form.validation.required),
|
||||
text('invalidEmail', 'Invalid email message', homeContent.form.validation.invalidEmail),
|
||||
],
|
||||
},
|
||||
{
|
||||
name: 'typeStep',
|
||||
label: 'Step 1 · Submission type',
|
||||
type: 'group',
|
||||
fields: [
|
||||
text('eyebrow', 'Eyebrow', homeContent.form.typeStep.eyebrow),
|
||||
text('heading', 'Heading', homeContent.form.typeStep.heading),
|
||||
text('selfTitle', 'Self-application title', homeContent.form.typeStep.selfTitle),
|
||||
textarea('selfDesc', 'Self-application description', homeContent.form.typeStep.selfDesc),
|
||||
text('nominationTitle', 'Nomination title', homeContent.form.typeStep.nominationTitle),
|
||||
textarea('nominationDesc', 'Nomination description', homeContent.form.typeStep.nominationDesc),
|
||||
],
|
||||
},
|
||||
{
|
||||
name: 'selfSteps',
|
||||
label: 'Self-application step labels',
|
||||
type: 'array',
|
||||
dbName: 'home_self_steps',
|
||||
defaultValue: homeContent.form.selfSteps,
|
||||
fields: [text('label', 'Label'), iconField('trophy')],
|
||||
},
|
||||
{
|
||||
name: 'nominationSteps',
|
||||
label: 'Nomination step labels',
|
||||
type: 'array',
|
||||
dbName: 'home_nom_steps',
|
||||
defaultValue: homeContent.form.nominationSteps,
|
||||
fields: [text('label', 'Label'), iconField('trophy')],
|
||||
},
|
||||
{
|
||||
name: 'employeeOptions',
|
||||
label: 'Employee options',
|
||||
type: 'array',
|
||||
dbName: 'home_emp_opts',
|
||||
defaultValue: homeContent.form.employeeOptions,
|
||||
fields: [text('value', 'Value'), text('label', 'Label')],
|
||||
},
|
||||
{
|
||||
name: 'eligibility',
|
||||
label: 'Self-application eligibility step',
|
||||
type: 'group',
|
||||
fields: [
|
||||
text('allowedEmployeeValues', 'Allowed employee option values, comma-separated', homeContent.form.eligibility.allowedEmployeeValues),
|
||||
text('requiredBayernValue', 'Required Bavaria answer value', homeContent.form.eligibility.requiredBayernValue),
|
||||
text('eyebrow', 'Eyebrow', homeContent.form.eligibility.eyebrow),
|
||||
text('heading', 'Heading', homeContent.form.eligibility.heading),
|
||||
text('employeesLabel', 'Employees field label', homeContent.form.eligibility.employeesLabel),
|
||||
text('bayernLabel', 'Bavaria field label', homeContent.form.eligibility.bayernLabel),
|
||||
text('ownerLabel', 'Owner field label', homeContent.form.eligibility.ownerLabel),
|
||||
text('ineligibleHeading', 'Ineligible heading', homeContent.form.eligibility.ineligibleHeading),
|
||||
textarea('ineligibleBody', 'Ineligible body', homeContent.form.eligibility.ineligibleBody),
|
||||
text('ineligibleContactPrefix', 'Ineligible contact prefix', homeContent.form.eligibility.ineligibleContactPrefix),
|
||||
text('ineligibleContactEmail', 'Ineligible contact email', homeContent.form.eligibility.ineligibleContactEmail),
|
||||
],
|
||||
},
|
||||
{
|
||||
name: 'selfContact',
|
||||
label: 'Self-application contact step',
|
||||
type: 'group',
|
||||
fields: Object.entries(homeContent.form.selfContact).map(([name, value]) => text(name, name, value)),
|
||||
},
|
||||
{
|
||||
name: 'selfSummary',
|
||||
label: 'Self-application summary step',
|
||||
type: 'group',
|
||||
fields: Object.entries(homeContent.form.selfSummary).map(([name, value]) => text(name, name, value)),
|
||||
},
|
||||
{
|
||||
name: 'nominationCompany',
|
||||
label: 'Nomination company step',
|
||||
type: 'group',
|
||||
fields: Object.entries(homeContent.form.nominationCompany).map(([name, value]) => text(name, name, value)),
|
||||
},
|
||||
{
|
||||
name: 'nominationContact',
|
||||
label: 'Nomination contact step',
|
||||
type: 'group',
|
||||
fields: Object.entries(homeContent.form.nominationContact).map(([name, value]) => text(name, name, value)),
|
||||
},
|
||||
{
|
||||
name: 'nominationSummary',
|
||||
label: 'Nomination summary step',
|
||||
type: 'group',
|
||||
fields: Object.entries(homeContent.form.nominationSummary).map(([name, value]) => text(name, name, value)),
|
||||
},
|
||||
{
|
||||
name: 'success',
|
||||
label: 'Success state copy',
|
||||
type: 'group',
|
||||
fields: Object.entries(homeContent.form.success).map(([name, value]) => text(name, name, value)),
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
name: 'videoModal',
|
||||
label: '10 · Video modal',
|
||||
label: '07 · Video modal',
|
||||
type: 'group',
|
||||
admin: sectionAdmin('Video and fallback copy shown when the hero video button is clicked.'),
|
||||
fields: [
|
||||
|
||||
@@ -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')
|
||||
}
|
||||
|
||||
@@ -51,7 +51,6 @@ const personFields: Field[] = [
|
||||
const rowFields: Field[] = [text('num', 'Number'), text('label', 'Label'), textarea('body', 'Body')]
|
||||
|
||||
const benefitFields: Field[] = [text('num', 'Number'), text('label', 'Label'), text('sub', 'Subline')]
|
||||
const packageFields: Field[] = [text('value', 'Value'), text('title', 'Title'), textarea('desc', 'Description')]
|
||||
const formStepFields: Field[] = [text('label', 'Label')]
|
||||
|
||||
export const netzwerkFields: Field[] = [
|
||||
@@ -101,6 +100,24 @@ export const netzwerkFields: Field[] = [
|
||||
text('greetingTitleLine2', 'Greeting title line 2', netzwerkContent.patronage.greetingTitleLine2),
|
||||
textarea('greetingQuote', 'Greeting quote', netzwerkContent.patronage.greetingQuote),
|
||||
text('greetingAttribution', 'Greeting attribution', netzwerkContent.patronage.greetingAttribution),
|
||||
{
|
||||
name: 'greetingVideo',
|
||||
type: 'upload',
|
||||
relationTo: 'media',
|
||||
label: 'Greeting video',
|
||||
filterOptions: {
|
||||
mimeType: {
|
||||
contains: 'video',
|
||||
},
|
||||
},
|
||||
admin: {
|
||||
description: 'Choose the Ilse Aigner greeting video. If empty, the video action is hidden.',
|
||||
},
|
||||
},
|
||||
text('greetingVideoButtonLabel', 'Video button label', netzwerkContent.patronage.greetingVideoButtonLabel),
|
||||
text('greetingVideoTitle', 'Video title', netzwerkContent.patronage.greetingVideoTitle),
|
||||
text('greetingVideoDescription', 'Video description', netzwerkContent.patronage.greetingVideoDescription),
|
||||
text('greetingVideoCloseLabel', 'Video close button label', netzwerkContent.patronage.greetingVideoCloseLabel),
|
||||
],
|
||||
},
|
||||
{
|
||||
@@ -134,13 +151,12 @@ export const netzwerkFields: Field[] = [
|
||||
name: 'jury',
|
||||
label: '04 · Jury members',
|
||||
type: 'group',
|
||||
admin: sectionAdmin('Jury section heading, chair badge, members, and note.'),
|
||||
admin: sectionAdmin('Jury section heading, chair badge, and rendered member selection.'),
|
||||
fields: [
|
||||
text('eyebrow', 'Eyebrow', netzwerkContent.jury.eyebrow),
|
||||
textarea('heading', 'Heading', netzwerkContent.jury.heading),
|
||||
textarea('description', 'Description', netzwerkContent.jury.description),
|
||||
text('chairBadge', 'Chair badge', netzwerkContent.jury.chairBadge),
|
||||
textarea('note', 'Note', netzwerkContent.jury.note),
|
||||
{
|
||||
name: 'members',
|
||||
label: 'Members',
|
||||
@@ -183,14 +199,15 @@ export const netzwerkFields: Field[] = [
|
||||
textarea('heading', 'Heading', netzwerkContent.partners.heading),
|
||||
textarea('description', 'Description', netzwerkContent.partners.description),
|
||||
text('detailLabel', 'Detail label', netzwerkContent.partners.detailLabel),
|
||||
text('premiumLabel', 'Premium label', netzwerkContent.partners.premiumLabel),
|
||||
text('defaultLogoColor', 'Default logo color', netzwerkContent.partners.defaultLogoColor),
|
||||
{
|
||||
name: 'tiers',
|
||||
label: 'Tiers',
|
||||
label: 'Tier headings and supporting copy',
|
||||
type: 'array',
|
||||
dbName: 'network_tiers',
|
||||
defaultValue: netzwerkContent.partners.tiers,
|
||||
admin: {
|
||||
description: 'One copy row per sponsor tier. Partner membership and order come from each Partner record.',
|
||||
},
|
||||
fields: [numberField('tier', 'Tier'), text('label', 'Label'), text('note', 'Note')],
|
||||
},
|
||||
{
|
||||
@@ -214,7 +231,7 @@ export const netzwerkFields: Field[] = [
|
||||
name: 'sponsoring',
|
||||
label: '07 · Sponsoring section',
|
||||
type: 'group',
|
||||
admin: sectionAdmin('Sponsoring pitch, image, benefits, form eyebrow, and membership footnote.'),
|
||||
admin: sectionAdmin('Sponsoring pitch, image, benefits, and form eyebrow.'),
|
||||
fields: [
|
||||
text('eyebrow', 'Eyebrow', netzwerkContent.sponsoring.eyebrow),
|
||||
textarea('heading', 'Heading', netzwerkContent.sponsoring.heading),
|
||||
@@ -222,10 +239,6 @@ export const netzwerkFields: Field[] = [
|
||||
uploadField('image', 'Form panel image', `Current frontend image: /images/${netzwerkContent.sponsoring.imageFilename}`),
|
||||
text('imageAlt', 'Image alt text', netzwerkContent.sponsoring.imageAlt),
|
||||
text('imageLabel', 'Image label', netzwerkContent.sponsoring.imageLabel),
|
||||
text('formEyebrow', 'Form eyebrow', netzwerkContent.sponsoring.formEyebrow),
|
||||
text('footnotePrefix', 'Footnote prefix', netzwerkContent.sponsoring.footnotePrefix),
|
||||
text('footnoteLabel', 'Footnote link label', netzwerkContent.sponsoring.footnoteLabel),
|
||||
text('footnoteUrl', 'Footnote link URL', netzwerkContent.sponsoring.footnoteUrl),
|
||||
{
|
||||
name: 'benefits',
|
||||
label: 'Benefits',
|
||||
@@ -240,7 +253,7 @@ export const netzwerkFields: Field[] = [
|
||||
name: 'sponsoringForm',
|
||||
label: '08 · Sponsoring form',
|
||||
type: 'group',
|
||||
admin: sectionAdmin('Wizard labels, package options, validation messages, success copy, and footer note.'),
|
||||
admin: sectionAdmin('Two-step wizard labels, field copy, validation messages, success copy, and footer note.'),
|
||||
fields: [
|
||||
{
|
||||
name: 'steps',
|
||||
@@ -248,26 +261,18 @@ export const netzwerkFields: Field[] = [
|
||||
type: 'array',
|
||||
dbName: 'network_form_steps',
|
||||
defaultValue: netzwerkContent.sponsoringForm.steps,
|
||||
minRows: 2,
|
||||
maxRows: 2,
|
||||
fields: formStepFields,
|
||||
},
|
||||
{
|
||||
name: 'packages',
|
||||
label: 'Package options',
|
||||
type: 'array',
|
||||
dbName: 'network_form_pkgs',
|
||||
defaultValue: netzwerkContent.sponsoringForm.packages,
|
||||
fields: packageFields,
|
||||
},
|
||||
text('step1Eyebrow', 'Step 1 eyebrow', netzwerkContent.sponsoringForm.step1Eyebrow),
|
||||
text('step1Heading', 'Step 1 heading', netzwerkContent.sponsoringForm.step1Heading),
|
||||
text('step2Eyebrow', 'Step 2 eyebrow', netzwerkContent.sponsoringForm.step2Eyebrow),
|
||||
text('step2Heading', 'Step 2 heading', netzwerkContent.sponsoringForm.step2Heading),
|
||||
text('step2Eyebrow', 'Step 1 eyebrow', netzwerkContent.sponsoringForm.step2Eyebrow),
|
||||
text('step2Heading', 'Step 1 heading', netzwerkContent.sponsoringForm.step2Heading),
|
||||
text('companyLabel', 'Company label', netzwerkContent.sponsoringForm.companyLabel),
|
||||
text('companyPlaceholder', 'Company placeholder', netzwerkContent.sponsoringForm.companyPlaceholder),
|
||||
text('industryLabel', 'Industry label', netzwerkContent.sponsoringForm.industryLabel),
|
||||
text('industryPlaceholder', 'Industry placeholder', netzwerkContent.sponsoringForm.industryPlaceholder),
|
||||
text('step3Eyebrow', 'Step 3 eyebrow', netzwerkContent.sponsoringForm.step3Eyebrow),
|
||||
text('step3Heading', 'Step 3 heading', netzwerkContent.sponsoringForm.step3Heading),
|
||||
text('step3Eyebrow', 'Step 2 eyebrow', netzwerkContent.sponsoringForm.step3Eyebrow),
|
||||
text('step3Heading', 'Step 2 heading', netzwerkContent.sponsoringForm.step3Heading),
|
||||
text('contactLabel', 'Contact label', netzwerkContent.sponsoringForm.contactLabel),
|
||||
text('contactPlaceholder', 'Contact placeholder', netzwerkContent.sponsoringForm.contactPlaceholder),
|
||||
text('emailLabel', 'Email label', netzwerkContent.sponsoringForm.emailLabel),
|
||||
@@ -275,7 +280,6 @@ export const netzwerkFields: Field[] = [
|
||||
text('backLabel', 'Back label', netzwerkContent.sponsoringForm.backLabel),
|
||||
text('nextLabel', 'Next label', netzwerkContent.sponsoringForm.nextLabel),
|
||||
text('submitLabel', 'Submit label', netzwerkContent.sponsoringForm.submitLabel),
|
||||
text('requiredPackageError', 'Required package error', netzwerkContent.sponsoringForm.requiredPackageError),
|
||||
text('requiredFieldError', 'Required field error', netzwerkContent.sponsoringForm.requiredFieldError),
|
||||
text('invalidEmailError', 'Invalid email error', netzwerkContent.sponsoringForm.invalidEmailError),
|
||||
text('doneLabel', 'Done step label', netzwerkContent.sponsoringForm.doneLabel),
|
||||
|
||||
@@ -24,13 +24,6 @@ const textarea = (name: string, label: string, defaultValue?: string): Field =>
|
||||
defaultValue,
|
||||
})
|
||||
|
||||
const checkbox = (name: string, label: string, defaultValue = false): Field => ({
|
||||
name,
|
||||
type: 'checkbox',
|
||||
label,
|
||||
defaultValue,
|
||||
})
|
||||
|
||||
const sectionAdmin = (description: string) => ({
|
||||
description,
|
||||
initCollapsed: true,
|
||||
@@ -133,7 +126,6 @@ export const participationFields: Field[] = [
|
||||
textarea('heading', 'Heading', participationContent.eligibility.heading),
|
||||
textarea('description', 'Intro paragraph', participationContent.eligibility.description),
|
||||
ctaGroup('primaryCta', 'Primary CTA', participationContent.eligibility.primaryCta.label, participationContent.eligibility.primaryCta.url),
|
||||
ctaGroup('secondaryCta', 'Secondary CTA', participationContent.eligibility.secondaryCta.label, participationContent.eligibility.secondaryCta.url),
|
||||
{
|
||||
name: 'criteria',
|
||||
label: 'Eligibility rows',
|
||||
@@ -151,7 +143,6 @@ export const participationFields: Field[] = [
|
||||
fields: [text('title', 'Title'), textarea('body', 'Body'), iconField('building2')],
|
||||
},
|
||||
text('nudgeText', 'Bottom nudge text', participationContent.eligibility.nudgeText),
|
||||
ctaGroup('nudgeCta', 'Bottom CTA', participationContent.eligibility.nudgeCta.label, participationContent.eligibility.nudgeCta.url),
|
||||
],
|
||||
},
|
||||
{
|
||||
@@ -163,13 +154,6 @@ export const participationFields: Field[] = [
|
||||
text('eyebrow', 'Eyebrow', participationContent.applicationWays.eyebrow),
|
||||
textarea('heading', 'Heading', participationContent.applicationWays.heading),
|
||||
textarea('description', 'Intro paragraph', participationContent.applicationWays.description),
|
||||
text('recommendedLabel', 'Featured card label', participationContent.applicationWays.recommendedLabel),
|
||||
ctaGroup(
|
||||
'fallbackCta',
|
||||
'Fallback CTA for path cards',
|
||||
participationContent.applicationWays.fallbackCta.label,
|
||||
participationContent.applicationWays.fallbackCta.url,
|
||||
),
|
||||
{
|
||||
name: 'institutions',
|
||||
label: 'Proposal institutions',
|
||||
@@ -189,7 +173,6 @@ export const participationFields: Field[] = [
|
||||
textarea('title', 'Title'),
|
||||
textarea('desc', 'Description'),
|
||||
iconField('send', 'ico'),
|
||||
checkbox('featured', 'Featured card'),
|
||||
{
|
||||
name: 'listType',
|
||||
label: 'Detail list type',
|
||||
@@ -208,7 +191,6 @@ export const participationFields: Field[] = [
|
||||
dbName: 'way_facts',
|
||||
fields: factFields,
|
||||
},
|
||||
ctaGroup('cta', 'CTA', participationContent.applicationWays.fallbackCta.label, participationContent.applicationWays.fallbackCta.url),
|
||||
],
|
||||
},
|
||||
],
|
||||
@@ -268,149 +250,6 @@ export const participationFields: Field[] = [
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
name: 'applicationForm',
|
||||
label: '07 · Application form wrapper',
|
||||
type: 'group',
|
||||
admin: { ...sectionAdmin('Legacy page-level form section. The frontend now uses Formulare > Bewerbungsformular.'), hidden: true },
|
||||
fields: [
|
||||
uploadField('image', 'Form image', `Current frontend image: /images/${participationContent.applicationForm.imageFilename}`),
|
||||
text('imageAlt', 'Image alt text', participationContent.applicationForm.imageAlt),
|
||||
text('imageCaption', 'Image caption', participationContent.applicationForm.imageCaption),
|
||||
text('eyebrow', 'Eyebrow', participationContent.applicationForm.eyebrow),
|
||||
textarea('heading', 'Heading', participationContent.applicationForm.heading),
|
||||
textarea('description', 'Body copy', participationContent.applicationForm.description),
|
||||
{
|
||||
name: 'facts',
|
||||
label: 'Fact rows',
|
||||
type: 'array',
|
||||
dbName: 'form_facts',
|
||||
defaultValue: participationContent.applicationForm.facts,
|
||||
fields: [text('num', 'Number'), text('label', 'Label'), textarea('desc', 'Description')],
|
||||
},
|
||||
text('formEyebrow', 'Form eyebrow', participationContent.applicationForm.formEyebrow),
|
||||
text('formTitle', 'Form title', participationContent.applicationForm.formTitle),
|
||||
ctaGroup('uploadCta', 'Top upload CTA', participationContent.applicationForm.uploadCta.label, participationContent.applicationForm.uploadCta.url),
|
||||
textarea('uploadPrompt', 'Bottom upload prompt', participationContent.applicationForm.uploadPrompt),
|
||||
ctaGroup('uploadFooterCta', 'Bottom upload CTA', participationContent.applicationForm.uploadFooterCta.label, participationContent.applicationForm.uploadFooterCta.url),
|
||||
],
|
||||
},
|
||||
{
|
||||
name: 'form',
|
||||
label: '08 · Application form copy and logic labels',
|
||||
type: 'group',
|
||||
admin: { ...sectionAdmin('Legacy page-level form fields. The frontend now uses Formulare > Bewerbungsformular.'), hidden: true },
|
||||
fields: [
|
||||
text('stepCompleteLabel', 'Completed step label', participationContent.form.stepCompleteLabel),
|
||||
text('backLabel', 'Back button label', participationContent.form.backLabel),
|
||||
text('nextLabel', 'Next button label', participationContent.form.nextLabel),
|
||||
text('submitLabel', 'Submit button label', participationContent.form.submitLabel),
|
||||
text('yesLabel', 'Yes option label', participationContent.form.yesLabel),
|
||||
text('noLabel', 'No option label', participationContent.form.noLabel),
|
||||
text('requiredSuffix', 'Required field suffix', participationContent.form.requiredSuffix),
|
||||
{
|
||||
name: 'validation',
|
||||
label: 'Validation messages',
|
||||
type: 'group',
|
||||
fields: [
|
||||
text('choose', 'Choose message', participationContent.form.validation.choose),
|
||||
text('required', 'Required message', participationContent.form.validation.required),
|
||||
text('invalidEmail', 'Invalid email message', participationContent.form.validation.invalidEmail),
|
||||
],
|
||||
},
|
||||
{
|
||||
name: 'typeStep',
|
||||
label: 'Step 1 · Submission type',
|
||||
type: 'group',
|
||||
fields: [
|
||||
text('eyebrow', 'Eyebrow', participationContent.form.typeStep.eyebrow),
|
||||
text('heading', 'Heading', participationContent.form.typeStep.heading),
|
||||
text('selfTitle', 'Self-application title', participationContent.form.typeStep.selfTitle),
|
||||
textarea('selfDesc', 'Self-application description', participationContent.form.typeStep.selfDesc),
|
||||
text('nominationTitle', 'Nomination title', participationContent.form.typeStep.nominationTitle),
|
||||
textarea('nominationDesc', 'Nomination description', participationContent.form.typeStep.nominationDesc),
|
||||
],
|
||||
},
|
||||
{
|
||||
name: 'selfSteps',
|
||||
label: 'Self-application step labels',
|
||||
type: 'array',
|
||||
dbName: 'self_steps',
|
||||
defaultValue: participationContent.form.selfSteps,
|
||||
fields: [text('label', 'Label'), iconField('trophy')],
|
||||
},
|
||||
{
|
||||
name: 'nominationSteps',
|
||||
label: 'Nomination step labels',
|
||||
type: 'array',
|
||||
dbName: 'nom_steps',
|
||||
defaultValue: participationContent.form.nominationSteps,
|
||||
fields: [text('label', 'Label'), iconField('trophy')],
|
||||
},
|
||||
{
|
||||
name: 'employeeOptions',
|
||||
label: 'Employee options',
|
||||
type: 'array',
|
||||
dbName: 'emp_opts',
|
||||
defaultValue: participationContent.form.employeeOptions,
|
||||
fields: [text('value', 'Value'), text('label', 'Label')],
|
||||
},
|
||||
{
|
||||
name: 'eligibility',
|
||||
label: 'Self-application eligibility step',
|
||||
type: 'group',
|
||||
fields: [
|
||||
text('allowedEmployeeValues', 'Allowed employee option values, comma-separated', participationContent.form.eligibility.allowedEmployeeValues),
|
||||
text('requiredBayernValue', 'Required Bavaria answer value', participationContent.form.eligibility.requiredBayernValue),
|
||||
text('eyebrow', 'Eyebrow', participationContent.form.eligibility.eyebrow),
|
||||
text('heading', 'Heading', participationContent.form.eligibility.heading),
|
||||
text('employeesLabel', 'Employees field label', participationContent.form.eligibility.employeesLabel),
|
||||
text('bayernLabel', 'Bavaria field label', participationContent.form.eligibility.bayernLabel),
|
||||
text('ownerLabel', 'Owner field label', participationContent.form.eligibility.ownerLabel),
|
||||
text('ineligibleHeading', 'Ineligible heading', participationContent.form.eligibility.ineligibleHeading),
|
||||
textarea('ineligibleBody', 'Ineligible body', participationContent.form.eligibility.ineligibleBody),
|
||||
text('ineligibleContactPrefix', 'Ineligible contact prefix', participationContent.form.eligibility.ineligibleContactPrefix),
|
||||
text('ineligibleContactEmail', 'Ineligible contact email', participationContent.form.eligibility.ineligibleContactEmail),
|
||||
],
|
||||
},
|
||||
{
|
||||
name: 'selfContact',
|
||||
label: 'Self-application contact step',
|
||||
type: 'group',
|
||||
fields: Object.entries(participationContent.form.selfContact).map(([name, value]) => text(name, name, value)),
|
||||
},
|
||||
{
|
||||
name: 'selfSummary',
|
||||
label: 'Self-application summary step',
|
||||
type: 'group',
|
||||
fields: Object.entries(participationContent.form.selfSummary).map(([name, value]) => text(name, name, value)),
|
||||
},
|
||||
{
|
||||
name: 'nominationCompany',
|
||||
label: 'Nomination company step',
|
||||
type: 'group',
|
||||
fields: Object.entries(participationContent.form.nominationCompany).map(([name, value]) => text(name, name, value)),
|
||||
},
|
||||
{
|
||||
name: 'nominationContact',
|
||||
label: 'Nomination contact step',
|
||||
type: 'group',
|
||||
fields: Object.entries(participationContent.form.nominationContact).map(([name, value]) => text(name, name, value)),
|
||||
},
|
||||
{
|
||||
name: 'nominationSummary',
|
||||
label: 'Nomination summary step',
|
||||
type: 'group',
|
||||
fields: Object.entries(participationContent.form.nominationSummary).map(([name, value]) => text(name, name, value)),
|
||||
},
|
||||
{
|
||||
name: 'success',
|
||||
label: 'Success state copy',
|
||||
type: 'group',
|
||||
fields: Object.entries(participationContent.form.success).map(([name, value]) => text(name, name, value)),
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
]
|
||||
|
||||
@@ -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),
|
||||
],
|
||||
},
|
||||
],
|
||||
|
||||
@@ -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.',
|
||||
},
|
||||
},
|
||||
{
|
||||
|
||||
@@ -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')
|
||||
|
||||
@@ -7,6 +7,11 @@ import { preistraegerDetailContent } from '@/spa/preistraegerDetailContent'
|
||||
import { generatePreviewPath } from '@/utilities/generatePreviewPath'
|
||||
import { slugField } from 'payload'
|
||||
|
||||
import {
|
||||
revalidatePreistraeger,
|
||||
revalidatePreistraegerDelete,
|
||||
} from './Preistraeger/hooks/revalidatePreistraeger'
|
||||
|
||||
import {
|
||||
MetaDescriptionField,
|
||||
MetaTitleField,
|
||||
@@ -372,6 +377,8 @@ export const Preistraeger: CollectionConfig = {
|
||||
slugField(),
|
||||
],
|
||||
hooks: {
|
||||
afterChange: [revalidatePreistraeger],
|
||||
afterDelete: [revalidatePreistraegerDelete],
|
||||
beforeChange: [populatePublishedAt],
|
||||
},
|
||||
versions: {
|
||||
|
||||
46
src/collections/Preistraeger/hooks/revalidatePreistraeger.ts
Normal file
46
src/collections/Preistraeger/hooks/revalidatePreistraeger.ts
Normal 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
|
||||
}
|
||||
114
src/migrations/20260731_143000_remove_press_record_fallbacks.ts
Normal file
114
src/migrations/20260731_143000_remove_press_record_fallbacks.ts
Normal 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)
|
||||
}
|
||||
397
src/migrations/20260731_160000_focused_payload_cleanup.ts
Normal file
397
src/migrations/20260731_160000_focused_payload_cleanup.ts
Normal 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'")
|
||||
}
|
||||
169
src/migrations/20260731_170000_netzwerk_greeting_video.ts
Normal file
169
src/migrations/20260731_170000_netzwerk_greeting_video.ts
Normal 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;'))
|
||||
}
|
||||
}
|
||||
181
src/migrations/20260731_180000_press_contact_image.ts
Normal file
181
src/migrations/20260731_180000_press_contact_image.ts
Normal 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;'))
|
||||
}
|
||||
}
|
||||
@@ -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 = '';`))
|
||||
}
|
||||
@@ -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)};`,
|
||||
),
|
||||
)
|
||||
}
|
||||
@@ -15,6 +15,12 @@ import * as migration_20260713_163000_participation_supporting_awards_copy from
|
||||
import * as migration_20260713_194500_contact_submissions from './20260713_194500_contact_submissions';
|
||||
import * as migration_20260713_213000_netzwerk_jury_selection from './20260713_213000_netzwerk_jury_selection';
|
||||
import * as migration_20260713_220000_preistraeger_detail_cleanup from './20260713_220000_preistraeger_detail_cleanup';
|
||||
import * as migration_20260731_143000_remove_press_record_fallbacks from './20260731_143000_remove_press_record_fallbacks';
|
||||
import * as migration_20260731_160000_focused_payload_cleanup from './20260731_160000_focused_payload_cleanup';
|
||||
import * as migration_20260731_170000_netzwerk_greeting_video from './20260731_170000_netzwerk_greeting_video';
|
||||
import * as migration_20260731_180000_press_contact_image from './20260731_180000_press_contact_image';
|
||||
import * as migration_20260731_190000_responsive_sponsor_hierarchy from './20260731_190000_responsive_sponsor_hierarchy';
|
||||
import * as migration_20260731_200000_home_phase_headline_line_break from './20260731_200000_home_phase_headline_line_break';
|
||||
|
||||
export const migrations = [
|
||||
{
|
||||
@@ -102,4 +108,34 @@ export const migrations = [
|
||||
down: migration_20260713_220000_preistraeger_detail_cleanup.down,
|
||||
name: '20260713_220000_preistraeger_detail_cleanup',
|
||||
},
|
||||
{
|
||||
up: migration_20260731_143000_remove_press_record_fallbacks.up,
|
||||
down: migration_20260731_143000_remove_press_record_fallbacks.down,
|
||||
name: '20260731_143000_remove_press_record_fallbacks',
|
||||
},
|
||||
{
|
||||
up: migration_20260731_160000_focused_payload_cleanup.up,
|
||||
down: migration_20260731_160000_focused_payload_cleanup.down,
|
||||
name: '20260731_160000_focused_payload_cleanup',
|
||||
},
|
||||
{
|
||||
up: migration_20260731_170000_netzwerk_greeting_video.up,
|
||||
down: migration_20260731_170000_netzwerk_greeting_video.down,
|
||||
name: '20260731_170000_netzwerk_greeting_video',
|
||||
},
|
||||
{
|
||||
up: migration_20260731_180000_press_contact_image.up,
|
||||
down: migration_20260731_180000_press_contact_image.down,
|
||||
name: '20260731_180000_press_contact_image',
|
||||
},
|
||||
{
|
||||
up: migration_20260731_190000_responsive_sponsor_hierarchy.up,
|
||||
down: migration_20260731_190000_responsive_sponsor_hierarchy.down,
|
||||
name: '20260731_190000_responsive_sponsor_hierarchy',
|
||||
},
|
||||
{
|
||||
up: migration_20260731_200000_home_phase_headline_line_break.up,
|
||||
down: migration_20260731_200000_home_phase_headline_line_break.down,
|
||||
name: '20260731_200000_home_phase_headline_line_break',
|
||||
},
|
||||
];
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -135,7 +135,8 @@ async function main() {
|
||||
const sponsoringImage = await mediaByFilename(payload, netzwerkContent.sponsoring.imageFilename)
|
||||
const { imageFilename: _heroFilename, ...heroContent } = netzwerkContent.hero
|
||||
const { imageFilename: _sponsoringFilename, ...sponsoringContent } = netzwerkContent.sponsoring
|
||||
const { items: _partnerItems, ...partnerSectionContent } = netzwerkContent.partners
|
||||
const { items: _partnerItems, tiers: partnerTiers, ...partnerSectionContent } = netzwerkContent.partners
|
||||
const existingGreetingVideo = page.netzwerk?.patronage?.greetingVideo || null
|
||||
const [patronPeople, juryMembers] = await Promise.all([
|
||||
itemsWithImageUploads(payload, netzwerkContent.patronage.people),
|
||||
juryMembersForPage(payload),
|
||||
@@ -155,6 +156,7 @@ async function main() {
|
||||
},
|
||||
patronage: {
|
||||
...netzwerkContent.patronage,
|
||||
greetingVideo: existingGreetingVideo,
|
||||
people: patronPeople,
|
||||
},
|
||||
jury: {
|
||||
@@ -167,6 +169,7 @@ async function main() {
|
||||
},
|
||||
partners: {
|
||||
...partnerSectionContent,
|
||||
tiers: partnerTiers.map(({ tier, label, note }) => ({ tier, label, note })),
|
||||
},
|
||||
},
|
||||
} as never,
|
||||
|
||||
@@ -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,
|
||||
})
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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' },
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -7,7 +7,8 @@ import type { SpaApplicationPhaseData } from '@/spa/applicationPhase'
|
||||
import type { SpaNewsletterFormData } from '@/spa/newsletterForm'
|
||||
import type { SpaSiteSettingsData } from '@/spa/siteSettings'
|
||||
|
||||
export type CmsRouteCollection = 'pages' | 'posts' | 'events' | 'preistraeger' | 'partners' | 'jury-mitglieder'
|
||||
export type CmsRouteCollection =
|
||||
'pages' | 'posts' | 'events' | 'preistraeger' | 'partners' | 'jury-mitglieder'
|
||||
|
||||
export type CmsRouteDoc = {
|
||||
id: number | string
|
||||
@@ -34,7 +35,13 @@ export type CmsRouteData = {
|
||||
|
||||
const CmsRouteContext = createContext<CmsRouteData | undefined>(undefined)
|
||||
|
||||
export function CmsRouteProvider({ children, value }: { children: React.ReactNode; value?: CmsRouteData }) {
|
||||
export function CmsRouteProvider({
|
||||
children,
|
||||
value,
|
||||
}: {
|
||||
children: React.ReactNode
|
||||
value?: CmsRouteData
|
||||
}) {
|
||||
return <CmsRouteContext.Provider value={value}>{children}</CmsRouteContext.Provider>
|
||||
}
|
||||
|
||||
@@ -48,10 +55,17 @@ export function useCmsCollection(collection: CmsRouteCollection) {
|
||||
|
||||
export function resolveCmsRelationship(entry: unknown, docs: CmsRouteDoc[]) {
|
||||
const entryID = entry && typeof entry === 'object' ? (entry as { id?: unknown }).id : entry
|
||||
const populatedDoc = docs.find((doc) => String(doc.id) === String(entryID))
|
||||
if (entryID === undefined || entryID === null) return undefined
|
||||
|
||||
if (populatedDoc) return populatedDoc
|
||||
return entry && typeof entry === 'object' ? (entry as CmsRouteDoc) : undefined
|
||||
return docs.find((doc) => String(doc.id) === String(entryID))
|
||||
}
|
||||
|
||||
export function resolveCmsRelationships(entries: unknown, docs: CmsRouteDoc[]) {
|
||||
if (!Array.isArray(entries)) return []
|
||||
|
||||
return entries
|
||||
.map((entry) => resolveCmsRelationship(entry, docs))
|
||||
.filter((doc): doc is CmsRouteDoc => Boolean(doc))
|
||||
}
|
||||
|
||||
export function useApplicationPhase() {
|
||||
|
||||
@@ -102,7 +102,7 @@ export default function HomeWinnersSection({ content }: { content?: HomeWinnersC
|
||||
const placeholderImage = usePreistraegerPlaceholderImage();
|
||||
const cmsPreistraeger = useCmsCollection('preistraeger');
|
||||
const winnersSection = content || {};
|
||||
const homeWinners = resolveHomeWinners(winnersSection.featured, cmsPreistraeger, WINNERS);
|
||||
const homeWinners = resolveHomeWinners(winnersSection.featured, cmsPreistraeger);
|
||||
const scrollDesktopWinners = !isMobile && homeWinners.length > 8;
|
||||
const scrollWinnerGrid = isMobile || scrollDesktopWinners;
|
||||
|
||||
|
||||
@@ -1,8 +1,9 @@
|
||||
import React from 'react';
|
||||
import { FileText, Search, UserPlus, Trophy } from 'lucide-react';
|
||||
import { ChevronLeft, ChevronRight, FileText, Search, UserPlus, Trophy } from 'lucide-react';
|
||||
import {
|
||||
ContainerScroll,
|
||||
ContainerSticky,
|
||||
getActiveTimelineStep,
|
||||
getTimelineScrollBehavior,
|
||||
getTimelineStepScrollLeft,
|
||||
ProcessCard,
|
||||
ProcessCardBody,
|
||||
ProcessCardTitle,
|
||||
@@ -34,12 +35,69 @@ function Lines({ text }: { text: string }) {
|
||||
return <>{text.split('\n').map((line, i) => <React.Fragment key={`${line}-${i}`}>{i > 0 && <br />}{line}</React.Fragment>)}</>;
|
||||
}
|
||||
|
||||
function usePrefersReducedMotion() {
|
||||
const query = '(prefers-reduced-motion: reduce)';
|
||||
|
||||
return React.useSyncExternalStore(
|
||||
(onStoreChange) => {
|
||||
const mediaQuery = window.matchMedia(query);
|
||||
mediaQuery.addEventListener('change', onStoreChange);
|
||||
return () => mediaQuery.removeEventListener('change', onStoreChange);
|
||||
},
|
||||
() => window.matchMedia(query).matches,
|
||||
() => false,
|
||||
);
|
||||
}
|
||||
|
||||
export default function WegZurAuszeichnungSection({ content }: { content?: ProcessContent }) {
|
||||
const isMobile = useIsMobile();
|
||||
const prefersReducedMotion = usePrefersReducedMotion();
|
||||
const trackRef = React.useRef<HTMLOListElement>(null);
|
||||
const [activeStep, setActiveStep] = React.useState(0);
|
||||
const section = { ...participationContent.process, ...(content || {}) };
|
||||
const steps = fallbackArray(section.steps, participationContent.process.steps);
|
||||
const stepPrefix = fallbackText(section.stepPrefix, participationContent.process.stepPrefix);
|
||||
|
||||
const scrollToStep = React.useCallback((index: number) => {
|
||||
const track = trackRef.current;
|
||||
if (!track) return;
|
||||
|
||||
const nextStep = Math.min(steps.length - 1, Math.max(0, index));
|
||||
const maxScroll = Math.max(0, track.scrollWidth - track.clientWidth);
|
||||
track.scrollTo({
|
||||
behavior: getTimelineScrollBehavior(prefersReducedMotion),
|
||||
left: getTimelineStepScrollLeft(nextStep, maxScroll, steps.length),
|
||||
});
|
||||
setActiveStep(nextStep);
|
||||
}, [prefersReducedMotion, steps.length]);
|
||||
|
||||
const handleTrackScroll = React.useCallback(() => {
|
||||
const track = trackRef.current;
|
||||
if (!track) return;
|
||||
|
||||
setActiveStep(getActiveTimelineStep(
|
||||
track.scrollLeft,
|
||||
Math.max(0, track.scrollWidth - track.clientWidth),
|
||||
steps.length,
|
||||
));
|
||||
}, [steps.length]);
|
||||
|
||||
const handleTrackKeyDown = (event: React.KeyboardEvent<HTMLOListElement>) => {
|
||||
if (event.key === 'ArrowRight') {
|
||||
event.preventDefault();
|
||||
scrollToStep(activeStep + 1);
|
||||
} else if (event.key === 'ArrowLeft') {
|
||||
event.preventDefault();
|
||||
scrollToStep(activeStep - 1);
|
||||
} else if (event.key === 'Home') {
|
||||
event.preventDefault();
|
||||
scrollToStep(0);
|
||||
} else if (event.key === 'End') {
|
||||
event.preventDefault();
|
||||
scrollToStep(steps.length - 1);
|
||||
}
|
||||
};
|
||||
|
||||
// ── MOBILE: stacked vertical list (no scroll-jack, no horizontal overflow) ──
|
||||
if (isMobile) {
|
||||
return (
|
||||
@@ -94,14 +152,13 @@ export default function WegZurAuszeichnungSection({ content }: { content?: Proce
|
||||
}
|
||||
|
||||
return (
|
||||
<ContainerScroll id="schritte" className="h-[300vh]" style={{ background: NAVY }}>
|
||||
<ContainerSticky className="top-0 h-screen flex flex-col">
|
||||
<section id="schritte" aria-labelledby="process-heading" style={{ background: NAVY }}>
|
||||
|
||||
{/* Section header */}
|
||||
<div style={{ padding: '56px 80px 40px', display: 'grid', gridTemplateColumns: '1fr 1fr', gap: 40, alignItems: 'flex-end', borderBottom: '1px solid rgba(255,255,255,0.07)', flexShrink: 0 }}>
|
||||
<div className="process-timeline-header" style={{ padding: '56px 80px 40px', display: 'grid', gridTemplateColumns: '1fr 1fr', gap: 40, alignItems: 'flex-end', borderBottom: '1px solid rgba(255,255,255,0.07)' }}>
|
||||
<div>
|
||||
<span style={{ fontFamily: FF, fontSize: 10, color: GOLD, textTransform: 'uppercase', letterSpacing: '0.32em', fontWeight: 700, display: 'block', marginBottom: 14 }}>{fallbackText(section.eyebrow, participationContent.process.eyebrow)}</span>
|
||||
<h2 className="type-section-title text-role-inverse" style={{ textTransform: 'uppercase', margin: 0 }}>
|
||||
<h2 id="process-heading" className="type-section-title text-role-inverse" style={{ textTransform: 'uppercase', margin: 0 }}>
|
||||
<Lines text={fallbackText(section.heading, participationContent.process.heading)} />
|
||||
</h2>
|
||||
</div>
|
||||
@@ -110,15 +167,14 @@ export default function WegZurAuszeichnungSection({ content }: { content?: Proce
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Scroll hint – prominent */}
|
||||
<div style={{ padding: '20px 80px', display: 'flex', alignItems: 'center', gap: 14, flexShrink: 0 }}>
|
||||
{/* Animated chevron stack */}
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 2 }}>
|
||||
{[0, 1, 2].map(i => (
|
||||
{/* Controls and real progress state */}
|
||||
<div className="process-timeline-controls" style={{ padding: '20px 80px', display: 'flex', alignItems: 'center', gap: 14 }}>
|
||||
<div aria-hidden="true" style={{ display: 'flex', gap: 2 }}>
|
||||
{[0, 1, 2].map((i) => (
|
||||
<svg
|
||||
key={i}
|
||||
width="12" height="7" viewBox="0 0 12 7" fill="none"
|
||||
style={{ opacity: 1 - i * 0.3, animation: `scrollBounce 1.6s ease-in-out ${i * 0.18}s infinite` }}
|
||||
style={{ opacity: 1 - i * 0.3, transform: 'rotate(-90deg)' }}
|
||||
>
|
||||
<polyline points="1,1 6,6 11,1" stroke={GOLD} strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round" />
|
||||
</svg>
|
||||
@@ -127,24 +183,57 @@ export default function WegZurAuszeichnungSection({ content }: { content?: Proce
|
||||
<span style={{ fontFamily: FF, fontSize: 10, fontWeight: 700, letterSpacing: '0.22em', textTransform: 'uppercase', color: 'rgba(255,255,255,0.55)' }}>
|
||||
{fallbackText(section.scrollHint, participationContent.process.scrollHint)}
|
||||
</span>
|
||||
{/* Progress bar */}
|
||||
<div style={{ flex: 1, height: 1, background: 'rgba(255,255,255,0.07)', position: 'relative', overflow: 'hidden', maxWidth: 200 }}>
|
||||
<div style={{ position: 'absolute', left: 0, top: 0, height: '100%', width: '25%', background: GOLD, opacity: 0.6 }} />
|
||||
<div data-testid="timeline-progress" style={{ position: 'absolute', left: 0, top: 0, height: '100%', width: `${((activeStep + 1) / steps.length) * 100}%`, background: GOLD, opacity: 0.6, transition: prefersReducedMotion ? 'none' : 'width 0.2s ease' }} />
|
||||
</div>
|
||||
<span 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>
|
||||
<span style={{ fontFamily: FF, fontSize: 9, color: 'rgba(255,255,255,0.50)', letterSpacing: '0.1em' }}>{fallbackText(section.progressLabel, participationContent.process.progressLabel)}</span>
|
||||
</div>
|
||||
|
||||
{/* Cards strip */}
|
||||
<div className="flex flex-nowrap flex-1 items-stretch px-20 pb-16 gap-0">
|
||||
{/* Native horizontal carousel: wheel/trackpad, touch, controls, and keyboard all work. */}
|
||||
<div className="process-timeline-card-shell">
|
||||
<ol
|
||||
ref={trackRef}
|
||||
id="participation-timeline-track"
|
||||
data-testid="timeline-track"
|
||||
aria-label="Schritte zum Preis"
|
||||
tabIndex={0}
|
||||
onKeyDown={handleTrackKeyDown}
|
||||
onScroll={handleTrackScroll}
|
||||
className="process-timeline-track"
|
||||
>
|
||||
{steps.map((item, index) => {
|
||||
const Icon = ICONS[item.icon as keyof typeof ICONS] || FileText;
|
||||
return (
|
||||
<ProcessCard
|
||||
key={`${item.step}-${index}`}
|
||||
itemsLength={steps.length}
|
||||
index={index}
|
||||
variant="bmp"
|
||||
className="h-[300px] self-start min-w-[70%] max-w-[70%] flex-shrink-0"
|
||||
data-testid={`timeline-step-${index + 1}`}
|
||||
aria-label={`${stepPrefix} ${fallbackText(item.step, String(index + 1))}: ${fallbackText(item.title, '')}`}
|
||||
className="process-timeline-card"
|
||||
>
|
||||
{/* Left accent strip with step number */}
|
||||
<ProcessCardTitle className="border-r border-[rgba(239,191,4,0.2)] flex flex-col items-center justify-between py-6 px-4 min-w-[64px]">
|
||||
@@ -175,24 +264,88 @@ export default function WegZurAuszeichnungSection({ content }: { content?: Proce
|
||||
{fallbackText(item.desc, '')}
|
||||
</p>
|
||||
{/* Step progress indicator */}
|
||||
<div style={{ display: 'flex', gap: 4 }}>
|
||||
<div aria-hidden="true" style={{ display: 'flex', gap: 4 }}>
|
||||
{steps.map((_, i) => (
|
||||
<div key={i} style={{ height: 2, flex: i === index ? 2 : 1, background: i === index ? GOLD : 'rgba(255,255,255,0.1)', transition: 'flex 0.3s' }} />
|
||||
<div
|
||||
key={i}
|
||||
style={{ height: 2, flex: i === activeStep ? 2 : 1, background: i === activeStep ? GOLD : 'rgba(255,255,255,0.1)', transition: prefersReducedMotion ? 'none' : 'flex 0.2s ease' }}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</ProcessCardBody>
|
||||
</ProcessCard>
|
||||
);
|
||||
})}
|
||||
</ol>
|
||||
</div>
|
||||
</ContainerSticky>
|
||||
|
||||
<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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -42,12 +42,17 @@ export function ApplicationFormSection({ source }: { source: NewsletterSource })
|
||||
const newsletterPhase = newsletterPhaseForApplicationPhase(activeApplicationPhase)
|
||||
const benefits = fallbackArray<NewsletterBenefit>(section.benefits, homeContent.application.benefits)
|
||||
const finalBenefits = showApplicationForm ? benefits : newsletterCopy.benefits
|
||||
const desktopGridTemplateColumns = source === 'homepage' ? '5fr 1px 7fr' : '4fr 1px 8fr'
|
||||
|
||||
return (
|
||||
<section style={{ background: '#24366A', position: 'relative', overflow: 'hidden', height: isMobile ? 'auto' : showApplicationForm ? 'calc(100vh - 60px)' : 'auto', minHeight: isMobile ? undefined : showApplicationForm ? undefined : 560, display: 'flex', flexDirection: 'column' }} id="bewerben">
|
||||
<div style={{ position: 'absolute', left: -120, top: '50%', transform: 'translateY(-50%)', width: 600, height: 600, borderRadius: '50%', background: 'radial-gradient(circle, rgba(255,255,255,0.08) 0%, transparent 70%)', pointerEvents: 'none' }} />
|
||||
|
||||
<div style={{ display: 'grid', gridTemplateColumns: isMobile ? '1fr' : '4fr 1px 8fr', position: 'relative', flex: isMobile ? 'none' : 1, minHeight: 0, overflow: 'hidden' }}>
|
||||
<div
|
||||
data-testid="application-form-layout"
|
||||
data-source={source}
|
||||
style={{ display: 'grid', gridTemplateColumns: isMobile ? '1fr' : desktopGridTemplateColumns, position: 'relative', flex: isMobile ? 'none' : 1, minHeight: 0, overflow: 'hidden' }}
|
||||
>
|
||||
<div style={{ padding: isMobile ? '32px 24px 24px' : '36px 36px 32px 52px', display: 'flex', flexDirection: 'column', justifyContent: 'center', overflow: 'hidden' }}>
|
||||
<span style={{ fontFamily: FF, fontSize: 10, color: '#EFBF04', textTransform: 'uppercase', letterSpacing: '0.36em', fontWeight: 700, display: 'block', marginBottom: 16, opacity: 0.8 }}>{showApplicationForm ? fallbackText(section.eyebrow, homeContent.application.eyebrow) : newsletterCopy.eyebrow}</span>
|
||||
<h2 className="type-section-title text-role-inverse" style={{ textTransform: 'uppercase', marginBottom: 16 }}>
|
||||
|
||||
@@ -147,8 +147,7 @@ export default function SponsoringForm({ theme = 'dark', content }: { theme?: 'd
|
||||
const isMobile = useIsMobile();
|
||||
const applicationPhase = useApplicationPhase();
|
||||
const copy = { ...netzwerkContent.sponsoringForm, ...(content || {}) };
|
||||
const configuredSteps = fallbackArray<StepOption>(copy.steps, netzwerkContent.sponsoringForm.steps);
|
||||
const steps = configuredSteps.slice(-2);
|
||||
const steps = fallbackArray<StepOption>(copy.steps, netzwerkContent.sponsoringForm.steps);
|
||||
const [step, setStep] = useState(0);
|
||||
const [dir, setDir] = useState(1);
|
||||
const [submitted, setSubmitted] = useState(false);
|
||||
|
||||
@@ -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
|
||||
|
||||
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' }} />
|
||||
export function partnerTickerDurationSeconds(logoCount: number) {
|
||||
if (logoCount <= 0) return 0
|
||||
|
||||
<div style={{ display: 'flex', animation: 'marquee 22s linear infinite', width: 'max-content' }}>
|
||||
{track.map((logo, i) => (
|
||||
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
|
||||
key={`${logo.id}-${i}`}
|
||||
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={{
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
color: '#fff',
|
||||
display: 'flex',
|
||||
flexShrink: 0,
|
||||
fontFamily: '"IBM Plex Sans", sans-serif',
|
||||
justifyContent: 'center',
|
||||
padding: '0 24px',
|
||||
color: '#fff',
|
||||
fontFamily: '"IBM Plex Sans", sans-serif',
|
||||
whiteSpace: 'nowrap',
|
||||
width: 260,
|
||||
flexShrink: 0,
|
||||
textAlign: 'center',
|
||||
whiteSpace: 'nowrap',
|
||||
width: PARTNER_TICKER_ITEM_WIDTH_PX,
|
||||
}}
|
||||
>
|
||||
{logo.label}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
|
||||
return (
|
||||
<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
|
||||
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%); }
|
||||
}
|
||||
|
||||
.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>
|
||||
);
|
||||
)
|
||||
}
|
||||
|
||||
@@ -2,117 +2,64 @@
|
||||
|
||||
import * as React from "react"
|
||||
|
||||
import { useMeasure } from "@uidotdev/usehooks"
|
||||
import { VariantProps, cva } from "class-variance-authority"
|
||||
import {
|
||||
HTMLMotionProps,
|
||||
MotionValue,
|
||||
motion,
|
||||
useScroll,
|
||||
useTransform,
|
||||
} from "motion/react"
|
||||
|
||||
import { cn } from "@/spa/lib/utils"
|
||||
|
||||
const processCardVariants = cva("flex border backdrop-blur-lg", {
|
||||
const processCardVariants = cva("flex border", {
|
||||
variants: {
|
||||
variant: {
|
||||
indigo:
|
||||
"flex border text-slate-50 border-slate-700 backdrop-blur-lg bg-gradient-to-br from-[rgba(15,23,42,0.7)_40%] to-[#3730a3_120%]",
|
||||
"text-slate-50 border-slate-700 bg-gradient-to-br from-[rgba(15,23,42,0.7)_40%] to-[#3730a3_120%]",
|
||||
light: "shadow",
|
||||
bmp: "flex border text-white border-[rgba(239,191,4,0.25)] bg-[#111D55]",
|
||||
},
|
||||
size: {
|
||||
sm: "min-w-[25%] max-w-[25%]",
|
||||
md: "min-w-[50%] max-w-[50%]",
|
||||
lg: "min-w-[75%] max-w-[75%]",
|
||||
xl: "min-w-full max-w-full",
|
||||
bmp: "text-white border-[rgba(239,191,4,0.25)] bg-[#111D55]",
|
||||
},
|
||||
},
|
||||
defaultVariants: {
|
||||
variant: "bmp",
|
||||
size: "md",
|
||||
},
|
||||
})
|
||||
|
||||
const subscribeToViewportWidth = (onStoreChange: () => void) => {
|
||||
window.addEventListener("resize", onStoreChange)
|
||||
return () => window.removeEventListener("resize", onStoreChange)
|
||||
export function getActiveTimelineStep(
|
||||
scrollLeft: number,
|
||||
maxScroll: number,
|
||||
stepsLength: number,
|
||||
) {
|
||||
if (stepsLength <= 1 || maxScroll <= 0) return 0
|
||||
|
||||
const progress = Math.min(1, Math.max(0, scrollLeft / maxScroll))
|
||||
return Math.round(progress * (stepsLength - 1))
|
||||
}
|
||||
|
||||
const getViewportWidth = () => window.innerWidth
|
||||
export function getTimelineStepScrollLeft(
|
||||
index: number,
|
||||
maxScroll: number,
|
||||
stepsLength: number,
|
||||
) {
|
||||
if (stepsLength <= 1 || maxScroll <= 0) return 0
|
||||
|
||||
const getServerViewportWidth = () => 0
|
||||
|
||||
function useViewportWidth() {
|
||||
return React.useSyncExternalStore(
|
||||
subscribeToViewportWidth,
|
||||
getViewportWidth,
|
||||
getServerViewportWidth
|
||||
)
|
||||
const safeIndex = Math.min(stepsLength - 1, Math.max(0, index))
|
||||
return (safeIndex / (stepsLength - 1)) * maxScroll
|
||||
}
|
||||
|
||||
interface ContainerScrollContextValue {
|
||||
scrollYProgress: MotionValue<number>
|
||||
export function getTimelineScrollBehavior(prefersReducedMotion: boolean): ScrollBehavior {
|
||||
return prefersReducedMotion ? "auto" : "smooth"
|
||||
}
|
||||
|
||||
interface ProcessCardProps
|
||||
extends HTMLMotionProps<"div">,
|
||||
VariantProps<typeof processCardVariants> {
|
||||
itemsLength: number
|
||||
index: number
|
||||
}
|
||||
extends React.HTMLAttributes<HTMLLIElement>,
|
||||
VariantProps<typeof processCardVariants> {}
|
||||
|
||||
const ContainerScrollContext = React.createContext<
|
||||
ContainerScrollContextValue | undefined
|
||||
>(undefined)
|
||||
|
||||
function useContainerScrollContext() {
|
||||
const context = React.useContext(ContainerScrollContext)
|
||||
if (!context) {
|
||||
throw new Error(
|
||||
"useContainerScrollContext must be used within a ContainerScroll Component"
|
||||
)
|
||||
}
|
||||
return context
|
||||
}
|
||||
|
||||
export const ContainerScroll = ({
|
||||
children,
|
||||
className,
|
||||
style,
|
||||
...props
|
||||
}: React.HtmlHTMLAttributes<HTMLDivElement>) => {
|
||||
const scrollRef = React.useRef<HTMLDivElement>(null)
|
||||
const { scrollYProgress } = useScroll({
|
||||
target: scrollRef,
|
||||
offset: ["start start", "end end"],
|
||||
})
|
||||
return (
|
||||
<ContainerScrollContext.Provider value={{ scrollYProgress }}>
|
||||
<div
|
||||
ref={scrollRef}
|
||||
className={cn("relative min-h-[120vh]", className)}
|
||||
style={{ position: "relative", ...style }}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
</ContainerScrollContext.Provider>
|
||||
)
|
||||
}
|
||||
|
||||
export const ContainerSticky = React.forwardRef<
|
||||
HTMLDivElement,
|
||||
React.HTMLAttributes<HTMLDivElement>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<div
|
||||
export const ProcessCard = React.forwardRef<HTMLLIElement, ProcessCardProps>(
|
||||
({ className, variant, ...props }, ref) => (
|
||||
<li
|
||||
ref={ref}
|
||||
className={cn("sticky left-0 top-0 w-full overflow-hidden", className)}
|
||||
className={cn(processCardVariants({ variant }), className)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
ContainerSticky.displayName = "ContainerSticky"
|
||||
),
|
||||
)
|
||||
ProcessCard.displayName = "ProcessCard"
|
||||
|
||||
export const ProcessCardTitle = React.forwardRef<
|
||||
HTMLDivElement,
|
||||
@@ -133,37 +80,3 @@ export const ProcessCardBody = React.forwardRef<
|
||||
/>
|
||||
))
|
||||
ProcessCardBody.displayName = "ProcessCardBody"
|
||||
|
||||
export const ProcessCard: React.FC<ProcessCardProps> = ({
|
||||
className,
|
||||
style,
|
||||
variant,
|
||||
size,
|
||||
itemsLength,
|
||||
index,
|
||||
...props
|
||||
}) => {
|
||||
const { scrollYProgress } = useContainerScrollContext()
|
||||
const start = index / itemsLength
|
||||
const end = start + 1 / itemsLength
|
||||
const viewportWidth = useViewportWidth()
|
||||
const [ref, { width }] = useMeasure()
|
||||
|
||||
const x = useTransform(
|
||||
scrollYProgress,
|
||||
[start, end],
|
||||
[viewportWidth, -((width ?? 0) * index) + 64 * index]
|
||||
)
|
||||
return (
|
||||
<motion.div
|
||||
ref={ref}
|
||||
style={{
|
||||
x: index > 0 ? x : 0,
|
||||
...style,
|
||||
}}
|
||||
className={cn(processCardVariants({ variant, size }), className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
ProcessCard.displayName = "ProcessCard"
|
||||
|
||||
@@ -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 –',
|
||||
|
||||
@@ -1,26 +1,14 @@
|
||||
import { resolveCmsRelationship, type CmsRouteDoc } from '@/spa/cmsRoute'
|
||||
import { resolveCmsRelationships, type CmsRouteDoc } from '@/spa/cmsRoute'
|
||||
|
||||
const FALLBACK_YEAR = 2025
|
||||
const FALLBACK_LIMIT = 8
|
||||
|
||||
type FallbackWinner = {
|
||||
id: string | number
|
||||
year?: string | number
|
||||
}
|
||||
|
||||
export function resolveHomeWinners(
|
||||
featured: unknown,
|
||||
cmsWinners: CmsRouteDoc[],
|
||||
fallbackWinners: FallbackWinner[],
|
||||
) {
|
||||
export function resolveHomeWinners(featured: unknown, cmsWinners: CmsRouteDoc[]) {
|
||||
const selectedWinners = Array.isArray(featured) ? featured : []
|
||||
const selectedWinnerDocs = selectedWinners
|
||||
.map((selected) => resolveCmsRelationship(selected, cmsWinners))
|
||||
.filter((winner): winner is CmsRouteDoc => Boolean(winner))
|
||||
|
||||
if (selectedWinnerDocs.length) return selectedWinnerDocs
|
||||
if (selectedWinners.length) return resolveCmsRelationships(selectedWinners, cmsWinners)
|
||||
|
||||
return (cmsWinners.length ? cmsWinners : fallbackWinners)
|
||||
return cmsWinners
|
||||
.filter((winner) => Number(winner.year) === FALLBACK_YEAR)
|
||||
.slice(0, FALLBACK_LIMIT)
|
||||
}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { resolveCmsRelationship, type CmsRouteDoc } from '@/spa/cmsRoute'
|
||||
import { resolveCmsRelationships, type CmsRouteDoc } from '@/spa/cmsRoute'
|
||||
|
||||
const hasText = (value: unknown) => typeof value === 'string' && value.trim().length > 0
|
||||
|
||||
@@ -6,12 +6,7 @@ export function selectJuryDocs(selection: unknown, docs: CmsRouteDoc[]) {
|
||||
if (selection === undefined) return docs
|
||||
if (!Array.isArray(selection)) return []
|
||||
|
||||
return selection
|
||||
.map((entry) => resolveCmsRelationship(entry, docs))
|
||||
.filter(
|
||||
(member): member is CmsRouteDoc =>
|
||||
Boolean(member && docs.some((doc) => String(doc.id) === String(member.id))),
|
||||
)
|
||||
return resolveCmsRelationships(selection, docs)
|
||||
}
|
||||
|
||||
export function sortJuryMembersQuoteFirst<T extends object>(members: T[]) {
|
||||
@@ -23,7 +18,8 @@ export function sortJuryMembersQuoteFirst<T extends object>(members: T[]) {
|
||||
const quoteOrder = Number(hasText(bSortable.quote)) - Number(hasText(aSortable.quote))
|
||||
if (quoteOrder !== 0) return quoteOrder
|
||||
|
||||
const sortOrder = Number(aSortable.sortOrder ?? a.index) - Number(bSortable.sortOrder ?? b.index)
|
||||
const sortOrder =
|
||||
Number(aSortable.sortOrder ?? a.index) - Number(bSortable.sortOrder ?? b.index)
|
||||
return sortOrder !== 0 ? sortOrder : a.index - b.index
|
||||
})
|
||||
.map(({ member }) => member)
|
||||
|
||||
@@ -38,6 +38,10 @@ export const netzwerkContent = {
|
||||
greetingQuote:
|
||||
'„Der Bayerische Mittelstandspreis steht für das, was Bayern stark macht: Unternehmergeist, Verantwortung und Qualität. Mit großer Freude übernehme ich erneut die Schirmherrschaft für diesen bedeutenden Preis.“',
|
||||
greetingAttribution: '– Ilse Aigner MdL, Präsidentin des Bayerischen Landtags',
|
||||
greetingVideoButtonLabel: 'Film abspielen',
|
||||
greetingVideoTitle: 'Grußwort von Ilse Aigner MdL',
|
||||
greetingVideoDescription: 'Video-Grußwort der Schirmherrin des Bayerischen Mittelstandspreises.',
|
||||
greetingVideoCloseLabel: 'Schließen',
|
||||
},
|
||||
juryIntro: {
|
||||
eyebrow: 'Wer entscheidet?',
|
||||
@@ -61,7 +65,6 @@ export const netzwerkContent = {
|
||||
description:
|
||||
'Unabhängige Expertinnen und Experten aus Wirtschaft, Wissenschaft und Verbänden, für einen vollständig unabhängigen Bewertungsprozess.',
|
||||
chairBadge: 'Jury-Vorsitz',
|
||||
note: 'Hinweis: Einzelne Persönlichkeiten engagieren sich sowohl in der Jury als auch als Partner des BMP – beide Rollen werden auf dieser Seite getrennt ausgewiesen.',
|
||||
members: [
|
||||
{
|
||||
name: 'Prof. Dr. Klaus Bergmann',
|
||||
@@ -121,10 +124,8 @@ export const netzwerkContent = {
|
||||
partners: {
|
||||
eyebrow: 'Netzwerkqualität',
|
||||
heading: 'PARTNER & SPONSOREN',
|
||||
description: 'Ein starkes Netzwerk aus Unternehmen und Medien begleitet den Bayerischen Mittelstandspreis. Klicken für Details.',
|
||||
description: 'Ein starkes Netzwerk aus Unternehmen und Medien begleitet den Bayerischen Mittelstandspreis. Die gestaffelten Bänder machen die Gewichtung auf jeder Bildschirmgröße klar.',
|
||||
detailLabel: 'Details',
|
||||
premiumLabel: '',
|
||||
defaultLogoColor: '#111D55',
|
||||
tiers: [
|
||||
{ tier: 1, label: 'Große Darstellung', note: '' },
|
||||
{ tier: 2, label: 'Mittlere Darstellung', note: '' },
|
||||
@@ -287,10 +288,6 @@ export const netzwerkContent = {
|
||||
imageFilename: 'networking-innenhof.jpg',
|
||||
imageAlt: 'BMP Partnernetzwerk',
|
||||
imageLabel: 'BMP Partnernetzwerk',
|
||||
formEyebrow: 'Sponsoring 2026',
|
||||
footnotePrefix: 'Oder:',
|
||||
footnoteLabel: 'Vereinsmitglied werden',
|
||||
footnoteUrl: '/mitglied-werden',
|
||||
benefits: [
|
||||
{ num: '01', label: 'Markenpräsenz', sub: 'Bühne, Drucksachen, digitale Kanäle' },
|
||||
{ num: '02', label: 'Top-Entscheider', sub: 'Exklusives Netzwerk-Dinner für Partner' },
|
||||
@@ -302,13 +299,6 @@ export const netzwerkContent = {
|
||||
{ label: 'Unternehmen' },
|
||||
{ label: 'Kontakt' },
|
||||
],
|
||||
packages: [
|
||||
{ value: 'main', title: 'Haupt-Sponsoring', desc: 'Maximale Sichtbarkeit auf allen Kanälen – Bühne, Digital, Print.' },
|
||||
{ value: 'category', title: 'Kategorie-Partner', desc: 'Namensgebung einer Auszeichnungskategorie – fokussiertes Branding.' },
|
||||
{ value: 'event', title: 'Event-Partner', desc: 'Präsenz beim exklusiven Gala-Abend in München.' },
|
||||
],
|
||||
step1Eyebrow: 'Schritt 1 – Paket wählen',
|
||||
step1Heading: 'Interesse an',
|
||||
step2Eyebrow: 'Schritt 2 – Unternehmen',
|
||||
step2Heading: 'Ihr Unternehmen',
|
||||
companyLabel: 'Unternehmen *',
|
||||
@@ -324,7 +314,6 @@ export const netzwerkContent = {
|
||||
backLabel: 'Zurück',
|
||||
nextLabel: 'Weiter',
|
||||
submitLabel: 'Exposé anfordern',
|
||||
requiredPackageError: 'Bitte wählen Sie ein Paket.',
|
||||
requiredFieldError: 'Pflichtfeld',
|
||||
invalidEmailError: 'Ungültige E-Mail',
|
||||
doneLabel: '✓',
|
||||
|
||||
@@ -6,7 +6,7 @@ import HomeWinnersSection, { type HomeWinnersContent } from '@/spa/components/Ho
|
||||
import TestimonialsSection from '@/spa/components/TestimonialsSection'
|
||||
import MunichSkylineBg from '@/spa/components/ui/munich-skyline-bg'
|
||||
import Image from '@/spa/components/ui/UnoptimizedImage'
|
||||
import { mediaAlt, mediaUrl } from '@/spa/cmsMediaField'
|
||||
import { mediaAlt, mediaObjectPosition, mediaUrl } from '@/spa/cmsMediaField'
|
||||
import { useCmsCollection, useCmsRoute, type CmsRouteDoc } from '@/spa/cmsRoute'
|
||||
import { useIsMobile } from '@/spa/hooks/useIsMobile'
|
||||
import { Link } from '@/spa/router'
|
||||
@@ -82,6 +82,7 @@ function HighlightedText({ text, highlight }: { text: string; highlight: string
|
||||
|
||||
const About: React.FC = () => {
|
||||
const isMobile = useIsMobile()
|
||||
const isRelevanceStacked = useIsMobile(1024)
|
||||
const cms = (useCmsRoute()?.doc?.about || {}) as AboutCms
|
||||
const cmsPages = useCmsCollection('pages')
|
||||
const hero = { ...aboutContent.hero, ...(cms.hero || {}) }
|
||||
@@ -159,10 +160,10 @@ const About: React.FC = () => {
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section id="was-ist" style={{ position: 'relative', overflow: 'hidden', isolation: 'isolate' }}>
|
||||
<section id="was-ist" data-testid="prize-section" style={{ position: 'relative', overflow: 'hidden', isolation: 'isolate' }}>
|
||||
<MunichSkylineBg />
|
||||
<div style={{ display: 'grid', gridTemplateColumns: isMobile ? '1fr' : '55% 45%', minHeight: isMobile ? 'auto' : 580 }}>
|
||||
<div style={{ background: WHITE, padding: isMobile ? '32px 24px 44px' : '88px 80px', display: 'flex', flexDirection: 'column', justifyContent: 'center', order: isMobile ? 2 : 0 }}>
|
||||
<div data-testid="prize-layout" data-layout={isRelevanceStacked ? 'stacked' : 'split'} style={{ display: 'grid', gridTemplateColumns: isRelevanceStacked ? '1fr' : '55% 45%', minHeight: isMobile ? 'auto' : 580 }}>
|
||||
<div data-testid="prize-copy-panel" style={{ background: WHITE, padding: isMobile ? '32px 24px 44px' : isRelevanceStacked ? '56px 48px' : '88px clamp(48px, 5.55vw, 80px)', display: 'flex', flexDirection: 'column', justifyContent: 'center', order: isRelevanceStacked ? 2 : 0 }}>
|
||||
{!isMobile && (
|
||||
<>
|
||||
<span style={{ fontFamily: FF, fontSize: 10, color: 'var(--text-eyebrow-light)', textTransform: 'uppercase', letterSpacing: '0.32em', fontWeight: 700, display: 'block', marginBottom: 20 }}>{fallbackText(prize.eyebrow, aboutContent.prize.eyebrow)}</span>
|
||||
@@ -177,26 +178,28 @@ const About: React.FC = () => {
|
||||
{text}
|
||||
</p>
|
||||
))}
|
||||
<div style={{ display: 'flex', order: isMobile ? -1 : 0, borderTop: isMobile ? 'none' : '1px solid #D0D5DD', borderBottom: isMobile ? '1px solid #D0D5DD' : 'none', marginBottom: isMobile ? 26 : 0 }}>
|
||||
<div data-testid="prize-facts" style={{ display: 'grid', gridTemplateColumns: 'repeat(3, minmax(0, 1fr))', order: isMobile ? -1 : 0, borderTop: isMobile ? 'none' : '1px solid #D0D5DD', borderBottom: isMobile ? '1px solid #D0D5DD' : 'none', marginBottom: isMobile ? 26 : 0 }}>
|
||||
{fallbackArray(prize.facts, aboutContent.prize.facts).map((f, i, arr) => (
|
||||
<div key={`${f.value}-${i}`} style={{ flex: 1, padding: '20px 0', borderRight: i < arr.length - 1 ? '1px solid #D0D5DD' : 'none', paddingLeft: 0, paddingRight: i < arr.length - 1 ? 20 : 0 }}>
|
||||
<div style={{ fontFamily: FF, fontSize: 'clamp(1.4rem, 2vw, 1.8rem)', fontWeight: 900, color: '#101828', letterSpacing: '-0.03em', lineHeight: 1 }}>{fallbackText(f.value, '')}</div>
|
||||
<div style={{ fontFamily: FF, fontSize: 12, fontWeight: 700, color: 'var(--text-brand-on-light)', textTransform: 'uppercase', letterSpacing: '0.18em', marginTop: 6 }}>{fallbackText(f.label, '')}</div>
|
||||
<div key={`${f.value}-${i}`} data-testid="prize-fact" style={{ minWidth: 0, padding: isMobile ? '20px 8px' : '20px 16px', borderRight: i < arr.length - 1 ? '1px solid #D0D5DD' : 'none', display: 'flex', flexDirection: 'column', alignItems: 'center', textAlign: 'center' }}>
|
||||
<div data-testid="prize-fact-value" style={{ fontFamily: FF, fontSize: 'clamp(1.4rem, 2vw, 1.8rem)', fontWeight: 900, color: '#101828', letterSpacing: '-0.03em', lineHeight: 1 }}>{fallbackText(f.value, '')}</div>
|
||||
<div data-testid="prize-fact-label" style={{ maxWidth: '100%', fontFamily: FF, fontSize: isMobile ? 10 : 11, fontWeight: 700, color: 'var(--text-brand-on-light)', textTransform: 'uppercase', letterSpacing: isMobile ? '0.11em' : '0.14em', lineHeight: 1.35, marginTop: 8, overflowWrap: 'anywhere', hyphens: 'auto' }}>{fallbackText(f.label, '')}</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div style={{ position: 'relative', overflow: 'hidden', minHeight: isMobile ? 280 : undefined, order: isMobile ? 1 : 0 }}>
|
||||
<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: 'center', display: 'block' }}
|
||||
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.75) 0%, transparent 60%)` }} />
|
||||
: `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>
|
||||
@@ -205,24 +208,30 @@ const About: React.FC = () => {
|
||||
</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>
|
||||
<figcaption data-testid="prize-image-caption" style={{ position: isMobile ? 'relative' : 'absolute', zIndex: 3, bottom: isMobile ? undefined : 32, left: isMobile ? undefined : 32, maxWidth: isMobile ? undefined : 'calc(100% - 64px)', padding: isMobile ? '12px 24px' : '10px 14px', borderLeft: `3px solid ${GOLD}`, background: isMobile ? '#03093A' : 'rgba(3,9,58,0.92)', color: '#fff', fontFamily: FF, fontSize: 11, fontWeight: 700, lineHeight: 1.4, letterSpacing: '0.16em', textTransform: 'uppercase', overflowWrap: 'anywhere', boxShadow: isMobile ? 'none' : '0 8px 24px rgba(3,9,58,0.24)', backdropFilter: isMobile ? undefined : 'blur(4px)' }}>
|
||||
{fallbackText(prize.imageLabel, aboutContent.prize.imageLabel)}
|
||||
</figcaption>
|
||||
</figure>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section id="mittelstand" style={{ overflow: 'hidden' }}>
|
||||
<div style={{ display: 'grid', gridTemplateColumns: isMobile ? '1fr' : '45% 55%', minHeight: isMobile ? 'auto' : 580 }}>
|
||||
<div style={{ position: 'relative', overflow: 'hidden', minHeight: isMobile ? 280 : undefined }}>
|
||||
<section id="mittelstand" data-testid="relevance-section" style={{ overflow: 'hidden' }}>
|
||||
<div
|
||||
data-testid="relevance-layout"
|
||||
data-layout={isRelevanceStacked ? 'stacked' : 'split'}
|
||||
style={{ display: 'grid', gridTemplateColumns: isRelevanceStacked ? '1fr' : '55% 45%', minHeight: isRelevanceStacked ? 'auto' : 580 }}
|
||||
>
|
||||
<div
|
||||
data-testid="relevance-image-panel"
|
||||
style={{ position: 'relative', overflow: 'hidden', minHeight: isRelevanceStacked ? (isMobile ? 280 : 420) : undefined }}
|
||||
>
|
||||
<Image
|
||||
unoptimized
|
||||
data-testid="relevance-image"
|
||||
src={mediaUrl(mittelstand.image, `/images/${aboutContent.mittelstand.imageFilename}`)}
|
||||
alt={mediaAlt(mittelstand.image, fallbackText(mittelstand.imageAlt, aboutContent.mittelstand.imageAlt))}
|
||||
style={{ position: 'absolute', inset: 0, width: '100%', height: '100%', objectFit: 'cover', objectPosition: 'center', display: 'block' }}
|
||||
style={{ position: 'absolute', inset: 0, width: '100%', height: '100%', objectFit: 'cover', objectPosition: mediaObjectPosition(mittelstand.image), display: 'block' }}
|
||||
/>
|
||||
<div style={{ position: 'absolute', inset: 0, background: isMobile
|
||||
? 'linear-gradient(to top, rgba(3,9,58,0.94) 0%, rgba(3,9,58,0.55) 38%, rgba(3,9,58,0.12) 72%, transparent 100%), linear-gradient(to right, rgba(3,9,58,0.5) 0%, transparent 55%)'
|
||||
@@ -242,7 +251,11 @@ const About: React.FC = () => {
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div style={{ background: NAVY, padding: isMobile ? '32px 24px 40px' : '88px 80px', display: 'flex', flexDirection: 'column', justifyContent: 'center' }}>
|
||||
<div
|
||||
data-testid="relevance-copy-panel"
|
||||
style={{ background: NAVY, padding: isMobile ? '32px 24px 40px' : isRelevanceStacked ? '56px 48px' : '88px 64px', display: 'flex', flexDirection: 'column', justifyContent: 'center' }}
|
||||
>
|
||||
<div data-testid="relevance-copy" style={{ width: '100%', maxWidth: 520, margin: '0 auto' }}>
|
||||
{!isMobile && (
|
||||
<>
|
||||
<span style={{ fontFamily: FF, fontSize: 10, color: '#EFBF04', textTransform: 'uppercase', letterSpacing: '0.32em', fontWeight: 700, display: 'block', marginBottom: 20 }}>{fallbackText(mittelstand.eyebrow, aboutContent.mittelstand.eyebrow)}</span>
|
||||
@@ -253,12 +266,13 @@ const About: React.FC = () => {
|
||||
)}
|
||||
<div style={{ width: 36, height: 2, background: GOLD, marginBottom: 28 }} />
|
||||
{paragraphs(mittelstand.body, aboutContent.mittelstand.body).map((text, index, arr) => (
|
||||
<p key={index} className="type-body text-role-inverse-secondary" style={{ maxWidth: 440, marginBottom: index === arr.length - 1 ? 40 : 16 }}>
|
||||
<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} />
|
||||
|
||||
@@ -34,7 +34,6 @@ type HomeHeroCms = {
|
||||
videoLabel?: string | null;
|
||||
};
|
||||
type HomeSectionCms = CmsRecord & {
|
||||
awardCaption?: string | null;
|
||||
awardImage?: unknown;
|
||||
awardImageAlt?: string | null;
|
||||
benefits?: unknown;
|
||||
@@ -117,7 +116,7 @@ const isMembershipCta = (cta?: { label?: string; url?: string; to?: string } | n
|
||||
cta?.label?.trim().toLowerCase() === 'mitglied werden';
|
||||
|
||||
function Lines({ text }: { text: string }) {
|
||||
return <>{text.split('\n').map((line, i) => <React.Fragment key={`${line}-${i}`}>{i > 0 && <br />}{line}</React.Fragment>)}</>;
|
||||
return <>{text.split(/\r?\n/).map((line, i) => <React.Fragment key={`${line}-${i}`}>{i > 0 && <br />}{line}</React.Fragment>)}</>;
|
||||
}
|
||||
|
||||
function partnerLogos(partners: PartnerLogoData[]) {
|
||||
@@ -269,7 +268,10 @@ const Home: React.FC = () => {
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 16, flexShrink: 0 }}>
|
||||
<div style={{ width: 1, height: 20, background: 'rgba(255,255,255,0.2)' }} />
|
||||
<div style={{ width: 'min(820px, calc(100vw - 260px))', overflow: 'hidden', flexShrink: 0 }}>
|
||||
<PartnerTicker logos={partnerLogos(networkPartners)} />
|
||||
<PartnerTicker
|
||||
ariaLabel={fallbackText(hero.partnersLabel, homeContent.hero.partnersLabel)}
|
||||
logos={partnerLogos(networkPartners)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -540,7 +542,7 @@ const Home: React.FC = () => {
|
||||
|
||||
// ─── StatusSlider ─────────────────────────────────────────────────────────────
|
||||
|
||||
function StatusSlider({ data }: { data?: SpaApplicationPhaseData }) {
|
||||
export function StatusSlider({ data }: { data?: SpaApplicationPhaseData }) {
|
||||
const isMobile = useIsMobile();
|
||||
if (!data?.phases?.length) return null;
|
||||
|
||||
@@ -566,11 +568,12 @@ function StatusSlider({ data }: { data?: SpaApplicationPhaseData }) {
|
||||
|
||||
return (
|
||||
<section
|
||||
data-testid="phase-section"
|
||||
style={{
|
||||
position: 'relative',
|
||||
overflow: 'hidden',
|
||||
height: isMobile ? 'auto' : 480,
|
||||
minHeight: isMobile ? 360 : 'auto',
|
||||
height: 'auto',
|
||||
minHeight: isMobile ? 360 : 480,
|
||||
}}
|
||||
>
|
||||
<StatusSlideCard
|
||||
@@ -603,7 +606,7 @@ function StatusSlideCard({
|
||||
<div
|
||||
style={{
|
||||
width: '100vw',
|
||||
height: '100%',
|
||||
minHeight: isMobile ? 360 : 480,
|
||||
flexShrink: 0,
|
||||
position: 'relative',
|
||||
background: phase.bg,
|
||||
@@ -627,8 +630,8 @@ function StatusSlideCard({
|
||||
|
||||
{/* Headline + body + CTA */}
|
||||
<div>
|
||||
<h2 className="type-page-title text-role-inverse" style={{ textTransform: 'uppercase', margin: '0 0 24px' }}>
|
||||
{phase.headline}
|
||||
<h2 className="type-page-title text-role-inverse" data-testid="phase-headline" style={{ textTransform: 'uppercase', margin: '0 0 24px' }}>
|
||||
<Lines text={fallbackText(phase.headline, '')} />
|
||||
</h2>
|
||||
<p className="type-body text-role-inverse-secondary" style={{ marginBottom: 32, maxWidth: isMobile ? 520 : 760 }}>
|
||||
{phase.body}
|
||||
|
||||
@@ -6,11 +6,19 @@ import MunichSkylineBg from '@/spa/components/ui/munich-skyline-bg';
|
||||
import { useIsMobile } from '@/spa/hooks/useIsMobile';
|
||||
import { useCmsCollection, useCmsRoute, type CmsRouteDoc } from '@/spa/cmsRoute';
|
||||
import { mediaAlt, mediaUrl } from '@/spa/cmsMediaField';
|
||||
import { homeContent } from '@/spa/homeContent';
|
||||
import { selectJuryDocs, sortJuryMembersQuoteFirst } from '@/spa/juryMembers';
|
||||
import { netzwerkContent } from '@/spa/netzwerkContent';
|
||||
import Image from '@/spa/components/ui/UnoptimizedImage'
|
||||
import { PartnerLogoMark, type PartnerLogoData } from '@/spa/components/ui/partner-logo';
|
||||
import {
|
||||
groupSponsorsByTier,
|
||||
sponsorCardBasis,
|
||||
sponsorColumns,
|
||||
sponsorTierNumbers,
|
||||
sponsorViewport,
|
||||
type SponsorTierNumber,
|
||||
type SponsorViewport,
|
||||
} from '@/spa/sponsorHierarchy';
|
||||
|
||||
const FF = '"IBM Plex Sans", sans-serif';
|
||||
const FB = '"Inter", sans-serif';
|
||||
@@ -19,22 +27,11 @@ const GOLD = '#EFBF04';
|
||||
|
||||
type NetzwerkCms = Omit<Partial<typeof netzwerkContent>, 'hero' | 'jury' | 'sponsoring'> & {
|
||||
hero?: Partial<typeof netzwerkContent.hero> & { image?: unknown }
|
||||
patronage?: Partial<typeof netzwerkContent.patronage> & { greetingVideo?: unknown }
|
||||
jury?: Partial<Omit<typeof netzwerkContent.jury, 'members'>> & { members?: unknown[] | null }
|
||||
sponsoring?: Partial<typeof netzwerkContent.sponsoring> & { image?: unknown }
|
||||
}
|
||||
|
||||
type HomeVideoCms = {
|
||||
hero?: {
|
||||
videoLabel?: string | null
|
||||
}
|
||||
videoModal?: {
|
||||
video?: unknown
|
||||
title?: string | null
|
||||
description?: string | null
|
||||
closeLabel?: string | null
|
||||
}
|
||||
}
|
||||
|
||||
type PatronPerson = (typeof netzwerkContent.patronage.people)[number] & { imageUpload?: unknown }
|
||||
type JuryMember = (typeof netzwerkContent.jury.members)[number] & {
|
||||
active?: boolean | null
|
||||
@@ -63,17 +60,7 @@ type PartnerModalCopy = typeof netzwerkContent.partners.modal
|
||||
const fallbackText = (value: unknown, fallback: string) => typeof value === 'string' && value.length > 0 ? value : fallback;
|
||||
const fallbackArray = <T,>(value: unknown, fallback: T[]) => Array.isArray(value) && value.length ? value as T[] : fallback;
|
||||
const managedImage = (upload: unknown, fallback: unknown) => mediaUrl(upload, fallbackText(fallback, ''))
|
||||
const deprecatedPartnerDescription = 'Partner in drei Kategorien: Hauptsponsoren, Medienpartner und weitere Sponsoren. Klicken für Details.'
|
||||
const visiblePartnerDescription = (value: unknown) => {
|
||||
const text = fallbackText(value, netzwerkContent.partners.description)
|
||||
return text.trim() === deprecatedPartnerDescription ? netzwerkContent.partners.description : text
|
||||
}
|
||||
const juryImageFallback = '/images/preistraeger-placeholder.svg'
|
||||
const sortedPartners = <T extends PartnerLogoData>(partners: T[]) =>
|
||||
partners
|
||||
.map((partner, index) => ({ partner, index }))
|
||||
.sort((a, b) => Number(a.partner.sortOrder ?? a.index) - Number(b.partner.sortOrder ?? b.index))
|
||||
.map(({ partner }) => partner)
|
||||
const normalizeJuryMember = (member: CmsRouteDoc): JuryMember => {
|
||||
const organization = fallbackText(member.organization, '')
|
||||
const rawRole = fallbackText(member.role, '')
|
||||
@@ -237,27 +224,46 @@ const JuryChairCard: React.FC<{ member: JuryMember; badge: string }> = ({ member
|
||||
|
||||
// ── SponsorCell (tiered) ──────────────────────────────────────────────────────
|
||||
|
||||
const SponsorCell: React.FC<{ partner: Partner; variant: 'lg' | 'md' | 'sm'; idx: number; cols: number; detailLabel: string; onClick: () => void }> = ({ partner, variant, idx, cols, detailLabel, onClick }) => {
|
||||
const SponsorCell: React.FC<{
|
||||
partner: Partner
|
||||
variant: 'lg' | 'md' | 'sm'
|
||||
columns: number
|
||||
detailLabel: string
|
||||
gap: number
|
||||
viewport: SponsorViewport
|
||||
onClick: () => void
|
||||
}> = ({ partner, variant, columns, detailLabel, gap, viewport, onClick }) => {
|
||||
const [hovered, setHovered] = useState(false);
|
||||
const isMobileCell = useIsMobile();
|
||||
const lg = variant === 'lg';
|
||||
const sm = variant === 'sm';
|
||||
const lastCol = (idx % cols) === cols - 1;
|
||||
const logoHeight = isMobileCell ? (lg ? 76 : sm ? 54 : 62) : lg ? 92 : sm ? 62 : 76;
|
||||
const mobileCell = viewport === 'mobile' || viewport === 'narrow-mobile';
|
||||
const logoHeight = mobileCell ? (lg ? 96 : sm ? 58 : 76) : lg ? 116 : sm ? 72 : 92;
|
||||
const basis = sponsorCardBasis(columns, gap);
|
||||
return (
|
||||
<div
|
||||
<button
|
||||
type="button"
|
||||
aria-label={`${detailLabel}: ${partner.name}`}
|
||||
data-testid={`sponsor-card-${partner.id}`}
|
||||
onClick={onClick}
|
||||
onMouseEnter={() => setHovered(true)}
|
||||
onMouseLeave={() => setHovered(false)}
|
||||
style={{
|
||||
padding: isMobileCell ? '26px 20px' : lg ? '44px 38px 40px' : sm ? '30px 28px' : '36px 32px',
|
||||
borderRight: !lastCol && !isMobileCell ? '1px solid rgba(3,9,58,0.08)' : 'none',
|
||||
borderBottom: '1px solid rgba(3,9,58,0.08)',
|
||||
appearance: 'none',
|
||||
boxSizing: 'border-box',
|
||||
width: basis,
|
||||
flex: `0 0 ${basis}`,
|
||||
minWidth: 0,
|
||||
padding: mobileCell ? (sm ? '22px 16px' : '30px 24px') : lg ? '48px 42px 44px' : sm ? '30px 26px' : '38px 34px',
|
||||
border: '1px solid rgba(3,9,58,0.1)',
|
||||
borderTop: `${lg ? 4 : sm ? 2 : 3}px solid ${GOLD}`,
|
||||
display: 'flex', flexDirection: 'column', gap: lg ? 18 : 16,
|
||||
cursor: 'pointer',
|
||||
background: hovered ? '#E8E5E4' : 'transparent',
|
||||
background: hovered ? '#E8E5E4' : 'rgba(255,255,255,0.92)',
|
||||
transition: 'background 0.15s',
|
||||
position: 'relative',
|
||||
textAlign: 'left',
|
||||
color: 'inherit',
|
||||
font: 'inherit',
|
||||
}}
|
||||
>
|
||||
<div style={{ height: logoHeight, display: 'flex', alignItems: 'center', justifyContent: 'center', width: '100%', minWidth: 0 }}>
|
||||
@@ -269,7 +275,7 @@ const SponsorCell: React.FC<{ partner: Partner; variant: 'lg' | 'md' | 'sm'; idx
|
||||
<div style={{ fontFamily: FF, fontSize: 11, textTransform: 'uppercase', letterSpacing: '0.12em', color: GOLD, fontWeight: 700, display: 'flex', alignItems: 'center', gap: 4 }}>
|
||||
{detailLabel} <ArrowRight size={10} />
|
||||
</div>
|
||||
</div>
|
||||
</button>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -464,15 +470,14 @@ function PartnerModal({ partner, copy, onClose }: { partner: Partner; copy: Part
|
||||
|
||||
const Netzwerk: React.FC = () => {
|
||||
const isMobile = useIsMobile();
|
||||
const isTabletOrMobile = useIsMobile(1200);
|
||||
const isNarrowMobile = useIsMobile(360);
|
||||
const [selectedPartner, setSelectedPartner] = useState<Partner | null>(null);
|
||||
const [showGreetingVideo, setShowGreetingVideo] = useState(false);
|
||||
const greetingVideoRef = useRef<HTMLVideoElement>(null);
|
||||
const cms = (useCmsRoute()?.doc?.netzwerk || {}) as NetzwerkCms;
|
||||
const homePage = useCmsCollection('pages').find((page) => page.spaPath === '/' || page.slug === 'startseite' || page.slug === 'home');
|
||||
const cmsPartners = useCmsCollection('partners') as unknown as Partner[];
|
||||
const cmsJuryMembers = useCmsCollection('jury-mitglieder');
|
||||
const home = (homePage?.home || {}) as HomeVideoCms;
|
||||
const homeVideoModal = home.videoModal || {};
|
||||
const hero = { ...netzwerkContent.hero, ...(cms.hero || {}) };
|
||||
const patronage = { ...netzwerkContent.patronage, ...(cms.patronage || {}) };
|
||||
const juryIntro = { ...netzwerkContent.juryIntro, ...(cms.juryIntro || {}) };
|
||||
@@ -483,30 +488,34 @@ const Netzwerk: React.FC = () => {
|
||||
const sponsoring = { ...netzwerkContent.sponsoring, ...(cms.sponsoring || {}) };
|
||||
const patronPeople = fallbackArray<PatronPerson>(patronage.people, netzwerkContent.patronage.people);
|
||||
const cmsJurySelection = cms.jury?.members;
|
||||
const hasCmsJurySelection = cmsJurySelection !== undefined;
|
||||
const sourcedJuryDocs = selectJuryDocs(cmsJurySelection, cmsJuryMembers);
|
||||
const normalizedJuryMembers = sourcedJuryDocs
|
||||
.map(normalizeJuryMember)
|
||||
.filter((member) => member.active !== false && member.name);
|
||||
const jurySourceMembers: JuryMember[] = normalizedJuryMembers.length || hasCmsJurySelection
|
||||
? normalizedJuryMembers
|
||||
: netzwerkContent.jury.members;
|
||||
const juryMembers = sortJuryMembersQuoteFirst(jurySourceMembers);
|
||||
const juryMembers = sortJuryMembersQuoteFirst(normalizedJuryMembers);
|
||||
const expertiseRows = fallbackArray<ExpertiseRow>(expertise.rows, netzwerkContent.expertise.rows);
|
||||
const partners = sortedPartners(cmsPartners)
|
||||
const partners = cmsPartners
|
||||
.filter((partner) => partner.active !== false)
|
||||
.map((partner) => ({
|
||||
...partner,
|
||||
id: partner.stableId || partner.id,
|
||||
}));
|
||||
const partnerTiers = fallbackArray<PartnerTier>(partnerSection.tiers, netzwerkContent.partners.tiers);
|
||||
const configuredPartnerTiers = fallbackArray<PartnerTier>(partnerSection.tiers, netzwerkContent.partners.tiers);
|
||||
const partnerTiers = sponsorTierNumbers.map((tier) => ({
|
||||
...(netzwerkContent.partners.tiers.find((entry) => entry.tier === tier) as PartnerTier),
|
||||
...(configuredPartnerTiers.find((entry) => Number(entry.tier) === tier) || {}),
|
||||
tier,
|
||||
}));
|
||||
const sponsorsByTier = groupSponsorsByTier(partners);
|
||||
const sponsorLayoutViewport = sponsorViewport({ isMobile, isNarrowMobile, isTabletOrMobile });
|
||||
const mobileStats = fallbackArray<StatItem>(juryIntro.stats, netzwerkContent.juryIntro.stats);
|
||||
const desktopStats = fallbackArray<StatItem>(juryIntro.desktopStats, netzwerkContent.juryIntro.desktopStats);
|
||||
const sponsorBenefits = fallbackArray<BenefitItem>(sponsoring.benefits, netzwerkContent.sponsoring.benefits);
|
||||
const greetingVideoSrc = mediaUrl(homeVideoModal.video, '');
|
||||
const greetingVideoTitle = fallbackText(homeVideoModal.title, homeContent.videoModal.title);
|
||||
const greetingVideoLabel = fallbackText(home.hero?.videoLabel, homeContent.hero.videoLabel);
|
||||
const greetingVideoCloseLabel = fallbackText(homeVideoModal.closeLabel, homeContent.videoModal.closeLabel);
|
||||
const greetingVideoSrc = mediaUrl(patronage.greetingVideo, '');
|
||||
const greetingVideoTitle = fallbackText(patronage.greetingVideoTitle, netzwerkContent.patronage.greetingVideoTitle);
|
||||
const greetingVideoDescription = fallbackText(patronage.greetingVideoDescription, netzwerkContent.patronage.greetingVideoDescription);
|
||||
const greetingVideoLabel = fallbackText(patronage.greetingVideoButtonLabel, netzwerkContent.patronage.greetingVideoButtonLabel);
|
||||
const greetingVideoCloseLabel = fallbackText(patronage.greetingVideoCloseLabel, netzwerkContent.patronage.greetingVideoCloseLabel);
|
||||
|
||||
useEffect(() => {
|
||||
if (!showGreetingVideo || !greetingVideoSrc) return;
|
||||
@@ -759,23 +768,61 @@ const Netzwerk: React.FC = () => {
|
||||
<h2 className="type-section-title text-role-heading" style={{ textTransform: 'uppercase', margin: 0 }}><Lines text={fallbackText(partnerSection.heading, netzwerkContent.partners.heading)} /></h2>
|
||||
</div>
|
||||
<p className="type-body text-role-secondary" style={{ margin: 0 }}>
|
||||
{visiblePartnerDescription(partnerSection.description)}
|
||||
{fallbackText(partnerSection.description, netzwerkContent.partners.description)}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Tiered grid – continuous, ranks marked by label bars */}
|
||||
<div style={{ position: 'relative', zIndex: 1 }}>
|
||||
{/* Independent tier bands keep rank legible without mixing tiers to fill a row. */}
|
||||
<div style={{ position: 'relative', zIndex: 1, padding: isMobile ? '16px 20px 52px' : '24px 48px 80px' }}>
|
||||
{partnerTiers.map(t => {
|
||||
const tier = Number(t.tier) as 1 | 2 | 3;
|
||||
const tier = Number(t.tier) as SponsorTierNumber;
|
||||
const variant = tier === 1 ? 'lg' as const : tier === 2 ? 'md' as const : 'sm' as const;
|
||||
const cols = isMobile ? (tier === 1 ? 1 : 2) : (tier === 3 ? 4 : 2);
|
||||
const items = partners.filter(p => p.tier === tier);
|
||||
const columns = sponsorColumns(tier, sponsorLayoutViewport);
|
||||
const gap = isMobile ? 12 : tier === 3 ? 16 : 20;
|
||||
const items = sponsorsByTier[tier];
|
||||
const label = fallbackText(t.label, netzwerkContent.partners.tiers[tier - 1]?.label || '');
|
||||
const note = fallbackText(t.note, '');
|
||||
if (!items.length) return null;
|
||||
return (
|
||||
<div key={t.tier} style={{ display: 'grid', gridTemplateColumns: `repeat(${cols}, 1fr)` }}>
|
||||
{items.map((p, i) => (
|
||||
<SponsorCell key={p.id} partner={p} variant={variant} idx={i} cols={cols} detailLabel={fallbackText(partnerSection.detailLabel, netzwerkContent.partners.detailLabel)} onClick={() => setSelectedPartner(p)} />
|
||||
<section
|
||||
key={tier}
|
||||
data-testid={`sponsor-tier-${tier}`}
|
||||
data-tier={tier}
|
||||
data-columns={columns}
|
||||
data-viewport={sponsorLayoutViewport}
|
||||
style={{
|
||||
maxWidth: 1240,
|
||||
margin: '0 auto',
|
||||
padding: isMobile ? (tier === 1 ? '34px 0 14px' : '42px 0 14px') : (tier === 1 ? '50px 0 18px' : '62px 0 18px'),
|
||||
borderBottom: tier === 3 ? 'none' : '1px solid rgba(3,9,58,0.08)',
|
||||
}}
|
||||
>
|
||||
<header style={{ maxWidth: tier === 1 ? 720 : 620, margin: '0 auto', textAlign: 'center', padding: isMobile ? '0 8px 24px' : '0 16px 32px' }}>
|
||||
<h3 style={{ fontFamily: FF, fontSize: isMobile ? (tier === 1 ? 25 : 21) : (tier === 1 ? 34 : tier === 2 ? 28 : 23), fontWeight: 800, color: NAVY, textTransform: 'uppercase', letterSpacing: tier === 3 ? '0.08em' : '0.04em', lineHeight: 1.1, margin: 0 }}>
|
||||
{label}
|
||||
</h3>
|
||||
{note ? (
|
||||
<p style={{ fontFamily: FB, fontSize: isMobile ? 14 : 16, color: 'rgba(16,24,40,0.58)', lineHeight: 1.55, margin: '12px 0 0' }}>{note}</p>
|
||||
) : null}
|
||||
</header>
|
||||
<div
|
||||
data-testid={`sponsor-tier-${tier}-cards`}
|
||||
style={{ display: 'flex', flexWrap: 'wrap', justifyContent: 'center', alignItems: 'stretch', gap }}
|
||||
>
|
||||
{items.map((partner) => (
|
||||
<SponsorCell
|
||||
key={partner.id}
|
||||
partner={partner}
|
||||
variant={variant}
|
||||
columns={columns}
|
||||
detailLabel={fallbackText(partnerSection.detailLabel, netzwerkContent.partners.detailLabel)}
|
||||
gap={gap}
|
||||
viewport={sponsorLayoutViewport}
|
||||
onClick={() => setSelectedPartner(partner)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
@@ -854,6 +901,10 @@ const Netzwerk: React.FC = () => {
|
||||
{showGreetingVideo && greetingVideoSrc && (
|
||||
<div
|
||||
onClick={(e) => { if (e.target === e.currentTarget) setShowGreetingVideo(false); }}
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
aria-labelledby="netzwerk-greeting-video-title"
|
||||
aria-describedby="netzwerk-greeting-video-description"
|
||||
style={{
|
||||
position: 'fixed',
|
||||
inset: 0,
|
||||
@@ -865,6 +916,8 @@ const Netzwerk: React.FC = () => {
|
||||
padding: isMobile ? 16 : 24,
|
||||
}}
|
||||
>
|
||||
<h2 id="netzwerk-greeting-video-title" className="sr-only">{greetingVideoTitle}</h2>
|
||||
<p id="netzwerk-greeting-video-description" className="sr-only">{greetingVideoDescription}</p>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setShowGreetingVideo(false)}
|
||||
|
||||
@@ -49,6 +49,7 @@ function Lines({ text }: { text: string }) {
|
||||
|
||||
const Participation: React.FC = () => {
|
||||
const isMobile = useIsMobile();
|
||||
const isEligibilityCompact = useIsMobile(900);
|
||||
const qualSectionRef = useRef<HTMLElement>(null);
|
||||
const cms = (useCmsRoute()?.doc?.participation || {}) as ParticipationCms;
|
||||
const hero = { ...participationContent.hero, ...(cms.hero || {}) };
|
||||
@@ -94,10 +95,10 @@ const Participation: React.FC = () => {
|
||||
<WegZurAuszeichnungSection content={process} />
|
||||
|
||||
{/* ── QUALIFICATIONS ───────────────────────────────────────────────────── */}
|
||||
<section id="voraussetzungen" ref={qualSectionRef} style={{ background: '#fff', overflow: 'hidden', position: 'relative', isolation: 'isolate' }}>
|
||||
<section id="voraussetzungen" ref={qualSectionRef} data-testid="eligibility-section" style={{ background: '#fff', overflow: 'hidden', position: 'relative', isolation: 'isolate', scrollMarginTop: 60 }}>
|
||||
|
||||
{/* Header */}
|
||||
<div style={{ position: 'relative', zIndex: 1, padding: isMobile ? '48px 24px 32px' : '80px 80px 56px', display: 'grid', gridTemplateColumns: isMobile ? '1fr' : '1fr 1fr', gap: isMobile ? 24 : 48, alignItems: 'flex-end', borderBottom: `1px solid #D0D5DD` }}>
|
||||
<div data-testid="eligibility-header" style={{ position: 'relative', zIndex: 1, padding: isMobile ? '48px 24px 36px' : '80px 80px 56px', display: 'grid', gridTemplateColumns: isMobile ? '1fr' : '1fr 1fr', gap: isMobile ? 24 : 48, alignItems: 'flex-end', background: '#fff', borderBottom: '1px solid rgba(17,29,85,0.16)' }}>
|
||||
<div>
|
||||
<span style={{ fontFamily: FF, fontSize: 10, color: 'var(--text-eyebrow-light)', textTransform: 'uppercase', letterSpacing: '0.32em', fontWeight: 700, display: 'block', marginBottom: 16 }}>{fallbackText(eligibility.eyebrow, participationContent.eligibility.eyebrow)}</span>
|
||||
<h2 className="type-section-title text-role-heading" style={{ textTransform: 'uppercase', margin: 0 }}>
|
||||
@@ -122,22 +123,25 @@ const Participation: React.FC = () => {
|
||||
</div>
|
||||
|
||||
{/* 4 criteria as editorial rows */}
|
||||
<div style={{ position: 'relative', zIndex: 1, background: '#fff', borderBottom: `1px solid #D0D5DD` }}>
|
||||
<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} />
|
||||
<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' }}>
|
||||
<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} style={{ display: 'flex', alignItems: 'flex-start', gap: 14 }}>
|
||||
<Icon size={18} style={{ color: GOLD, flexShrink: 0, marginTop: 3 }} strokeWidth={1.7} />
|
||||
<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 }}>
|
||||
@@ -148,30 +152,31 @@ const Participation: React.FC = () => {
|
||||
);
|
||||
})}
|
||||
</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>
|
||||
</div>
|
||||
<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>
|
||||
</aside>
|
||||
</section>
|
||||
|
||||
{/* ── BEWERBUNGSWEGE ───────────────────────────────────────────────────── */}
|
||||
<BewerbungswegeSection content={applicationWays} />
|
||||
|
||||
{/* ── DATES BANNER ─────────────────────────────────────────────────────── */}
|
||||
<section id="fristen" style={{ background: GOLD }}>
|
||||
<section id="fristen" data-testid="important-dates-transition" style={{ background: GOLD, borderTop: `${isMobile ? 5 : 8}px solid ${NAVY}`, scrollMarginTop: 80, boxShadow: 'inset 0 1px 0 rgba(255,255,255,0.24)' }}>
|
||||
{isMobile ? (
|
||||
/* Mobile: slim horizontal bar */
|
||||
<div style={{ padding: '24px 22px 26px' }}>
|
||||
<div style={{ fontFamily: FF, fontSize: 12, fontWeight: 700, color: '#101828', textTransform: 'uppercase', letterSpacing: '0.2em', marginBottom: 18 }}>{fallbackText(datesBanner.heading, participationContent.datesBanner.heading)}</div>
|
||||
<div style={{ display: 'flex' }}>
|
||||
<div data-testid="important-dates-list" style={{ display: 'grid' }}>
|
||||
{mobileDates.map((d, i) => (
|
||||
<div key={i} style={{ flex: 1, minWidth: 0, paddingLeft: i > 0 ? 14 : 0, borderLeft: i > 0 ? '1px solid rgba(3,9,58,0.2)' : 'none' }}>
|
||||
<div style={{ fontFamily: FF, fontSize: 17, fontWeight: 900, color: '#101828', letterSpacing: '-0.02em', lineHeight: 1.12, marginBottom: 6 }}>{fallbackText(d.date, '')}</div>
|
||||
<div style={{ fontFamily: FF, fontSize: 9.5, fontWeight: 700, letterSpacing: '0.1em', textTransform: 'uppercase', color: 'rgba(16,24,40,0.55)', lineHeight: 1.25 }}>{fallbackText(d.label, '')}</div>
|
||||
<div key={i} data-testid="important-date-item" data-order={i + 1} style={{ display: 'grid', gridTemplateColumns: 'minmax(112px, 0.9fr) minmax(0, 1.1fr)', gap: 16, minWidth: 0, padding: '14px 0', borderTop: '1px solid rgba(3,9,58,0.2)', alignItems: 'center' }}>
|
||||
<div style={{ fontFamily: FF, fontSize: 16, fontWeight: 900, color: '#101828', letterSpacing: '-0.02em', lineHeight: 1.12 }}>{fallbackText(d.date, '')}</div>
|
||||
<div style={{ fontFamily: FF, fontSize: 10.5, fontWeight: 700, letterSpacing: '0.08em', textTransform: 'uppercase', color: 'rgba(16,24,40,0.72)', lineHeight: 1.35, overflowWrap: 'anywhere' }}>{fallbackText(d.label, '')}</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
@@ -188,7 +193,7 @@ const Participation: React.FC = () => {
|
||||
<React.Fragment key={i}>
|
||||
<div style={{ padding: '48px 40px', display: 'flex', flexDirection: 'column', justifyContent: 'center' }}>
|
||||
<div style={{ fontFamily: FF, fontSize: 'clamp(1.1rem, 1.8vw, 1.4rem)', fontWeight: 900, color: '#101828', letterSpacing: '-0.02em', marginBottom: 6 }}>{fallbackText(d.date, '')}</div>
|
||||
<div style={{ fontFamily: FF, fontSize: 10, fontWeight: 700, letterSpacing: '0.18em', textTransform: 'uppercase', color: 'rgba(16,24,40,0.55)' }}>{fallbackText(d.label, '')}</div>
|
||||
<div style={{ fontFamily: FF, fontSize: 10, fontWeight: 700, letterSpacing: '0.18em', textTransform: 'uppercase', color: 'rgba(16,24,40,0.72)' }}>{fallbackText(d.label, '')}</div>
|
||||
</div>
|
||||
{i < desktopDates.length - 1 && <div style={{ background: 'rgba(3,9,58,0.12)' }} />}
|
||||
</React.Fragment>
|
||||
@@ -207,31 +212,32 @@ const Participation: React.FC = () => {
|
||||
|
||||
// ── QualRow ───────────────────────────────────────────────────────────────────
|
||||
|
||||
function QualRow({ item, Icon, last }: { item: { num: string; title: string; body: string }; Icon: React.ElementType; last: boolean }) {
|
||||
function QualRow({ item, Icon, last, index, compact }: { item: { num: string; title: string; body: string }; Icon: React.ElementType; last: boolean; index: number; compact: boolean }) {
|
||||
const [hovered, setHovered] = React.useState(false);
|
||||
const isMobileRow = useIsMobile();
|
||||
return (
|
||||
<div
|
||||
style={{ display: 'grid', gridTemplateColumns: isMobileRow ? '40px 1fr' : '80px 200px 1fr 48px', gap: isMobileRow ? '0 16px' : '0 32px', padding: isMobileRow ? '24px 24px' : '28px 80px', borderBottom: last ? 'none' : '1px solid #D0D5DD', alignItems: isMobileRow ? 'flex-start' : 'center', background: hovered ? '#EAE7E6' : 'transparent', transition: 'background 0.15s', cursor: 'default' }}
|
||||
<li
|
||||
data-testid="eligibility-criterion"
|
||||
data-order={index + 1}
|
||||
style={{ display: 'grid', gridTemplateColumns: compact ? '40px 1fr' : '64px minmax(180px, 220px) minmax(0, 1fr) 44px', gap: compact ? '0 16px' : '0 28px', padding: compact ? '22px 24px' : '26px 0', borderBottom: last ? 'none' : '1px solid rgba(17,29,85,0.14)', alignItems: compact ? 'flex-start' : 'center', background: hovered ? '#fff' : 'transparent', transition: 'background 0.15s', cursor: 'default' }}
|
||||
onMouseEnter={() => setHovered(true)}
|
||||
onMouseLeave={() => setHovered(false)}
|
||||
>
|
||||
<span style={{ fontFamily: FF, fontSize: 11, fontWeight: 700, color: hovered ? GOLD : 'rgba(239,191,4,0.5)', letterSpacing: '0.1em', transition: 'color 0.2s', paddingTop: 2 }}>{item.num}</span>
|
||||
{isMobileRow ? (
|
||||
<span style={{ fontFamily: FF, fontSize: 11, fontWeight: 700, color: hovered ? '#715600' : '#5B6475', letterSpacing: '0.1em', transition: 'color 0.2s', paddingTop: 2 }}>{item.num}</span>
|
||||
{compact ? (
|
||||
<div>
|
||||
<div style={{ fontFamily: FF, fontSize: 17, fontWeight: 700, color: '#101828', textTransform: 'uppercase', letterSpacing: '0.07em', marginBottom: 4 }}>{item.title}</div>
|
||||
<div style={{ fontFamily: FB, fontSize: 17, color: 'rgba(16,24,40,0.5)', lineHeight: 1.6 }}>{item.body}</div>
|
||||
<div style={{ fontFamily: FB, fontSize: 17, color: 'rgba(16,24,40,0.72)', lineHeight: 1.6 }}>{item.body}</div>
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
<div style={{ fontFamily: FF, fontSize: 17, fontWeight: 700, color: '#101828', textTransform: 'uppercase', letterSpacing: '0.07em' }}>{item.title}</div>
|
||||
<div style={{ fontFamily: FB, fontSize: 17, color: 'rgba(16,24,40,0.5)', lineHeight: 1.6 }}>{item.body}</div>
|
||||
<div style={{ fontFamily: FB, fontSize: 17, color: 'rgba(16,24,40,0.72)', lineHeight: 1.6 }}>{item.body}</div>
|
||||
<div style={{ width: 36, height: 36, background: hovered ? GOLD : 'rgba(3,9,58,0.06)', display: 'flex', alignItems: 'center', justifyContent: 'center', transition: 'background 0.2s' }}>
|
||||
<Icon size={16} style={{ color: hovered ? '#101828' : 'rgba(16,24,40,0.3)', transition: 'color 0.2s' }} strokeWidth={1.5} />
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</li>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import React, { useState, useMemo } from 'react';
|
||||
import { Link } from '@/spa/router';
|
||||
import { Trophy, ArrowRight, Medal } from 'lucide-react';
|
||||
import { WINNERS, type Winner } from '@/spa/data/winners';
|
||||
import type { Winner } from '@/spa/data/winners';
|
||||
import { useCmsCollection, useCmsRoute, type CmsRouteDoc } from '@/spa/cmsRoute';
|
||||
import { docImageUrl, mediaAlt, mediaUrl } from '@/spa/cmsMediaField';
|
||||
import { useIsMobile } from '@/spa/hooks/useIsMobile';
|
||||
@@ -151,8 +151,7 @@ export default function Preistraeger() {
|
||||
);
|
||||
const winners = useMemo(() => {
|
||||
const fallbackWinner = card.fallbackWinner || preistraegerIndexContent.card.fallbackWinner;
|
||||
if (cmsPreistraeger.length) return cmsPreistraeger.map((doc) => normalizeWinner(doc, placeholderImage, fallbackWinner)).filter((winner) => winner.slug);
|
||||
return WINNERS;
|
||||
return cmsPreistraeger.map((doc) => normalizeWinner(doc, placeholderImage, fallbackWinner)).filter((winner) => winner.slug);
|
||||
}, [card, cmsPreistraeger, placeholderImage]);
|
||||
const periods = useMemo<WinnerPeriod[]>(() => {
|
||||
const annualYears = [...new Set(
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
import React from 'react';
|
||||
import { Link, useParams, Navigate } from '@/spa/router';
|
||||
import { ArrowLeft, Globe, ChevronRight, Download, ChevronLeft, X } from 'lucide-react';
|
||||
import { WINNERS } from '@/spa/data/winners';
|
||||
import { useCmsRoute } from '@/spa/cmsRoute';
|
||||
import { docImageUrl, mediaAlt, mediaUrl } from '@/spa/cmsMediaField';
|
||||
import { useIsMobile } from '@/spa/hooks/useIsMobile';
|
||||
@@ -79,7 +78,7 @@ export default function PreistraegerDetail() {
|
||||
website: String(cmsWinner.website || '#'),
|
||||
hasMedia: Boolean(cmsWinner.hasMedia),
|
||||
detailPage: (cmsWinner.detailPage || {}) as PreistraegerDetailCms,
|
||||
} : WINNERS.find(w => w.slug === slug);
|
||||
} : undefined;
|
||||
const isMobile = useIsMobile();
|
||||
|
||||
const detail = (winner && 'detailPage' in winner ? { ...preistraegerDetailContent, ...(winner.detailPage || {}) } : cmsDetail) as PreistraegerDetailCms;
|
||||
|
||||
@@ -2,9 +2,15 @@ import React from 'react';
|
||||
import { Calendar, MapPin } from 'lucide-react';
|
||||
import MunichSkylineBg from '@/spa/components/ui/munich-skyline-bg';
|
||||
import { useIsMobile } from '@/spa/hooks/useIsMobile';
|
||||
import { useCmsCollection, useCmsRoute, type CmsRouteDoc } from '@/spa/cmsRoute';
|
||||
import { docImageUrl, mediaAlt, mediaUrl } from '@/spa/cmsMediaField';
|
||||
import { useCmsCollection, useCmsRoute } from '@/spa/cmsRoute';
|
||||
import { mediaAlt, mediaUrl } from '@/spa/cmsMediaField';
|
||||
import Image from '@/spa/components/ui/UnoptimizedImage'
|
||||
import {
|
||||
pressEventsFromCms,
|
||||
pressNewsFromCms,
|
||||
type PressEventItem,
|
||||
type PressNewsItem,
|
||||
} from '@/spa/pressCollectionItems';
|
||||
import { pressIndexContent } from '@/spa/pressIndexContent';
|
||||
import { Link } from '@/spa/router';
|
||||
|
||||
@@ -15,14 +21,11 @@ const FB = '"Inter", sans-serif';
|
||||
|
||||
type PressIndexCms = Partial<typeof pressIndexContent> & {
|
||||
hero?: Partial<typeof pressIndexContent.hero> & { image?: unknown }
|
||||
events?: Partial<typeof pressIndexContent.events> & { collectionFallbackImage?: unknown; fallbackItems?: PressEventItem[] }
|
||||
news?: Partial<typeof pressIndexContent.news> & { collectionFallbackImage?: unknown; fallbackItems?: PressNewsItem[] }
|
||||
downloads?: Partial<typeof pressIndexContent.downloads> & { kitFile?: unknown }
|
||||
events?: Partial<typeof pressIndexContent.events> & { collectionFallbackImage?: unknown }
|
||||
news?: Partial<typeof pressIndexContent.news> & { collectionFallbackImage?: unknown }
|
||||
downloads?: Partial<typeof pressIndexContent.downloads> & { contactImage?: unknown }
|
||||
}
|
||||
|
||||
type PressEventItem = (typeof pressIndexContent.events.fallbackItems)[number] & { image?: unknown }
|
||||
type PressNewsItem = (typeof pressIndexContent.news.fallbackItems)[number] & { image?: unknown }
|
||||
|
||||
const fallbackText = (value: unknown, fallback: string) => typeof value === 'string' && value.length > 0 ? value : fallback;
|
||||
const fallbackArray = <T,>(value: unknown, fallback: T[]) => Array.isArray(value) && value.length ? value as T[] : fallback;
|
||||
|
||||
@@ -39,43 +42,6 @@ const statusLabelMap = (items: unknown) =>
|
||||
return labels
|
||||
}, {})
|
||||
|
||||
const normalizeStatus = (status: unknown, labels: Record<string, string>, defaultStatusLabel: string) => {
|
||||
const value = fallbackText(status, defaultStatusLabel)
|
||||
return labels[value] || value
|
||||
}
|
||||
|
||||
const detailPath = (spaPath: unknown, slug: unknown, prefix: '/presse/events' | '/presse/blog') => {
|
||||
const path = fallbackText(spaPath, '')
|
||||
if (path.startsWith(`${prefix}/`)) return path
|
||||
|
||||
const pathSlug = fallbackText(slug, '').replace(/^\/+/, '')
|
||||
return pathSlug ? `${prefix}/${pathSlug}` : prefix
|
||||
}
|
||||
|
||||
const eventFromCms = (
|
||||
event: CmsRouteDoc,
|
||||
fallbackImage: string,
|
||||
labels: Record<string, string>,
|
||||
eventsSection: NonNullable<PressIndexCms['events']>,
|
||||
): PressEventItem => ({
|
||||
title: String(event.title || fallbackText(eventsSection.missingTitleLabel, pressIndexContent.events.missingTitleLabel)),
|
||||
date: String(event.date || fallbackText(eventsSection.missingDateLabel, pressIndexContent.events.missingDateLabel)),
|
||||
location: String(event.location || event.venue || fallbackText(eventsSection.missingLocationLabel, pressIndexContent.events.missingLocationLabel)),
|
||||
cat: String(event.category || fallbackText(eventsSection.missingCategoryLabel, pressIndexContent.events.missingCategoryLabel)),
|
||||
status: normalizeStatus(event.status, labels, fallbackText(eventsSection.defaultStatusLabel, pressIndexContent.events.defaultStatusLabel)),
|
||||
img: docImageUrl(event, fallbackImage),
|
||||
desc: String(event.description || event.meta?.description || ''),
|
||||
slug: detailPath(event.spaPath, event.slug, '/presse/events'),
|
||||
})
|
||||
|
||||
const postFromCms = (post: CmsRouteDoc, fallbackImage: string, newsSection: NonNullable<PressIndexCms['news']>): PressNewsItem => ({
|
||||
title: String(post.title || fallbackText(newsSection.missingTitleLabel, pressIndexContent.news.missingTitleLabel)),
|
||||
excerpt: String(post.excerpt || post.meta?.description || ''),
|
||||
cat: String(post.cat || fallbackText(newsSection.missingCategoryLabel, pressIndexContent.news.missingCategoryLabel)),
|
||||
img: docImageUrl(post, fallbackImage, 'heroImage'),
|
||||
slug: detailPath(post.spaPath, post.slug, '/presse/blog'),
|
||||
})
|
||||
|
||||
function Lines({ text }: { text: string }) {
|
||||
return <>{text.split('\n').map((line, i) => <React.Fragment key={`${line}-${i}`}>{i > 0 && <br />}{line}</React.Fragment>)}</>
|
||||
}
|
||||
@@ -180,28 +146,23 @@ const Press: React.FC = () => {
|
||||
const newsSection = React.useMemo(() => ({ ...pressIndexContent.news, ...(cms.news || {}) }), [cms.news]);
|
||||
const downloads = React.useMemo(() => ({ ...pressIndexContent.downloads, ...(cms.downloads || {}) }), [cms.downloads]);
|
||||
const contactLines = fallbackText(downloads.contactLines, pressIndexContent.downloads.contactLines).split('\n');
|
||||
const contactImageUrl = mediaUrl(downloads.contactImage, '');
|
||||
const contactImageAlt = typeof downloads.contactImageAlt === 'string'
|
||||
? downloads.contactImageAlt
|
||||
: mediaAlt(downloads.contactImage, '');
|
||||
const cmsEvents = useCmsCollection('events');
|
||||
const cmsPosts = useCmsCollection('posts');
|
||||
const eventStatusLabels = React.useMemo(() => statusLabelMap(eventsSection.statusLabels), [eventsSection.statusLabels]);
|
||||
const eventCollectionFallbackImage = mediaUrl(eventsSection.collectionFallbackImage, `/images/${pressIndexContent.events.collectionFallbackImageFilename}`);
|
||||
const newsCollectionFallbackImage = mediaUrl(newsSection.collectionFallbackImage, `/images/${pressIndexContent.news.collectionFallbackImageFilename}`);
|
||||
const combinedEvents = React.useMemo(() => {
|
||||
if (cmsEvents.length) {
|
||||
return cmsEvents.map((event) =>
|
||||
eventFromCms(
|
||||
event,
|
||||
eventCollectionFallbackImage,
|
||||
eventStatusLabels,
|
||||
eventsSection,
|
||||
),
|
||||
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 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]);
|
||||
return (
|
||||
<div className="animate-fade-in">
|
||||
|
||||
@@ -428,13 +389,21 @@ const Press: React.FC = () => {
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* ── 4. PRESSE-MATERIAL & AKKREDITIERUNG ─────────────────────────── */}
|
||||
{/* ── 4. PRESSE-MATERIAL & KONTAKT ────────────────────────────────── */}
|
||||
<section id="downloads" style={{ background: '#fff', overflow: 'hidden', position: 'relative', isolation: 'isolate' }}>
|
||||
<MunichSkylineBg />
|
||||
<div style={{ display: 'grid', gridTemplateColumns: '1fr', minHeight: isMobile ? 'auto' : 600 }}>
|
||||
|
||||
{/* Left col */}
|
||||
<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)',
|
||||
}}
|
||||
>
|
||||
|
||||
{/* Copy comes first in the DOM and visually, including in the mobile stack. */}
|
||||
<div
|
||||
data-testid="press-contact-copy"
|
||||
style={{
|
||||
background: '#fff',
|
||||
padding: isMobile ? '48px 24px' : '88px 80px',
|
||||
@@ -524,6 +493,25 @@ const Press: React.FC = () => {
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{contactImageUrl && (
|
||||
<div
|
||||
data-testid="press-contact-image"
|
||||
style={{
|
||||
minHeight: isMobile ? 320 : 560,
|
||||
position: 'relative',
|
||||
overflow: 'hidden',
|
||||
background: NAVY,
|
||||
}}
|
||||
>
|
||||
<Image
|
||||
unoptimized
|
||||
src={contactImageUrl}
|
||||
alt={contactImageAlt}
|
||||
style={{ width: '100%', height: '100%', position: 'absolute', inset: 0, objectFit: 'cover', display: 'block' }}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
|
||||
@@ -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: 'Initiativbewerbung',
|
||||
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',
|
||||
|
||||
102
src/spa/pressCollectionItems.ts
Normal file
102
src/spa/pressCollectionItems.ts
Normal 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'),
|
||||
}))
|
||||
}
|
||||
@@ -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: '',
|
||||
},
|
||||
}
|
||||
|
||||
59
src/spa/sponsorHierarchy.ts
Normal file
59
src/spa/sponsorHierarchy.ts
Normal 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})`
|
||||
}
|
||||
37
src/utilities/publicationQuery.ts
Normal file
37
src/utilities/publicationQuery.ts
Normal 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,
|
||||
},
|
||||
},
|
||||
}
|
||||
}
|
||||
58
src/utilities/sitemapBaseURL.cjs
Normal file
58
src/utilities/sitemapBaseURL.cjs
Normal 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,
|
||||
}
|
||||
65
src/utilities/sitemapRoutes.ts
Normal file
65
src/utilities/sitemapRoutes.ts
Normal 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,
|
||||
}
|
||||
}
|
||||
@@ -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')
|
||||
})
|
||||
})
|
||||
|
||||
170
tests/int/about-prize.int.spec.tsx
Normal file
170
tests/int/about-prize.int.spec.tsx
Normal 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()
|
||||
})
|
||||
})
|
||||
129
tests/int/about-relevance.int.spec.tsx
Normal file
129
tests/int/about-relevance.int.spec.tsx
Normal 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()
|
||||
})
|
||||
})
|
||||
80
tests/int/application-form-layout.int.spec.tsx
Normal file
80
tests/int/application-form-layout.int.spec.tsx
Normal 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',
|
||||
)
|
||||
})
|
||||
})
|
||||
@@ -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,
|
||||
])
|
||||
})
|
||||
})
|
||||
|
||||
73
tests/int/home-phase-headline.int.spec.tsx
Normal file
73
tests/int/home-phase-headline.int.spec.tsx
Normal 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')
|
||||
})
|
||||
})
|
||||
@@ -18,15 +18,35 @@ const published2025Winners: CmsRouteDoc[] = Array.from({ length: 9 }, (_, index)
|
||||
describe('home winner selection', () => {
|
||||
it('uses a non-empty CMS selection without appending winners from the fallback year', () => {
|
||||
const featured = selected2026Winners.map(({ id }) => id)
|
||||
const winners = resolveHomeWinners(featured, [...selected2026Winners, ...published2025Winners], [])
|
||||
const winners = resolveHomeWinners(featured, [...selected2026Winners, ...published2025Winners])
|
||||
|
||||
expect(winners).toEqual(selected2026Winners)
|
||||
expect(winners.some((winner) => winner.year === 2025)).toBe(false)
|
||||
})
|
||||
|
||||
it('falls back to at most eight 2025 winners when the CMS selection is empty', () => {
|
||||
const winners = resolveHomeWinners([], published2025Winners, [])
|
||||
const winners = resolveHomeWinners([], published2025Winners)
|
||||
|
||||
expect(winners).toEqual(published2025Winners.slice(0, 8))
|
||||
})
|
||||
|
||||
it('omits an unpublished selected winner without changing the remaining page order', () => {
|
||||
const [first, unpublished, third] = selected2026Winners
|
||||
const selection = [third, { ...unpublished, image: { id: 99 } }, first]
|
||||
const publicWinners = resolveHomeWinners(selection, [first, third])
|
||||
const previewWinners = resolveHomeWinners(selection, [first, unpublished, third])
|
||||
|
||||
expect(publicWinners).toEqual([third, first])
|
||||
expect(previewWinners).toEqual([third, unpublished, first])
|
||||
})
|
||||
|
||||
it('does not replace an entirely unpublished CMS selection with fallback winners', () => {
|
||||
const [unpublished] = selected2026Winners
|
||||
|
||||
expect(resolveHomeWinners([{ ...unpublished, image: { id: 99 } }], [])).toEqual([])
|
||||
})
|
||||
|
||||
it('does not resurrect static winners when the published collection is empty', () => {
|
||||
expect(resolveHomeWinners([], [])).toEqual([])
|
||||
})
|
||||
})
|
||||
|
||||
@@ -13,6 +13,15 @@ describe('Netzwerk jury members', () => {
|
||||
expect(selectJuryDocs([3, { id: 1 }], docs).map((member) => member.id)).toEqual([3, 1])
|
||||
expect(selectJuryDocs([], docs)).toEqual([])
|
||||
expect(selectJuryDocs(undefined, docs)).toEqual(docs)
|
||||
expect(selectJuryDocs(undefined, [])).toEqual([])
|
||||
})
|
||||
|
||||
it('omits an unpublished selected member publicly and restores it in preview order', () => {
|
||||
const selection = [{ id: 3 }, { id: 2, name: 'Stale embedded member' }, { id: 1 }]
|
||||
const publicDocs = [docs[0], docs[2]]
|
||||
|
||||
expect(selectJuryDocs(selection, publicDocs).map((member) => member.id)).toEqual([3, 1])
|
||||
expect(selectJuryDocs(selection, docs).map((member) => member.id)).toEqual([3, 2, 1])
|
||||
})
|
||||
|
||||
it('places non-empty quotes first and preserves sort order within each group', () => {
|
||||
|
||||
203
tests/int/netzwerk-sponsor-hierarchy.int.spec.tsx
Normal file
203
tests/int/netzwerk-sponsor-hierarchy.int.spec.tsx
Normal file
@@ -0,0 +1,203 @@
|
||||
import { cleanup, render, screen, within } from '@testing-library/react'
|
||||
import { readFileSync } from 'node:fs'
|
||||
import { join } from 'node:path'
|
||||
import { afterEach, beforeAll, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
import { CmsRouteProvider, type CmsRouteData } from '@/spa/cmsRoute'
|
||||
import { netzwerkContent } from '@/spa/netzwerkContent'
|
||||
import Netzwerk from '@/spa/pages/Netzwerk'
|
||||
import {
|
||||
groupSponsorsByTier,
|
||||
sponsorCardBasis,
|
||||
sponsorColumns,
|
||||
sponsorViewport,
|
||||
} from '@/spa/sponsorHierarchy'
|
||||
|
||||
vi.mock('next/navigation', () => ({
|
||||
usePathname: () => '/netzwerk',
|
||||
useRouter: () => ({ push: vi.fn(), replace: vi.fn() }),
|
||||
}))
|
||||
|
||||
let viewportWidth = 1440
|
||||
|
||||
const partner = (name: string, tier: 1 | 2 | 3, sortOrder: number) => ({
|
||||
id: name.toLowerCase().replace(/\s/g, '-'),
|
||||
stableId: name.toLowerCase().replace(/\s/g, '-'),
|
||||
name,
|
||||
tier,
|
||||
sortOrder,
|
||||
active: true,
|
||||
desc: `${name} description`,
|
||||
fullDesc: `${name} details`,
|
||||
industry: `${name} industry`,
|
||||
location: `${name} location`,
|
||||
website: `${name.toLowerCase()}.example`,
|
||||
})
|
||||
|
||||
const partners = [
|
||||
partner('Tier One B', 1, 200),
|
||||
partner('Tier Three H', 3, 8),
|
||||
partner('Tier Two B', 2, 20),
|
||||
partner('Tier Three A', 3, 1),
|
||||
partner('Tier One A', 1, 100),
|
||||
partner('Tier Three C', 3, 3),
|
||||
partner('Tier Two A', 2, 10),
|
||||
partner('Tier Three B', 3, 2),
|
||||
partner('Tier Three D', 3, 4),
|
||||
partner('Tier Three E', 3, 5),
|
||||
partner('Tier Three F', 3, 6),
|
||||
partner('Tier Three G', 3, 7),
|
||||
]
|
||||
|
||||
const sponsorCopy = {
|
||||
eyebrow: 'CMS sponsor eyebrow',
|
||||
heading: 'CMS sponsor heading',
|
||||
description: 'CMS sponsor supporting copy',
|
||||
detailLabel: 'CMS sponsor action',
|
||||
tiers: [
|
||||
{ tier: 1, label: 'CMS feature tier', note: 'CMS feature tier note' },
|
||||
{ tier: 2, label: 'CMS standard tier', note: 'CMS standard tier note' },
|
||||
{ tier: 3, label: 'CMS compact tier', note: 'CMS compact tier note' },
|
||||
],
|
||||
}
|
||||
|
||||
const routeData = (): CmsRouteData => ({
|
||||
collection: 'pages',
|
||||
doc: {
|
||||
id: 'network-page',
|
||||
slug: 'netzwerk',
|
||||
spaPath: '/netzwerk',
|
||||
netzwerk: { partners: sponsorCopy },
|
||||
},
|
||||
lists: {
|
||||
partners,
|
||||
'jury-mitglieder': [],
|
||||
},
|
||||
})
|
||||
|
||||
function renderNetwork(width: number) {
|
||||
viewportWidth = width
|
||||
return render(
|
||||
<CmsRouteProvider value={routeData()}>
|
||||
<Netzwerk />
|
||||
</CmsRouteProvider>,
|
||||
)
|
||||
}
|
||||
|
||||
describe('responsive sponsor hierarchy', () => {
|
||||
beforeAll(() => {
|
||||
Object.defineProperty(window, 'matchMedia', {
|
||||
configurable: true,
|
||||
value: vi.fn().mockImplementation((query: string) => {
|
||||
const maximum = Number(query.match(/max-width:\s*(\d+)px/)?.[1] || Number.POSITIVE_INFINITY)
|
||||
return {
|
||||
addEventListener: vi.fn(),
|
||||
matches: viewportWidth <= maximum,
|
||||
media: query,
|
||||
removeEventListener: vi.fn(),
|
||||
}
|
||||
}),
|
||||
})
|
||||
})
|
||||
|
||||
afterEach(() => cleanup())
|
||||
|
||||
it('keeps tiers separate and applies Partner sortOrder only within each tier', () => {
|
||||
const grouped = groupSponsorsByTier(partners)
|
||||
|
||||
expect(grouped[1].map(({ name }) => name)).toEqual(['Tier One A', 'Tier One B'])
|
||||
expect(grouped[2].map(({ name }) => name)).toEqual(['Tier Two A', 'Tier Two B'])
|
||||
expect(grouped[3].map(({ name }) => name)).toEqual([
|
||||
'Tier Three A',
|
||||
'Tier Three B',
|
||||
'Tier Three C',
|
||||
'Tier Three D',
|
||||
'Tier Three E',
|
||||
'Tier Three F',
|
||||
'Tier Three G',
|
||||
'Tier Three H',
|
||||
])
|
||||
expect([...grouped[1], ...grouped[2], ...grouped[3]].map(({ name }) => name)).toEqual([
|
||||
'Tier One A',
|
||||
'Tier One B',
|
||||
'Tier Two A',
|
||||
'Tier Two B',
|
||||
'Tier Three A',
|
||||
'Tier Three B',
|
||||
'Tier Three C',
|
||||
'Tier Three D',
|
||||
'Tier Three E',
|
||||
'Tier Three F',
|
||||
'Tier Three G',
|
||||
'Tier Three H',
|
||||
])
|
||||
})
|
||||
|
||||
it('defines the approved columns and conservative narrow-mobile fallback', () => {
|
||||
expect([1, 2, 3].map((tier) => sponsorColumns(tier as 1 | 2 | 3, 'desktop'))).toEqual([2, 3, 4])
|
||||
expect([1, 2, 3].map((tier) => sponsorColumns(tier as 1 | 2 | 3, 'tablet'))).toEqual([2, 2, 3])
|
||||
expect([1, 2, 3].map((tier) => sponsorColumns(tier as 1 | 2 | 3, 'mobile'))).toEqual([1, 1, 2])
|
||||
expect([1, 2, 3].map((tier) => sponsorColumns(tier as 1 | 2 | 3, 'narrow-mobile'))).toEqual([
|
||||
1, 1, 1,
|
||||
])
|
||||
expect(
|
||||
sponsorViewport({ isMobile: false, isNarrowMobile: false, isTabletOrMobile: true }),
|
||||
).toBe('tablet')
|
||||
expect(sponsorCardBasis(3, 16)).toBe('calc((100% - 32px) / 3)')
|
||||
})
|
||||
|
||||
it('renders every visible sponsor-section label from Netzwerk CMS fields', () => {
|
||||
renderNetwork(1440)
|
||||
|
||||
expect(screen.getByText(sponsorCopy.eyebrow)).toBeTruthy()
|
||||
expect(screen.getByText(sponsorCopy.heading)).toBeTruthy()
|
||||
expect(screen.getByText(sponsorCopy.description)).toBeTruthy()
|
||||
sponsorCopy.tiers.forEach(({ label, note }) => {
|
||||
expect(screen.getByRole('heading', { name: label })).toBeTruthy()
|
||||
expect(screen.getByText(note)).toBeTruthy()
|
||||
})
|
||||
expect(screen.getAllByText(sponsorCopy.detailLabel)).toHaveLength(partners.length)
|
||||
expect(screen.queryByText(netzwerkContent.partners.description)).toBeNull()
|
||||
netzwerkContent.partners.tiers.forEach(({ label }) =>
|
||||
expect(screen.queryByText(label)).toBeNull(),
|
||||
)
|
||||
})
|
||||
|
||||
it.each([
|
||||
{ width: 1440, viewport: 'desktop', columns: [2, 3, 4] },
|
||||
{ width: 1024, viewport: 'tablet', columns: [2, 2, 3] },
|
||||
{ width: 390, viewport: 'mobile', columns: [1, 1, 2] },
|
||||
{ width: 340, viewport: 'narrow-mobile', columns: [1, 1, 1] },
|
||||
])('centers independent bands at $width px', ({ width, viewport, columns }) => {
|
||||
renderNetwork(width)
|
||||
|
||||
columns.forEach((columnCount, index) => {
|
||||
const tier = index + 1
|
||||
const band = screen.getByTestId(`sponsor-tier-${tier}`)
|
||||
const cards = screen.getByTestId(`sponsor-tier-${tier}-cards`)
|
||||
expect(band.getAttribute('data-columns')).toBe(String(columnCount))
|
||||
expect(band.getAttribute('data-viewport')).toBe(viewport)
|
||||
expect(cards.style.display).toBe('flex')
|
||||
expect(cards.style.flexWrap).toBe('wrap')
|
||||
expect(cards.style.justifyContent).toBe('center')
|
||||
expect(within(band).getAllByRole('button')).toHaveLength(tier === 3 ? 8 : 2)
|
||||
})
|
||||
})
|
||||
|
||||
it('keeps sponsor defaults and legacy labels out of the rendered component source', () => {
|
||||
const source = readFileSync(join(process.cwd(), 'src/spa/pages/Netzwerk.tsx'), 'utf8')
|
||||
const visibleDefaults = [
|
||||
netzwerkContent.partners.eyebrow,
|
||||
netzwerkContent.partners.heading,
|
||||
netzwerkContent.partners.description,
|
||||
netzwerkContent.partners.detailLabel,
|
||||
...netzwerkContent.partners.tiers.map(({ label }) => label),
|
||||
'Partner in drei Kategorien: Hauptsponsoren, Medienpartner und weitere Sponsoren. Klicken für Details.',
|
||||
]
|
||||
|
||||
visibleDefaults.forEach((copy) => {
|
||||
expect(source).not.toContain(`'${copy}'`)
|
||||
expect(source).not.toContain(`"${copy}"`)
|
||||
})
|
||||
})
|
||||
})
|
||||
120
tests/int/netzwerk-video.int.spec.tsx
Normal file
120
tests/int/netzwerk-video.int.spec.tsx
Normal file
@@ -0,0 +1,120 @@
|
||||
import { cleanup, fireEvent, render, screen } from '@testing-library/react'
|
||||
import { afterEach, beforeAll, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
import { CmsRouteProvider, type CmsRouteData, type CmsRouteDoc } from '@/spa/cmsRoute'
|
||||
import { netzwerkContent } from '@/spa/netzwerkContent'
|
||||
import Netzwerk from '@/spa/pages/Netzwerk'
|
||||
|
||||
vi.mock('next/navigation', () => ({
|
||||
usePathname: () => '/netzwerk',
|
||||
useRouter: () => ({ push: vi.fn(), replace: vi.fn() }),
|
||||
}))
|
||||
|
||||
const homePage = (videoUrl: string): CmsRouteDoc => ({
|
||||
id: `home-${videoUrl}`,
|
||||
slug: 'startseite',
|
||||
spaPath: '/',
|
||||
home: {
|
||||
hero: { videoLabel: 'Homepage film' },
|
||||
videoModal: {
|
||||
video: { url: videoUrl },
|
||||
title: 'Homepage video',
|
||||
closeLabel: 'Homepage video close',
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
const networkRoute = (video?: { url: string; label: string; title: string }): CmsRouteData => ({
|
||||
collection: 'pages',
|
||||
doc: {
|
||||
id: 'network-page',
|
||||
slug: 'netzwerk',
|
||||
spaPath: '/netzwerk',
|
||||
netzwerk: {
|
||||
patronage: {
|
||||
...(video
|
||||
? {
|
||||
greetingVideo: { url: video.url },
|
||||
greetingVideoButtonLabel: video.label,
|
||||
greetingVideoTitle: video.title,
|
||||
greetingVideoDescription: `${video.title} description`,
|
||||
greetingVideoCloseLabel: `${video.title} close`,
|
||||
}
|
||||
: {}),
|
||||
},
|
||||
},
|
||||
},
|
||||
lists: {
|
||||
pages: [homePage('/api/media/file/homepage.mp4')],
|
||||
partners: [],
|
||||
'jury-mitglieder': [],
|
||||
},
|
||||
})
|
||||
|
||||
describe('Netzwerk greeting video', () => {
|
||||
beforeAll(() => {
|
||||
Object.defineProperty(window, 'matchMedia', {
|
||||
configurable: true,
|
||||
value: vi.fn().mockImplementation((query: string) => ({
|
||||
addEventListener: vi.fn(),
|
||||
matches: false,
|
||||
media: query,
|
||||
removeEventListener: vi.fn(),
|
||||
})),
|
||||
})
|
||||
vi.spyOn(HTMLMediaElement.prototype, 'play').mockResolvedValue(undefined)
|
||||
})
|
||||
|
||||
afterEach(() => cleanup())
|
||||
|
||||
it('does not fall back to the homepage video when Netzwerk media is empty', () => {
|
||||
render(
|
||||
<CmsRouteProvider value={networkRoute()}>
|
||||
<Netzwerk />
|
||||
</CmsRouteProvider>,
|
||||
)
|
||||
|
||||
expect(screen.queryByRole('button', { name: netzwerkContent.patronage.greetingVideoButtonLabel })).toBeNull()
|
||||
expect(screen.queryByLabelText('Homepage video')).toBeNull()
|
||||
})
|
||||
|
||||
it('resolves the page-owned Netzwerk media independently of homepage changes', () => {
|
||||
const firstNetworkVideo = {
|
||||
url: '/api/media/file/network-first.mp4',
|
||||
label: 'Play Netzwerk greeting',
|
||||
title: 'Netzwerk greeting one',
|
||||
}
|
||||
const secondNetworkVideo = {
|
||||
url: '/api/media/file/network-second.mp4',
|
||||
label: 'Play second Netzwerk greeting',
|
||||
title: 'Netzwerk greeting two',
|
||||
}
|
||||
const { rerender } = render(
|
||||
<CmsRouteProvider value={networkRoute(firstNetworkVideo)}>
|
||||
<Netzwerk />
|
||||
</CmsRouteProvider>,
|
||||
)
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: firstNetworkVideo.label }))
|
||||
expect(screen.getByRole('dialog').querySelector('video')?.getAttribute('src')).toBe(firstNetworkVideo.url)
|
||||
|
||||
const homeChanged = networkRoute(firstNetworkVideo)
|
||||
homeChanged.lists = {
|
||||
...homeChanged.lists,
|
||||
pages: [homePage('/api/media/file/homepage-changed.mp4')],
|
||||
}
|
||||
rerender(
|
||||
<CmsRouteProvider value={homeChanged}>
|
||||
<Netzwerk />
|
||||
</CmsRouteProvider>,
|
||||
)
|
||||
expect(screen.getByRole('dialog').querySelector('video')?.getAttribute('src')).toBe(firstNetworkVideo.url)
|
||||
|
||||
rerender(
|
||||
<CmsRouteProvider value={networkRoute(secondNetworkVideo)}>
|
||||
<Netzwerk />
|
||||
</CmsRouteProvider>,
|
||||
)
|
||||
expect(screen.getByRole('dialog').querySelector('video')?.getAttribute('src')).toBe(secondNetworkVideo.url)
|
||||
})
|
||||
})
|
||||
157
tests/int/participation-eligibility.int.spec.tsx
Normal file
157
tests/int/participation-eligibility.int.spec.tsx
Normal file
@@ -0,0 +1,157 @@
|
||||
import { cleanup, render, screen, within } from '@testing-library/react'
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
import { CmsRouteProvider, type CmsRouteData } from '@/spa/cmsRoute'
|
||||
import Participation from '@/spa/pages/Participation'
|
||||
|
||||
const viewport = vi.hoisted(() => ({ mobile: false }))
|
||||
|
||||
vi.mock('@/spa/hooks/useIsMobile', () => ({
|
||||
useIsMobile: () => viewport.mobile,
|
||||
}))
|
||||
|
||||
vi.mock('next/navigation', () => ({
|
||||
usePathname: () => '/teilnahme',
|
||||
useRouter: () => ({ push: vi.fn(), replace: vi.fn() }),
|
||||
}))
|
||||
|
||||
vi.mock('@/spa/components/WegZurAuszeichnungSection', () => ({
|
||||
default: () => <section data-testid="timeline-stub" />,
|
||||
}))
|
||||
|
||||
vi.mock('@/spa/components/AwardsGridSection', () => ({
|
||||
default: () => <section data-testid="awards-stub" />,
|
||||
}))
|
||||
|
||||
vi.mock('@/spa/components/forms/ApplicationFormSection', () => ({
|
||||
ApplicationFormSection: () => <section data-testid="application-form-stub" />,
|
||||
}))
|
||||
|
||||
vi.mock('@/spa/components/ui/UnoptimizedImage', () => ({
|
||||
default: ({ alt }: { alt: string }) => <div role="img" aria-label={alt} />,
|
||||
}))
|
||||
|
||||
const participationRoute: CmsRouteData = {
|
||||
collection: 'pages',
|
||||
doc: {
|
||||
id: 'participation-page',
|
||||
slug: 'teilnahme',
|
||||
spaPath: '/teilnahme',
|
||||
participation: {
|
||||
eligibility: {
|
||||
eyebrow: 'CMS eligibility eyebrow',
|
||||
heading: 'CMS ELIGIBILITY\nHEADING',
|
||||
description: 'CMS eligibility introduction.',
|
||||
primaryCta: { label: 'CMS eligibility CTA', url: '/kontakt' },
|
||||
criteria: [
|
||||
{
|
||||
num: 'A',
|
||||
title: 'First CMS criterion',
|
||||
body: 'First CMS criterion body.',
|
||||
icon: 'mapPin',
|
||||
},
|
||||
{
|
||||
num: 'B',
|
||||
title: 'Second CMS criterion',
|
||||
body: 'Second CMS criterion body.',
|
||||
icon: 'users',
|
||||
},
|
||||
],
|
||||
notes: [
|
||||
{ title: 'First CMS note', body: 'First CMS note body.', icon: 'building2' },
|
||||
{ title: 'Second CMS note', body: 'Second CMS note body.', icon: 'userCheck' },
|
||||
],
|
||||
nudgeText: 'CMS eligibility conclusion.',
|
||||
},
|
||||
datesBanner: {
|
||||
heading: 'CMS important dates',
|
||||
description: 'CMS dates description.',
|
||||
mobileItems: [{ date: 'Mobile date', label: 'Mobile label' }],
|
||||
desktopItems: [{ date: 'Desktop date', label: 'Desktop label' }],
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
const renderParticipation = () =>
|
||||
render(
|
||||
<CmsRouteProvider value={participationRoute}>
|
||||
<Participation />
|
||||
</CmsRouteProvider>,
|
||||
)
|
||||
|
||||
describe('participation eligibility hierarchy', () => {
|
||||
beforeEach(() => {
|
||||
viewport.mobile = false
|
||||
})
|
||||
|
||||
afterEach(() => cleanup())
|
||||
|
||||
it('keeps CMS eligibility content and its CTA authoritative inside semantic groups', () => {
|
||||
renderParticipation()
|
||||
|
||||
const section = screen.getByTestId('eligibility-section')
|
||||
const criteria = within(section).getAllByTestId('eligibility-criterion')
|
||||
const notes = within(section).getAllByTestId('eligibility-note')
|
||||
const cta = within(section).getByRole('link', { name: 'CMS eligibility CTA' })
|
||||
|
||||
expect(within(section).getByRole('heading', { level: 2 }).textContent).toBe(
|
||||
'CMS ELIGIBILITYHEADING',
|
||||
)
|
||||
expect(within(section).getByText('CMS eligibility introduction.')).toBeTruthy()
|
||||
expect(criteria.map((row) => row.textContent)).toEqual([
|
||||
'AFirst CMS criterionFirst CMS criterion body.',
|
||||
'BSecond CMS criterionSecond CMS criterion body.',
|
||||
])
|
||||
expect(criteria.every((row) => row.tagName === 'LI')).toBe(true)
|
||||
expect(criteria[0]?.parentElement?.tagName).toBe('OL')
|
||||
expect(notes.map((note) => note.textContent)).toEqual([
|
||||
'First CMS noteFirst CMS note body.',
|
||||
'Second CMS noteSecond CMS note body.',
|
||||
])
|
||||
expect(within(section).getByText('CMS eligibility conclusion.')).toBeTruthy()
|
||||
expect(cta.getAttribute('href')).toBe('/kontakt')
|
||||
expect(within(section).getAllByRole('link')).toHaveLength(1)
|
||||
})
|
||||
|
||||
it('provides restrained group and dates-transition hooks on desktop', () => {
|
||||
renderParticipation()
|
||||
|
||||
expect(screen.getByTestId('eligibility-header').style.background).toBe('rgb(255, 255, 255)')
|
||||
expect(screen.getByTestId('eligibility-criteria-group').style.background).toBe(
|
||||
'rgb(244, 246, 248)',
|
||||
)
|
||||
expect(screen.getByTestId('eligibility-summary').style.background).toBe('rgb(17, 29, 85)')
|
||||
expect(screen.getByTestId('important-dates-transition').style.borderTopWidth).toBe('8px')
|
||||
expect(screen.getByRole('heading', { name: 'CMS important dates', level: 3 })).toBeTruthy()
|
||||
expect(screen.getByText('Desktop date')).toBeTruthy()
|
||||
expect(screen.queryByText('Mobile date')).toBeNull()
|
||||
})
|
||||
|
||||
it('keeps criteria, notes, and conclusion in a single readable mobile order', () => {
|
||||
viewport.mobile = true
|
||||
renderParticipation()
|
||||
|
||||
const section = screen.getByTestId('eligibility-section')
|
||||
const criteriaGroup = within(section).getByTestId('eligibility-criteria-group')
|
||||
const notesGroup = within(section).getByTestId('eligibility-notes-group')
|
||||
const summary = within(section).getByTestId('eligibility-summary')
|
||||
const rows = within(criteriaGroup).getAllByTestId('eligibility-criterion')
|
||||
|
||||
expect(rows.map((row) => row.getAttribute('data-order'))).toEqual(['1', '2'])
|
||||
expect(rows.every((row) => row.style.gridTemplateColumns === '40px 1fr')).toBe(true)
|
||||
expect(
|
||||
criteriaGroup.compareDocumentPosition(notesGroup) & Node.DOCUMENT_POSITION_FOLLOWING,
|
||||
).toBeTruthy()
|
||||
expect(
|
||||
notesGroup.compareDocumentPosition(summary) & Node.DOCUMENT_POSITION_FOLLOWING,
|
||||
).toBeTruthy()
|
||||
expect(screen.getByTestId('important-dates-transition').style.borderTopWidth).toBe('5px')
|
||||
expect(screen.getByTestId('important-date-item').getAttribute('data-order')).toBe('1')
|
||||
expect(screen.getByTestId('important-date-item').style.gridTemplateColumns).toBe(
|
||||
'minmax(112px, 0.9fr) minmax(0, 1.1fr)',
|
||||
)
|
||||
expect(screen.getByText('Mobile date')).toBeTruthy()
|
||||
expect(screen.queryByText('Desktop date')).toBeNull()
|
||||
})
|
||||
})
|
||||
91
tests/int/participation-timeline.int.spec.tsx
Normal file
91
tests/int/participation-timeline.int.spec.tsx
Normal file
@@ -0,0 +1,91 @@
|
||||
import { cleanup, fireEvent, render, screen } from '@testing-library/react'
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
import WegZurAuszeichnungSection from '@/spa/components/WegZurAuszeichnungSection'
|
||||
import {
|
||||
getActiveTimelineStep,
|
||||
getTimelineScrollBehavior,
|
||||
getTimelineStepScrollLeft,
|
||||
} from '@/spa/components/ui/process-timeline'
|
||||
|
||||
vi.mock('@/spa/hooks/useIsMobile', () => ({
|
||||
useIsMobile: () => false,
|
||||
}))
|
||||
|
||||
let reduceMotion = false
|
||||
|
||||
describe('participation timeline', () => {
|
||||
beforeEach(() => {
|
||||
reduceMotion = false
|
||||
Object.defineProperty(window, 'matchMedia', {
|
||||
configurable: true,
|
||||
value: vi.fn().mockImplementation((query: string) => ({
|
||||
addEventListener: vi.fn(),
|
||||
matches: query === '(prefers-reduced-motion: reduce)' && reduceMotion,
|
||||
media: query,
|
||||
removeEventListener: vi.fn(),
|
||||
})),
|
||||
})
|
||||
})
|
||||
|
||||
afterEach(() => cleanup())
|
||||
|
||||
it('maps the real horizontal scroll range to deterministic steps', () => {
|
||||
expect(getActiveTimelineStep(0, 600, 4)).toBe(0)
|
||||
expect(getActiveTimelineStep(200, 600, 4)).toBe(1)
|
||||
expect(getActiveTimelineStep(400, 600, 4)).toBe(2)
|
||||
expect(getActiveTimelineStep(600, 600, 4)).toBe(3)
|
||||
expect(getTimelineStepScrollLeft(2, 600, 4)).toBe(400)
|
||||
expect(getTimelineStepScrollLeft(99, 600, 4)).toBe(600)
|
||||
})
|
||||
|
||||
it('updates progress from native track scroll state', () => {
|
||||
render(<WegZurAuszeichnungSection />)
|
||||
const track = screen.getByTestId('timeline-track')
|
||||
Object.defineProperties(track, {
|
||||
clientWidth: { configurable: true, value: 400 },
|
||||
scrollLeft: { configurable: true, value: 400, writable: true },
|
||||
scrollWidth: { configurable: true, value: 1000 },
|
||||
})
|
||||
|
||||
fireEvent.scroll(track)
|
||||
|
||||
expect(screen.getByTestId('timeline-progress-label').textContent).toContain('03 / 04')
|
||||
expect(screen.getByTestId('timeline-progress').style.width).toBe('75%')
|
||||
expect(screen.getAllByTestId(/timeline-step-/)).toHaveLength(4)
|
||||
})
|
||||
|
||||
it('uses immediate scrolling in reduced-motion mode while keeping controls operable', () => {
|
||||
reduceMotion = true
|
||||
render(<WegZurAuszeichnungSection />)
|
||||
const track = screen.getByTestId('timeline-track')
|
||||
const scrollTo = vi.fn()
|
||||
Object.defineProperties(track, {
|
||||
clientWidth: { configurable: true, value: 400 },
|
||||
scrollTo: { configurable: true, value: scrollTo },
|
||||
scrollWidth: { configurable: true, value: 1000 },
|
||||
})
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Nächster Schritt' }))
|
||||
|
||||
expect(getTimelineScrollBehavior(true)).toBe('auto')
|
||||
expect(scrollTo).toHaveBeenCalledWith({ behavior: 'auto', left: 200 })
|
||||
expect(screen.getByTestId('timeline-progress-label').textContent).toContain('02 / 04')
|
||||
})
|
||||
|
||||
it('supports direct keyboard navigation across the track', () => {
|
||||
render(<WegZurAuszeichnungSection />)
|
||||
const track = screen.getByTestId('timeline-track')
|
||||
const scrollTo = vi.fn()
|
||||
Object.defineProperties(track, {
|
||||
clientWidth: { configurable: true, value: 400 },
|
||||
scrollTo: { configurable: true, value: scrollTo },
|
||||
scrollWidth: { configurable: true, value: 1000 },
|
||||
})
|
||||
|
||||
fireEvent.keyDown(track, { key: 'End' })
|
||||
|
||||
expect(scrollTo).toHaveBeenCalledWith({ behavior: 'smooth', left: 600 })
|
||||
expect(screen.getByTestId('timeline-progress-label').textContent).toContain('04 / 04')
|
||||
})
|
||||
})
|
||||
73
tests/int/partner-ticker.int.spec.tsx
Normal file
73
tests/int/partner-ticker.int.spec.tsx
Normal file
@@ -0,0 +1,73 @@
|
||||
import { cleanup, fireEvent, render, screen, within } from '@testing-library/react'
|
||||
import { afterEach, describe, expect, it } from 'vitest'
|
||||
|
||||
import {
|
||||
PARTNER_TICKER_ITEM_WIDTH_PX,
|
||||
PARTNER_TICKER_MIN_DURATION_SECONDS,
|
||||
PARTNER_TICKER_SPEED_PX_PER_SECOND,
|
||||
PartnerTicker,
|
||||
partnerTickerDurationSeconds,
|
||||
} from '@/spa/components/ui/partner-ticker'
|
||||
|
||||
const logos = ['RSM', 'WWK', 'münchen.tv'].map((name) => ({
|
||||
id: name,
|
||||
label: <span>{name}</span>,
|
||||
}))
|
||||
|
||||
describe('homepage partner ticker', () => {
|
||||
afterEach(() => cleanup())
|
||||
|
||||
it('derives a calm duration from the exact single-copy track length', () => {
|
||||
expect(partnerTickerDurationSeconds(0)).toBe(0)
|
||||
expect(partnerTickerDurationSeconds(1)).toBe(PARTNER_TICKER_MIN_DURATION_SECONDS)
|
||||
expect(partnerTickerDurationSeconds(12)).toBe(
|
||||
(12 * PARTNER_TICKER_ITEM_WIDTH_PX) / PARTNER_TICKER_SPEED_PX_PER_SECOND,
|
||||
)
|
||||
expect(partnerTickerDurationSeconds(12)).toBe(65)
|
||||
})
|
||||
|
||||
it('duplicates one identical track and translates by exactly one copy', () => {
|
||||
render(<PartnerTicker ariaLabel="Unsere Partner" logos={logos} />)
|
||||
|
||||
const copies = screen.getAllByTestId('partner-ticker-copy')
|
||||
expect(copies).toHaveLength(2)
|
||||
expect(copies[1].getAttribute('aria-hidden')).toBe('true')
|
||||
expect(within(copies[0]).getAllByText(/RSM|WWK|münchen.tv/)).toHaveLength(logos.length)
|
||||
expect(within(copies[1]).getAllByText(/RSM|WWK|münchen.tv/)).toHaveLength(logos.length)
|
||||
|
||||
const ticker = screen.getByTestId('partner-ticker')
|
||||
const styles = ticker.querySelector('style')?.textContent || ''
|
||||
expect(styles).toContain('to { transform: translateX(-50%); }')
|
||||
})
|
||||
|
||||
it('pauses for pointer hover and keyboard focus within the ticker', () => {
|
||||
render(<PartnerTicker ariaLabel="Unsere Partner" logos={logos} />)
|
||||
|
||||
const ticker = screen.getByTestId('partner-ticker')
|
||||
const track = screen.getByTestId('partner-ticker-track')
|
||||
expect(track.style.animationPlayState).toBe('running')
|
||||
|
||||
fireEvent.mouseEnter(ticker)
|
||||
expect(track.style.animationPlayState).toBe('paused')
|
||||
fireEvent.mouseLeave(ticker)
|
||||
expect(track.style.animationPlayState).toBe('running')
|
||||
|
||||
fireEvent.focus(ticker)
|
||||
expect(track.style.animationPlayState).toBe('paused')
|
||||
fireEvent.blur(ticker)
|
||||
expect(track.style.animationPlayState).toBe('running')
|
||||
})
|
||||
|
||||
it('provides a non-animated, horizontally accessible reduced-motion presentation', () => {
|
||||
render(<PartnerTicker ariaLabel="Unsere Partner" logos={logos} />)
|
||||
|
||||
const ticker = screen.getByTestId('partner-ticker')
|
||||
const styles = ticker.querySelector('style')?.textContent || ''
|
||||
expect(ticker.getAttribute('tabindex')).toBe('0')
|
||||
expect(styles).toContain('@media (prefers-reduced-motion: reduce)')
|
||||
expect(styles).toContain('overflow-x: auto !important')
|
||||
expect(styles).toContain('animation: none !important')
|
||||
expect(styles).toContain('.partner-ticker-copy[aria-hidden="true"]')
|
||||
expect(styles).toContain('display: none !important')
|
||||
})
|
||||
})
|
||||
48
tests/int/partners-admin-ordering.int.spec.ts
Normal file
48
tests/int/partners-admin-ordering.int.spec.ts
Normal file
@@ -0,0 +1,48 @@
|
||||
import type { Field, NumberField, NumberFieldSingleValidation } from 'payload'
|
||||
import { describe, expect, it } from 'vitest'
|
||||
|
||||
import { Partners } from '@/collections/Partners'
|
||||
|
||||
function partnerField(name: string): Field {
|
||||
const field = Partners.fields.find((candidate) => 'name' in candidate && candidate.name === name)
|
||||
if (!field) throw new Error(`Missing Partners field: ${name}`)
|
||||
return field
|
||||
}
|
||||
|
||||
describe('Partners admin ordering contract', () => {
|
||||
it('shows the title and ordering state needed for content operations', () => {
|
||||
expect(Partners.admin?.useAsTitle).toBe('name')
|
||||
expect(Partners.admin?.defaultColumns).toEqual([
|
||||
'name',
|
||||
'tier',
|
||||
'sortOrder',
|
||||
'_status',
|
||||
'active',
|
||||
'showOnHomepage',
|
||||
'updatedAt',
|
||||
])
|
||||
})
|
||||
|
||||
it('accepts only numeric tiers 1, 2, and 3', async () => {
|
||||
const tierField = partnerField('tier')
|
||||
|
||||
expect(tierField).toMatchObject({
|
||||
type: 'number',
|
||||
min: 1,
|
||||
max: 3,
|
||||
required: true,
|
||||
admin: { step: 1 },
|
||||
})
|
||||
|
||||
const validate = (tierField as NumberField).validate as NumberFieldSingleValidation | undefined
|
||||
expect(validate).toBeTypeOf('function')
|
||||
|
||||
for (const value of [1, 2, 3]) {
|
||||
expect(await validate?.(value, {} as never)).toBe(true)
|
||||
}
|
||||
|
||||
for (const value of [0, 1.5, 4, Number.NaN, null, undefined]) {
|
||||
expect(await validate?.(value, {} as never)).toBe('Tier must be one of 1, 2, or 3.')
|
||||
}
|
||||
})
|
||||
})
|
||||
185
tests/int/payload-field-inventory.int.spec.ts
Normal file
185
tests/int/payload-field-inventory.int.spec.ts
Normal file
@@ -0,0 +1,185 @@
|
||||
import type { Field } from 'payload'
|
||||
import { describe, expect, it } from 'vitest'
|
||||
|
||||
import { ApplicationForm } from '@/ApplicationForm/config'
|
||||
import { ApplicationPhase } from '@/ApplicationPhase/config'
|
||||
import { Footer } from '@/Footer/config'
|
||||
import { Header } from '@/Header/config'
|
||||
import { NewsletterForm } from '@/NewsletterForm/config'
|
||||
import { SiteSettings } from '@/SiteSettings/config'
|
||||
import { aboutFields } from '@/collections/Pages/aboutFields'
|
||||
import { homeFields } from '@/collections/Pages/homeFields'
|
||||
import { netzwerkFields } from '@/collections/Pages/netzwerkFields'
|
||||
import { participationFields } from '@/collections/Pages/participationFields'
|
||||
import { pressIndexFields } from '@/collections/Pages/pressIndexFields'
|
||||
|
||||
function fieldPaths(fields: Field[], prefix = ''): string[] {
|
||||
return fields.flatMap((field) => {
|
||||
if (!('name' in field) || typeof field.name !== 'string') {
|
||||
return 'fields' in field && Array.isArray(field.fields) ? fieldPaths(field.fields, prefix) : []
|
||||
}
|
||||
|
||||
const path = prefix ? `${prefix}.${field.name}` : field.name
|
||||
const children = 'fields' in field && Array.isArray(field.fields) ? fieldPaths(field.fields, path) : []
|
||||
return [path, ...children]
|
||||
})
|
||||
}
|
||||
|
||||
function findField(fields: Field[], path: string): Field | undefined {
|
||||
const [name, ...rest] = path.split('.')
|
||||
const field = fields.find((candidate) => 'name' in candidate && candidate.name === name)
|
||||
if (!field || rest.length === 0) return field
|
||||
if (!('fields' in field) || !Array.isArray(field.fields)) return undefined
|
||||
return findField(field.fields, rest.join('.'))
|
||||
}
|
||||
|
||||
describe('Payload field inventory', () => {
|
||||
it('keeps obsolete page controls out of the schema', () => {
|
||||
const paths = [
|
||||
...fieldPaths(homeFields),
|
||||
...fieldPaths(participationFields),
|
||||
...fieldPaths(aboutFields),
|
||||
...fieldPaths(pressIndexFields),
|
||||
...fieldPaths(netzwerkFields),
|
||||
]
|
||||
|
||||
expect(paths).not.toEqual(
|
||||
expect.arrayContaining([
|
||||
'home.application',
|
||||
'home.form',
|
||||
'participation.applicationForm',
|
||||
'participation.form',
|
||||
'participation.eligibility.secondaryCta',
|
||||
'participation.eligibility.nudgeCta',
|
||||
'participation.applicationWays.recommendedLabel',
|
||||
'participation.applicationWays.fallbackCta',
|
||||
'participation.applicationWays.ways.featured',
|
||||
'participation.applicationWays.ways.cta',
|
||||
'about.highlights',
|
||||
'pressIndex.events.detailLabel',
|
||||
'pressIndex.news.readMoreLabel',
|
||||
'pressIndex.downloads.kitLabel',
|
||||
'pressIndex.downloads.kitMeta',
|
||||
'pressIndex.downloads.kitFile',
|
||||
'pressIndex.downloads.kitUrl',
|
||||
'pressIndex.downloads.kitDownloadName',
|
||||
'pressIndex.accreditation',
|
||||
'netzwerk.jury.note',
|
||||
'netzwerk.partners.premiumLabel',
|
||||
'netzwerk.partners.defaultLogoColor',
|
||||
'netzwerk.sponsoring.footnotePrefix',
|
||||
'netzwerk.sponsoring.footnoteLabel',
|
||||
'netzwerk.sponsoring.footnoteUrl',
|
||||
'netzwerk.sponsoring.formEyebrow',
|
||||
'netzwerk.sponsoringForm.packages',
|
||||
'netzwerk.sponsoringForm.step1Eyebrow',
|
||||
'netzwerk.sponsoringForm.step1Heading',
|
||||
'netzwerk.sponsoringForm.requiredPackageError',
|
||||
]),
|
||||
)
|
||||
})
|
||||
|
||||
it('retains rendered page controls and authoritative relationships', () => {
|
||||
const paths = [
|
||||
...fieldPaths(homeFields),
|
||||
...fieldPaths(participationFields),
|
||||
...fieldPaths(aboutFields),
|
||||
...fieldPaths(pressIndexFields),
|
||||
...fieldPaths(netzwerkFields),
|
||||
]
|
||||
|
||||
expect(paths).toEqual(
|
||||
expect.arrayContaining([
|
||||
'home.hero',
|
||||
'home.winners.featured',
|
||||
'home.videoModal.video',
|
||||
'participation.process.steps',
|
||||
'participation.awardsGrid.cards',
|
||||
'about.testimonials.items',
|
||||
'pressIndex.events.statusLabels',
|
||||
'pressIndex.downloads.contactName',
|
||||
'pressIndex.downloads.contactImage',
|
||||
'pressIndex.downloads.contactImageAlt',
|
||||
'netzwerk.jury.members',
|
||||
'netzwerk.patronage.greetingVideo',
|
||||
'netzwerk.patronage.greetingVideoButtonLabel',
|
||||
'netzwerk.patronage.greetingVideoTitle',
|
||||
'netzwerk.patronage.greetingVideoDescription',
|
||||
'netzwerk.patronage.greetingVideoCloseLabel',
|
||||
'netzwerk.partners.eyebrow',
|
||||
'netzwerk.partners.heading',
|
||||
'netzwerk.partners.description',
|
||||
'netzwerk.partners.detailLabel',
|
||||
'netzwerk.partners.tiers',
|
||||
'netzwerk.partners.tiers.label',
|
||||
'netzwerk.partners.tiers.note',
|
||||
'netzwerk.partners.modal.websiteLabel',
|
||||
'netzwerk.sponsoringForm.steps',
|
||||
'netzwerk.sponsoringForm.requiredFieldError',
|
||||
]),
|
||||
)
|
||||
})
|
||||
|
||||
it('keeps shared multi-page and site-wide content in Globals', () => {
|
||||
const globals = [ApplicationForm, ApplicationPhase, NewsletterForm, Header, Footer, SiteSettings]
|
||||
const slugs = globals.map((global) => global.slug)
|
||||
|
||||
expect(slugs).toEqual([
|
||||
'application-form',
|
||||
'application-phase',
|
||||
'newsletter-form',
|
||||
'header',
|
||||
'footer',
|
||||
'site-settings',
|
||||
])
|
||||
expect(fieldPaths(ApplicationForm.fields)).toEqual(expect.arrayContaining(['section', 'form']))
|
||||
expect(fieldPaths(ApplicationPhase.fields)).toEqual(expect.arrayContaining(['activePhase', 'phases']))
|
||||
expect(fieldPaths(NewsletterForm.fields)).toEqual(expect.arrayContaining(['evaluation', 'completed']))
|
||||
})
|
||||
|
||||
it('exposes exactly two editable sponsoring step labels', () => {
|
||||
expect(findField(netzwerkFields, 'netzwerk.sponsoringForm.steps')).toMatchObject({
|
||||
minRows: 2,
|
||||
maxRows: 2,
|
||||
})
|
||||
})
|
||||
|
||||
it('limits the Netzwerk greeting upload to video media', () => {
|
||||
const field = findField(netzwerkFields, 'netzwerk.patronage.greetingVideo')
|
||||
|
||||
expect(field).toMatchObject({
|
||||
type: 'upload',
|
||||
relationTo: 'media',
|
||||
filterOptions: {
|
||||
mimeType: {
|
||||
contains: 'video',
|
||||
},
|
||||
},
|
||||
})
|
||||
expect(field).not.toHaveProperty('defaultValue')
|
||||
})
|
||||
|
||||
it('keeps sponsor tier copy separate from collection-owned tier assignment and ordering', () => {
|
||||
expect(findField(netzwerkFields, 'netzwerk.partners.tiers')).toMatchObject({
|
||||
type: 'array',
|
||||
dbName: 'network_tiers',
|
||||
})
|
||||
expect(findField(netzwerkFields, 'netzwerk.partners.tiers.label')).toMatchObject({ type: 'text' })
|
||||
expect(findField(netzwerkFields, 'netzwerk.partners.tiers.note')).toMatchObject({ type: 'text' })
|
||||
})
|
||||
|
||||
it('limits the press contact upload to image media', () => {
|
||||
const field = findField(pressIndexFields, 'pressIndex.downloads.contactImage')
|
||||
|
||||
expect(field).toMatchObject({
|
||||
type: 'upload',
|
||||
relationTo: 'media',
|
||||
filterOptions: {
|
||||
mimeType: {
|
||||
contains: 'image',
|
||||
},
|
||||
},
|
||||
})
|
||||
expect(field).not.toHaveProperty('defaultValue')
|
||||
})
|
||||
})
|
||||
16
tests/int/press-collection-items.int.spec.ts
Normal file
16
tests/int/press-collection-items.int.spec.ts
Normal file
@@ -0,0 +1,16 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
|
||||
import { pressEventsFromCms, pressNewsFromCms } from '@/spa/pressCollectionItems'
|
||||
import { pressIndexContent } from '@/spa/pressIndexContent'
|
||||
|
||||
describe('press collection items', () => {
|
||||
it('does not replace an empty published events collection with demo event rows', () => {
|
||||
expect(
|
||||
pressEventsFromCms([], '/images/event-placeholder.jpg', {}, pressIndexContent.events),
|
||||
).toEqual([])
|
||||
})
|
||||
|
||||
it('does not replace an empty published posts collection with demo news cards', () => {
|
||||
expect(pressNewsFromCms([], '/images/news-placeholder.jpg', pressIndexContent.news)).toEqual([])
|
||||
})
|
||||
})
|
||||
123
tests/int/press-contact-image.int.spec.tsx
Normal file
123
tests/int/press-contact-image.int.spec.tsx
Normal file
@@ -0,0 +1,123 @@
|
||||
import { cleanup, render, screen, within } from '@testing-library/react'
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
import { CmsRouteProvider, type CmsRouteData } from '@/spa/cmsRoute'
|
||||
import { pressIndexContent } from '@/spa/pressIndexContent'
|
||||
import Press from '@/spa/pages/Press'
|
||||
|
||||
const viewport = vi.hoisted(() => ({ mobile: false }))
|
||||
|
||||
vi.mock('@/spa/hooks/useIsMobile', () => ({
|
||||
useIsMobile: () => viewport.mobile,
|
||||
}))
|
||||
|
||||
vi.mock('next/navigation', () => ({
|
||||
usePathname: () => '/presse',
|
||||
useRouter: () => ({ push: vi.fn(), replace: vi.fn() }),
|
||||
}))
|
||||
|
||||
const pressRoute = (contactImage?: unknown, contactImageAlt?: string): CmsRouteData => ({
|
||||
collection: 'pages',
|
||||
doc: {
|
||||
id: 'press-page',
|
||||
slug: 'presse',
|
||||
spaPath: '/presse',
|
||||
pressIndex: {
|
||||
downloads: {
|
||||
contactImage,
|
||||
contactImageAlt,
|
||||
},
|
||||
},
|
||||
},
|
||||
lists: {
|
||||
events: [],
|
||||
posts: [],
|
||||
},
|
||||
})
|
||||
|
||||
describe('Presse contact image', () => {
|
||||
beforeEach(() => {
|
||||
viewport.mobile = false
|
||||
})
|
||||
|
||||
afterEach(() => cleanup())
|
||||
|
||||
it('renders selected media and page-owned alt text in the desktop right column', () => {
|
||||
render(
|
||||
<CmsRouteProvider
|
||||
value={pressRoute(
|
||||
{ url: '/api/media/file/press-contact.jpg', alt: 'Media default alt' },
|
||||
'Press team portrait',
|
||||
)}
|
||||
>
|
||||
<Press />
|
||||
</CmsRouteProvider>,
|
||||
)
|
||||
|
||||
const layout = screen.getByTestId('press-contact-layout')
|
||||
const imageColumn = screen.getByTestId('press-contact-image')
|
||||
const image = within(imageColumn).getByRole('img', { name: 'Press team portrait' })
|
||||
|
||||
expect(layout.getAttribute('data-layout')).toBe('two-column')
|
||||
expect(layout.style.gridTemplateColumns).toBe('minmax(0, 1fr) minmax(320px, 1fr)')
|
||||
expect(image.getAttribute('src')).toBe('/api/media/file/press-contact.jpg')
|
||||
expect(
|
||||
screen.getByTestId('press-contact-copy').compareDocumentPosition(imageColumn) &
|
||||
Node.DOCUMENT_POSITION_FOLLOWING,
|
||||
).toBeTruthy()
|
||||
})
|
||||
|
||||
it('clears only the visual and keeps the contact copy in a compact fallback-free layout', () => {
|
||||
const { rerender } = render(
|
||||
<CmsRouteProvider
|
||||
value={pressRoute({ url: '/api/media/file/press-contact.jpg' }, 'Press contact')}
|
||||
>
|
||||
<Press />
|
||||
</CmsRouteProvider>,
|
||||
)
|
||||
|
||||
expect(screen.getByTestId('press-contact-image')).toBeTruthy()
|
||||
|
||||
rerender(
|
||||
<CmsRouteProvider value={pressRoute(undefined, '')}>
|
||||
<Press />
|
||||
</CmsRouteProvider>,
|
||||
)
|
||||
|
||||
const layout = screen.getByTestId('press-contact-layout')
|
||||
expect(screen.queryByTestId('press-contact-image')).toBeNull()
|
||||
expect(layout.getAttribute('data-layout')).toBe('copy-only')
|
||||
expect(layout.style.gridTemplateColumns).toBe('1fr')
|
||||
expect(
|
||||
within(screen.getByTestId('press-contact-copy')).getByText(
|
||||
pressIndexContent.downloads.contactName,
|
||||
),
|
||||
).toBeTruthy()
|
||||
expect(within(layout).queryByRole('img')).toBeNull()
|
||||
})
|
||||
|
||||
it('stacks copy before the selected image on mobile', () => {
|
||||
viewport.mobile = true
|
||||
|
||||
render(
|
||||
<CmsRouteProvider
|
||||
value={pressRoute({ filename: 'press-contact-mobile.jpg' }, 'Mobile press contact')}
|
||||
>
|
||||
<Press />
|
||||
</CmsRouteProvider>,
|
||||
)
|
||||
|
||||
const layout = screen.getByTestId('press-contact-layout')
|
||||
const copy = screen.getByTestId('press-contact-copy')
|
||||
const imageColumn = screen.getByTestId('press-contact-image')
|
||||
|
||||
expect(layout.getAttribute('data-layout')).toBe('stacked')
|
||||
expect(layout.style.gridTemplateColumns).toBe('1fr')
|
||||
expect(
|
||||
within(imageColumn).getByRole('img', { name: 'Mobile press contact' }).getAttribute('src'),
|
||||
).toBe('/api/media/file/press-contact-mobile.jpg')
|
||||
expect(
|
||||
copy.compareDocumentPosition(imageColumn) & Node.DOCUMENT_POSITION_FOLLOWING,
|
||||
).toBeTruthy()
|
||||
})
|
||||
})
|
||||
464
tests/int/public-route-hydration.int.spec.ts
Normal file
464
tests/int/public-route-hydration.int.spec.ts
Normal file
@@ -0,0 +1,464 @@
|
||||
import config from '@/payload.config'
|
||||
import { resolveHomeWinners } from '@/spa/homeWinnerSelection'
|
||||
import { selectJuryDocs } from '@/spa/juryMembers'
|
||||
import type { CmsRouteDoc } from '@/spa/cmsRoute'
|
||||
import { getPublicationQueryOptions } from '@/utilities/publicationQuery'
|
||||
import type { Payload } from 'payload'
|
||||
import { getPayload } from 'payload'
|
||||
import { afterAll, beforeAll, describe, expect, it } from 'vitest'
|
||||
|
||||
const lexicalDocument = {
|
||||
root: {
|
||||
children: [
|
||||
{
|
||||
children: [
|
||||
{
|
||||
detail: 0,
|
||||
format: 0,
|
||||
mode: 'normal' as const,
|
||||
style: '',
|
||||
text: 'Route hydration fixture',
|
||||
type: 'text',
|
||||
version: 1,
|
||||
},
|
||||
],
|
||||
direction: 'ltr' as const,
|
||||
format: '' as const,
|
||||
indent: 0,
|
||||
textFormat: 0,
|
||||
type: 'paragraph',
|
||||
version: 1,
|
||||
},
|
||||
],
|
||||
direction: 'ltr' as const,
|
||||
format: '' as const,
|
||||
indent: 0,
|
||||
type: 'root',
|
||||
version: 1,
|
||||
},
|
||||
}
|
||||
|
||||
const fixtureKey = `route-hydration-${process.pid}-${Date.now()}`
|
||||
|
||||
let payload: Payload
|
||||
let mediaID: number
|
||||
const cleanup: Array<() => Promise<unknown>> = []
|
||||
|
||||
async function findFixtureDocs(
|
||||
collection: 'events' | 'jury-mitglieder' | 'partners' | 'posts' | 'preistraeger',
|
||||
ids: Array<number | string>,
|
||||
preview: boolean,
|
||||
sort?: string,
|
||||
): Promise<CmsRouteDoc[]> {
|
||||
const result = await payload.find({
|
||||
collection,
|
||||
...getPublicationQueryOptions(preview),
|
||||
depth: 1,
|
||||
limit: 20,
|
||||
pagination: false,
|
||||
...(sort ? { sort } : {}),
|
||||
where: { id: { in: ids } },
|
||||
})
|
||||
|
||||
return result.docs as unknown as CmsRouteDoc[]
|
||||
}
|
||||
|
||||
describe('public route hydration', () => {
|
||||
beforeAll(async () => {
|
||||
payload = await getPayload({ config })
|
||||
|
||||
const imageData = Buffer.from(
|
||||
'iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mNk+A8AAQUBAScY42YAAAAASUVORK5CYII=',
|
||||
'base64',
|
||||
)
|
||||
const media = await payload.create({
|
||||
collection: 'media',
|
||||
data: { alt: 'Route hydration fixture' },
|
||||
file: {
|
||||
data: imageData,
|
||||
mimetype: 'image/png',
|
||||
name: `${fixtureKey}.png`,
|
||||
size: imageData.length,
|
||||
},
|
||||
})
|
||||
mediaID = media.id
|
||||
})
|
||||
|
||||
afterAll(async () => {
|
||||
for (const remove of cleanup.reverse()) await remove()
|
||||
if (mediaID) await payload.delete({ collection: 'media', id: mediaID })
|
||||
})
|
||||
|
||||
it('omits an unpublished jury member selected on /netzwerk and restores it in preview', async () => {
|
||||
const first = await payload.create({
|
||||
collection: 'jury-mitglieder',
|
||||
context: { disableRevalidate: true },
|
||||
data: { _status: 'published', name: `${fixtureKey} jury first`, sortOrder: 10 },
|
||||
draft: false,
|
||||
})
|
||||
const unpublished = await payload.create({
|
||||
collection: 'jury-mitglieder',
|
||||
context: { disableRevalidate: true },
|
||||
data: { _status: 'draft', name: `${fixtureKey} jury unpublished`, sortOrder: 20 },
|
||||
draft: false,
|
||||
})
|
||||
const third = await payload.create({
|
||||
collection: 'jury-mitglieder',
|
||||
context: { disableRevalidate: true },
|
||||
data: { _status: 'published', name: `${fixtureKey} jury third`, sortOrder: 30 },
|
||||
draft: false,
|
||||
})
|
||||
cleanup.push(
|
||||
() =>
|
||||
payload.delete({
|
||||
collection: 'jury-mitglieder',
|
||||
context: { disableRevalidate: true },
|
||||
id: first.id,
|
||||
}),
|
||||
() =>
|
||||
payload.delete({
|
||||
collection: 'jury-mitglieder',
|
||||
context: { disableRevalidate: true },
|
||||
id: unpublished.id,
|
||||
}),
|
||||
() =>
|
||||
payload.delete({
|
||||
collection: 'jury-mitglieder',
|
||||
context: { disableRevalidate: true },
|
||||
id: third.id,
|
||||
}),
|
||||
)
|
||||
|
||||
const ids = [first.id, unpublished.id, third.id]
|
||||
const selection = [third, unpublished, first]
|
||||
const publicDocs = await findFixtureDocs('jury-mitglieder', ids, false, 'sortOrder')
|
||||
const previewDocs = await findFixtureDocs('jury-mitglieder', ids, true, 'sortOrder')
|
||||
|
||||
expect(selectJuryDocs(selection, publicDocs).map((doc) => doc.id)).toEqual([third.id, first.id])
|
||||
expect(selectJuryDocs(selection, previewDocs).map((doc) => doc.id)).toEqual([
|
||||
third.id,
|
||||
unpublished.id,
|
||||
first.id,
|
||||
])
|
||||
})
|
||||
|
||||
it('omits an unpublished selected homepage winner and restores it after republishing without a page edit', async () => {
|
||||
const first = await payload.create({
|
||||
collection: 'preistraeger',
|
||||
context: { disableRevalidate: true },
|
||||
data: {
|
||||
_status: 'published',
|
||||
slug: `${fixtureKey}-winner-first`,
|
||||
title: `${fixtureKey} winner first`,
|
||||
year: 2026,
|
||||
},
|
||||
draft: false,
|
||||
})
|
||||
const unpublished = await payload.create({
|
||||
collection: 'preistraeger',
|
||||
context: { disableRevalidate: true },
|
||||
data: {
|
||||
_status: 'draft',
|
||||
slug: `${fixtureKey}-winner-unpublished`,
|
||||
title: `${fixtureKey} winner unpublished`,
|
||||
year: 2026,
|
||||
},
|
||||
draft: false,
|
||||
})
|
||||
const third = await payload.create({
|
||||
collection: 'preistraeger',
|
||||
context: { disableRevalidate: true },
|
||||
data: {
|
||||
_status: 'published',
|
||||
slug: `${fixtureKey}-winner-third`,
|
||||
title: `${fixtureKey} winner third`,
|
||||
year: 2026,
|
||||
},
|
||||
draft: false,
|
||||
})
|
||||
cleanup.push(
|
||||
() =>
|
||||
payload.delete({
|
||||
collection: 'preistraeger',
|
||||
context: { disableRevalidate: true },
|
||||
id: first.id,
|
||||
}),
|
||||
() =>
|
||||
payload.delete({
|
||||
collection: 'preistraeger',
|
||||
context: { disableRevalidate: true },
|
||||
id: unpublished.id,
|
||||
}),
|
||||
() =>
|
||||
payload.delete({
|
||||
collection: 'preistraeger',
|
||||
context: { disableRevalidate: true },
|
||||
id: third.id,
|
||||
}),
|
||||
)
|
||||
|
||||
const ids = [first.id, unpublished.id, third.id]
|
||||
const selection = [third, unpublished, first]
|
||||
const publicDocs = await findFixtureDocs('preistraeger', ids, false)
|
||||
const previewDocs = await findFixtureDocs('preistraeger', ids, true)
|
||||
|
||||
expect(resolveHomeWinners(selection, publicDocs).map((doc) => doc.id)).toEqual([
|
||||
third.id,
|
||||
first.id,
|
||||
])
|
||||
expect(resolveHomeWinners(selection, previewDocs).map((doc) => doc.id)).toEqual([
|
||||
third.id,
|
||||
unpublished.id,
|
||||
first.id,
|
||||
])
|
||||
|
||||
await payload.update({
|
||||
collection: 'preistraeger',
|
||||
context: { disableRevalidate: true },
|
||||
id: unpublished.id,
|
||||
data: { _status: 'published' },
|
||||
draft: false,
|
||||
})
|
||||
const republishedDocs = await findFixtureDocs('preistraeger', ids, false)
|
||||
|
||||
expect(resolveHomeWinners(selection, republishedDocs).map((doc) => doc.id)).toEqual([
|
||||
third.id,
|
||||
unpublished.id,
|
||||
first.id,
|
||||
])
|
||||
})
|
||||
|
||||
it('omits an unpublished partner from the homepage ticker and preserves sort order', async () => {
|
||||
const first = await payload.create({
|
||||
collection: 'partners',
|
||||
context: { disableRevalidate: true },
|
||||
data: {
|
||||
_status: 'published',
|
||||
logo: mediaID,
|
||||
name: `${fixtureKey} partner first`,
|
||||
sortOrder: 10,
|
||||
stableId: `${fixtureKey}-partner-first`,
|
||||
tier: 1,
|
||||
},
|
||||
draft: false,
|
||||
})
|
||||
const unpublished = await payload.create({
|
||||
collection: 'partners',
|
||||
context: { disableRevalidate: true },
|
||||
data: {
|
||||
_status: 'draft',
|
||||
logo: mediaID,
|
||||
name: `${fixtureKey} partner unpublished`,
|
||||
sortOrder: 20,
|
||||
stableId: `${fixtureKey}-partner-unpublished`,
|
||||
tier: 2,
|
||||
},
|
||||
draft: false,
|
||||
})
|
||||
const third = await payload.create({
|
||||
collection: 'partners',
|
||||
context: { disableRevalidate: true },
|
||||
data: {
|
||||
_status: 'published',
|
||||
logo: mediaID,
|
||||
name: `${fixtureKey} partner third`,
|
||||
sortOrder: 30,
|
||||
stableId: `${fixtureKey}-partner-third`,
|
||||
tier: 3,
|
||||
},
|
||||
draft: false,
|
||||
})
|
||||
cleanup.push(
|
||||
() =>
|
||||
payload.delete({
|
||||
collection: 'partners',
|
||||
context: { disableRevalidate: true },
|
||||
id: first.id,
|
||||
}),
|
||||
() =>
|
||||
payload.delete({
|
||||
collection: 'partners',
|
||||
context: { disableRevalidate: true },
|
||||
id: unpublished.id,
|
||||
}),
|
||||
() =>
|
||||
payload.delete({
|
||||
collection: 'partners',
|
||||
context: { disableRevalidate: true },
|
||||
id: third.id,
|
||||
}),
|
||||
)
|
||||
|
||||
const ids = [first.id, unpublished.id, third.id]
|
||||
const publicDocs = await findFixtureDocs('partners', ids, false, 'sortOrder')
|
||||
const previewDocs = await findFixtureDocs('partners', ids, true, 'sortOrder')
|
||||
|
||||
expect(publicDocs.map((doc) => doc.id)).toEqual([first.id, third.id])
|
||||
expect(previewDocs.map((doc) => doc.id)).toEqual([first.id, unpublished.id, third.id])
|
||||
})
|
||||
|
||||
it('leaves no event row after the final /presse event is unpublished', async () => {
|
||||
const event = await payload.create({
|
||||
collection: 'events',
|
||||
context: { disableRevalidate: true },
|
||||
data: {
|
||||
_status: 'published',
|
||||
slug: `${fixtureKey}-event-final`,
|
||||
title: `${fixtureKey} event final`,
|
||||
},
|
||||
draft: false,
|
||||
})
|
||||
cleanup.push(() =>
|
||||
payload.delete({
|
||||
collection: 'events',
|
||||
context: { disableRevalidate: true },
|
||||
id: event.id,
|
||||
}),
|
||||
)
|
||||
|
||||
expect((await findFixtureDocs('events', [event.id], false)).map((doc) => doc.id)).toEqual([
|
||||
event.id,
|
||||
])
|
||||
|
||||
await payload.update({
|
||||
collection: 'events',
|
||||
context: { disableRevalidate: true },
|
||||
id: event.id,
|
||||
data: { _status: 'draft' },
|
||||
draft: false,
|
||||
})
|
||||
|
||||
expect(await findFixtureDocs('events', [event.id], false)).toEqual([])
|
||||
expect((await findFixtureDocs('events', [event.id], true)).map((doc) => doc.id)).toEqual([
|
||||
event.id,
|
||||
])
|
||||
})
|
||||
|
||||
it('omits an unpublished event previously rendered on /presse', async () => {
|
||||
const published = await payload.create({
|
||||
collection: 'events',
|
||||
context: { disableRevalidate: true },
|
||||
data: {
|
||||
_status: 'published',
|
||||
slug: `${fixtureKey}-event-published`,
|
||||
title: `${fixtureKey} event published`,
|
||||
},
|
||||
draft: false,
|
||||
})
|
||||
const unpublished = await payload.create({
|
||||
collection: 'events',
|
||||
context: { disableRevalidate: true },
|
||||
data: {
|
||||
_status: 'draft',
|
||||
slug: `${fixtureKey}-event-unpublished`,
|
||||
title: `${fixtureKey} event unpublished`,
|
||||
},
|
||||
draft: false,
|
||||
})
|
||||
cleanup.push(
|
||||
() =>
|
||||
payload.delete({
|
||||
collection: 'events',
|
||||
context: { disableRevalidate: true },
|
||||
id: published.id,
|
||||
}),
|
||||
() =>
|
||||
payload.delete({
|
||||
collection: 'events',
|
||||
context: { disableRevalidate: true },
|
||||
id: unpublished.id,
|
||||
}),
|
||||
)
|
||||
|
||||
const ids = [published.id, unpublished.id]
|
||||
const publicDocs = await findFixtureDocs('events', ids, false)
|
||||
const previewDocs = await findFixtureDocs('events', ids, true)
|
||||
|
||||
expect(publicDocs.map((doc) => doc.id)).toEqual([published.id])
|
||||
expect(previewDocs.map((doc) => doc.id).sort()).toEqual(ids.sort())
|
||||
})
|
||||
|
||||
it('leaves no news card after the final /presse article is unpublished', async () => {
|
||||
const post = await payload.create({
|
||||
collection: 'posts',
|
||||
context: { disableRevalidate: true },
|
||||
data: {
|
||||
_status: 'published',
|
||||
content: lexicalDocument,
|
||||
slug: `${fixtureKey}-post-final`,
|
||||
title: `${fixtureKey} post final`,
|
||||
},
|
||||
draft: false,
|
||||
})
|
||||
cleanup.push(() =>
|
||||
payload.delete({
|
||||
collection: 'posts',
|
||||
context: { disableRevalidate: true },
|
||||
id: post.id,
|
||||
}),
|
||||
)
|
||||
|
||||
expect((await findFixtureDocs('posts', [post.id], false)).map((doc) => doc.id)).toEqual([
|
||||
post.id,
|
||||
])
|
||||
|
||||
await payload.update({
|
||||
collection: 'posts',
|
||||
context: { disableRevalidate: true },
|
||||
id: post.id,
|
||||
data: { _status: 'draft' },
|
||||
draft: false,
|
||||
})
|
||||
|
||||
expect(await findFixtureDocs('posts', [post.id], false)).toEqual([])
|
||||
expect((await findFixtureDocs('posts', [post.id], true)).map((doc) => doc.id)).toEqual([
|
||||
post.id,
|
||||
])
|
||||
})
|
||||
|
||||
it('omits an unpublished press article previously rendered on /presse', async () => {
|
||||
const published = await payload.create({
|
||||
collection: 'posts',
|
||||
context: { disableRevalidate: true },
|
||||
data: {
|
||||
_status: 'published',
|
||||
content: lexicalDocument,
|
||||
slug: `${fixtureKey}-post-published`,
|
||||
title: `${fixtureKey} post published`,
|
||||
},
|
||||
draft: false,
|
||||
})
|
||||
const unpublished = await payload.create({
|
||||
collection: 'posts',
|
||||
context: { disableRevalidate: true },
|
||||
data: {
|
||||
_status: 'draft',
|
||||
content: lexicalDocument,
|
||||
slug: `${fixtureKey}-post-unpublished`,
|
||||
title: `${fixtureKey} post unpublished`,
|
||||
},
|
||||
draft: false,
|
||||
})
|
||||
cleanup.push(
|
||||
() =>
|
||||
payload.delete({
|
||||
collection: 'posts',
|
||||
context: { disableRevalidate: true },
|
||||
id: published.id,
|
||||
}),
|
||||
() =>
|
||||
payload.delete({
|
||||
collection: 'posts',
|
||||
context: { disableRevalidate: true },
|
||||
id: unpublished.id,
|
||||
}),
|
||||
)
|
||||
|
||||
const ids = [published.id, unpublished.id]
|
||||
const publicDocs = await findFixtureDocs('posts', ids, false)
|
||||
const previewDocs = await findFixtureDocs('posts', ids, true)
|
||||
|
||||
expect(publicDocs.map((doc) => doc.id)).toEqual([published.id])
|
||||
expect(previewDocs.map((doc) => doc.id).sort()).toEqual(ids.sort())
|
||||
})
|
||||
})
|
||||
260
tests/int/publication-contract.int.spec.ts
Normal file
260
tests/int/publication-contract.int.spec.ts
Normal file
@@ -0,0 +1,260 @@
|
||||
import config from '@/payload.config'
|
||||
import { getPublicationQueryOptions } from '@/utilities/publicationQuery'
|
||||
import type { Payload, RequiredDataFromCollectionSlug } from 'payload'
|
||||
import { getPayload } from 'payload'
|
||||
import { afterAll, beforeAll, describe, expect, it } from 'vitest'
|
||||
|
||||
const publicationCollections = [
|
||||
'pages',
|
||||
'preistraeger',
|
||||
'jury-mitglieder',
|
||||
'partners',
|
||||
'events',
|
||||
'posts',
|
||||
] as const
|
||||
|
||||
type PublicationCollection = (typeof publicationCollections)[number]
|
||||
|
||||
type PublicationCase<TCollection extends PublicationCollection = PublicationCollection> = {
|
||||
collection: TCollection
|
||||
data: (label: string) => RequiredDataFromCollectionSlug<TCollection>
|
||||
hasPublicDetailRoute: boolean
|
||||
}
|
||||
|
||||
const lexicalDocument = {
|
||||
root: {
|
||||
children: [
|
||||
{
|
||||
children: [
|
||||
{
|
||||
detail: 0,
|
||||
format: 0,
|
||||
mode: 'normal' as const,
|
||||
style: '',
|
||||
text: 'Publication contract fixture',
|
||||
type: 'text',
|
||||
version: 1,
|
||||
},
|
||||
],
|
||||
direction: 'ltr' as const,
|
||||
format: '' as const,
|
||||
indent: 0,
|
||||
textFormat: 0,
|
||||
type: 'paragraph',
|
||||
version: 1,
|
||||
},
|
||||
],
|
||||
direction: 'ltr' as const,
|
||||
format: '' as const,
|
||||
indent: 0,
|
||||
type: 'root',
|
||||
version: 1,
|
||||
},
|
||||
}
|
||||
|
||||
let mediaID: number
|
||||
const cleanup: Array<() => Promise<unknown>> = []
|
||||
|
||||
const publicationCases: PublicationCase[] = [
|
||||
{
|
||||
collection: 'pages',
|
||||
data: (label) => ({
|
||||
layout: [{ blockType: 'content', columns: [] }],
|
||||
_status: 'published',
|
||||
slug: `publication-${label}`,
|
||||
spaPath: `/publication-${label}`,
|
||||
title: `Publication ${label}`,
|
||||
}),
|
||||
hasPublicDetailRoute: true,
|
||||
},
|
||||
{
|
||||
collection: 'preistraeger',
|
||||
data: (label) => ({
|
||||
_status: 'published',
|
||||
slug: `publication-${label}`,
|
||||
spaPath: `/preistraeger/publication-${label}`,
|
||||
title: `Publication ${label}`,
|
||||
}),
|
||||
hasPublicDetailRoute: true,
|
||||
},
|
||||
{
|
||||
collection: 'jury-mitglieder',
|
||||
data: (label) => ({ _status: 'published', name: `Publication ${label}` }),
|
||||
hasPublicDetailRoute: false,
|
||||
},
|
||||
{
|
||||
collection: 'partners',
|
||||
data: (label) => ({
|
||||
_status: 'published',
|
||||
logo: mediaID,
|
||||
name: `Publication ${label}`,
|
||||
stableId: `publication-${label}`,
|
||||
tier: 3,
|
||||
}),
|
||||
hasPublicDetailRoute: false,
|
||||
},
|
||||
{
|
||||
collection: 'events',
|
||||
data: (label) => ({
|
||||
_status: 'published',
|
||||
slug: `publication-${label}`,
|
||||
spaPath: `/presse/events/publication-${label}`,
|
||||
title: `Publication ${label}`,
|
||||
}),
|
||||
hasPublicDetailRoute: true,
|
||||
},
|
||||
{
|
||||
collection: 'posts',
|
||||
data: (label) => ({
|
||||
_status: 'published',
|
||||
content: lexicalDocument,
|
||||
slug: `publication-${label}`,
|
||||
spaPath: `/presse/blog/publication-${label}`,
|
||||
title: `Publication ${label}`,
|
||||
}),
|
||||
hasPublicDetailRoute: true,
|
||||
},
|
||||
]
|
||||
|
||||
let payload: Payload
|
||||
|
||||
describe('draft-enabled collection publication contract', () => {
|
||||
beforeAll(async () => {
|
||||
payload = await getPayload({ config })
|
||||
|
||||
const imageData = Buffer.from(
|
||||
'iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mNk+A8AAQUBAScY42YAAAAASUVORK5CYII=',
|
||||
'base64',
|
||||
)
|
||||
const media = await payload.create({
|
||||
collection: 'media',
|
||||
data: { alt: 'Publication contract fixture' },
|
||||
file: {
|
||||
data: imageData,
|
||||
mimetype: 'image/png',
|
||||
name: `publication-contract-${process.pid}.png`,
|
||||
size: imageData.length,
|
||||
},
|
||||
})
|
||||
mediaID = media.id
|
||||
})
|
||||
|
||||
afterAll(async () => {
|
||||
for (const removeFixture of cleanup.reverse()) {
|
||||
await removeFixture()
|
||||
}
|
||||
|
||||
if (mediaID) {
|
||||
await payload.delete({ collection: 'media', id: mediaID })
|
||||
}
|
||||
})
|
||||
|
||||
it('keeps anonymous public and authenticated preview query modes explicit', () => {
|
||||
expect(getPublicationQueryOptions(false)).toEqual({
|
||||
draft: false,
|
||||
overrideAccess: false,
|
||||
})
|
||||
expect(getPublicationQueryOptions(true)).toEqual({
|
||||
draft: true,
|
||||
overrideAccess: true,
|
||||
})
|
||||
})
|
||||
|
||||
it.each(publicationCases)(
|
||||
'returns only published $collection documents to anonymous public reads',
|
||||
async ({ collection, data, hasPublicDetailRoute }) => {
|
||||
const published = await payload.create({
|
||||
collection,
|
||||
context: { disableRevalidate: true },
|
||||
data: data(`${collection}-published`),
|
||||
draft: false,
|
||||
})
|
||||
const unpublished = await payload.create({
|
||||
collection,
|
||||
context: { disableRevalidate: true },
|
||||
data: data(`${collection}-unpublished`),
|
||||
draft: false,
|
||||
})
|
||||
cleanup.push(
|
||||
() =>
|
||||
payload.delete({
|
||||
collection,
|
||||
context: { disableRevalidate: true },
|
||||
id: published.id,
|
||||
}),
|
||||
() =>
|
||||
payload.delete({
|
||||
collection,
|
||||
context: { disableRevalidate: true },
|
||||
id: unpublished.id,
|
||||
}),
|
||||
)
|
||||
|
||||
const storedDraft = await payload.update({
|
||||
collection,
|
||||
context: { disableRevalidate: true },
|
||||
id: unpublished.id,
|
||||
data: { _status: 'draft' },
|
||||
draft: false,
|
||||
})
|
||||
|
||||
expect(published._status).toBe('published')
|
||||
expect(storedDraft._status).toBe('draft')
|
||||
|
||||
const publicResult = await payload.find({
|
||||
collection,
|
||||
...getPublicationQueryOptions(false),
|
||||
pagination: false,
|
||||
where: {
|
||||
id: {
|
||||
in: [published.id, unpublished.id],
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
expect(publicResult.docs.map((doc) => doc.id)).toEqual([published.id])
|
||||
expect(publicResult.docs.every((doc) => doc._status === 'published')).toBe(true)
|
||||
|
||||
const previewResult = await payload.find({
|
||||
collection,
|
||||
...getPublicationQueryOptions(true),
|
||||
pagination: false,
|
||||
where: {
|
||||
id: {
|
||||
in: [published.id, unpublished.id],
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
expect(previewResult.docs.map((doc) => doc.id).sort()).toEqual(
|
||||
[published.id, unpublished.id].sort(),
|
||||
)
|
||||
|
||||
if (hasPublicDetailRoute) {
|
||||
await expect(
|
||||
payload.findByID({
|
||||
collection,
|
||||
id: published.id,
|
||||
...getPublicationQueryOptions(false),
|
||||
}),
|
||||
).resolves.toMatchObject({ id: published.id, _status: 'published' })
|
||||
|
||||
await expect(
|
||||
payload.findByID({
|
||||
collection,
|
||||
id: unpublished.id,
|
||||
...getPublicationQueryOptions(false),
|
||||
}),
|
||||
).rejects.toThrow()
|
||||
|
||||
await expect(
|
||||
payload.findByID({
|
||||
collection,
|
||||
id: unpublished.id,
|
||||
...getPublicationQueryOptions(true),
|
||||
}),
|
||||
).resolves.toMatchObject({ id: unpublished.id, _status: 'draft' })
|
||||
}
|
||||
},
|
||||
)
|
||||
})
|
||||
210
tests/int/sitemap-routes.int.spec.ts
Normal file
210
tests/int/sitemap-routes.int.spec.ts
Normal file
@@ -0,0 +1,210 @@
|
||||
import { createRequire } from 'node:module'
|
||||
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
import { getSitemapBaseURL, normalizeSitemapBaseURL } from '@/utilities/sitemapBaseURL.cjs'
|
||||
|
||||
type SitemapCollection = 'pages' | 'posts' | 'events' | 'preistraeger'
|
||||
|
||||
type SitemapDocument = {
|
||||
_status: 'draft' | 'published'
|
||||
slug?: string
|
||||
spaPath?: string
|
||||
updatedAt?: string
|
||||
}
|
||||
|
||||
type SitemapFindQuery = {
|
||||
collection: SitemapCollection
|
||||
draft?: boolean
|
||||
overrideAccess?: boolean
|
||||
where?: {
|
||||
_status?: {
|
||||
equals?: string
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const routeState = vi.hoisted(() => ({
|
||||
docs: {
|
||||
events: [],
|
||||
pages: [],
|
||||
posts: [],
|
||||
preistraeger: [],
|
||||
} as Record<SitemapCollection, SitemapDocument[]>,
|
||||
queries: [] as SitemapFindQuery[],
|
||||
}))
|
||||
|
||||
vi.mock('@payload-config', () => ({ default: {} }))
|
||||
|
||||
vi.mock('next/cache', () => ({
|
||||
unstable_cache: (callback: () => unknown) => callback,
|
||||
}))
|
||||
|
||||
vi.mock('payload', () => ({
|
||||
getPayload: async () => ({
|
||||
find: async (query: SitemapFindQuery) => {
|
||||
routeState.queries.push(query)
|
||||
|
||||
const isPublishedPublicQuery =
|
||||
query.draft === false &&
|
||||
query.overrideAccess === false &&
|
||||
query.where?._status?.equals === 'published'
|
||||
|
||||
return {
|
||||
docs: isPublishedPublicQuery
|
||||
? routeState.docs[query.collection].filter((document) => document._status === 'published')
|
||||
: routeState.docs[query.collection],
|
||||
}
|
||||
},
|
||||
}),
|
||||
}))
|
||||
|
||||
import { GET as getPagesSitemap } from '@/app/(frontend)/(sitemaps)/pages-sitemap.xml/route'
|
||||
import { GET as getPostsSitemap } from '@/app/(frontend)/(sitemaps)/posts-sitemap.xml/route'
|
||||
|
||||
const productionURL = 'https://www.bayerischer-mittelstandspreis.de'
|
||||
|
||||
async function sitemapLocations(response: Response): Promise<string[]> {
|
||||
const xml = await response.text()
|
||||
return [...xml.matchAll(/<loc>([^<]+)<\/loc>/g)].map((match) => match[1])
|
||||
}
|
||||
|
||||
describe('sitemap route generation', () => {
|
||||
beforeEach(() => {
|
||||
process.env.NEXT_PUBLIC_SERVER_URL = `${productionURL}/`
|
||||
process.env.VERCEL_PROJECT_PRODUCTION_URL = ''
|
||||
|
||||
for (const collection of Object.keys(routeState.docs) as SitemapCollection[]) {
|
||||
routeState.docs[collection] = []
|
||||
}
|
||||
routeState.queries = []
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks()
|
||||
})
|
||||
|
||||
it.each([
|
||||
{
|
||||
label: 'stored homepage path',
|
||||
document: { _status: 'published', slug: 'startseite', spaPath: '/' },
|
||||
expectedPath: '/',
|
||||
},
|
||||
{
|
||||
label: 'home slug fallback',
|
||||
document: { _status: 'published', slug: 'home' },
|
||||
expectedPath: '/',
|
||||
},
|
||||
{
|
||||
label: 'startseite slug fallback',
|
||||
document: { _status: 'published', slug: 'startseite' },
|
||||
expectedPath: '/',
|
||||
},
|
||||
{
|
||||
label: 'normal stored page path',
|
||||
document: { _status: 'published', slug: 'legacy-contact', spaPath: '/kontakt' },
|
||||
expectedPath: '/kontakt',
|
||||
},
|
||||
] as const)('emits the $label', async ({ document, expectedPath }) => {
|
||||
routeState.docs.pages = [document]
|
||||
|
||||
await expect(sitemapLocations(await getPagesSitemap())).resolves.toEqual([
|
||||
`${productionURL}${expectedPath}`,
|
||||
])
|
||||
})
|
||||
|
||||
it.each([
|
||||
{ collection: 'posts', prefix: '/presse/blog', slug: 'presseartikel' },
|
||||
{ collection: 'events', prefix: '/presse/events', slug: 'preisverleihung' },
|
||||
{ collection: 'preistraeger', prefix: '/preistraeger', slug: 'muster-gmbh' },
|
||||
] as const)(
|
||||
'emits $collection detail routes under $prefix',
|
||||
async ({ collection, prefix, slug }) => {
|
||||
routeState.docs[collection] = [
|
||||
{
|
||||
_status: 'published',
|
||||
slug,
|
||||
spaPath: `${prefix}/${slug}`,
|
||||
},
|
||||
]
|
||||
|
||||
await expect(sitemapLocations(await getPostsSitemap())).resolves.toContain(
|
||||
`${productionURL}${prefix}/${slug}`,
|
||||
)
|
||||
},
|
||||
)
|
||||
|
||||
it('excludes unpublished documents through the established anonymous public query contract', async () => {
|
||||
routeState.docs.pages = [
|
||||
{ _status: 'published', slug: 'sichtbar', spaPath: '/sichtbar' },
|
||||
{ _status: 'draft', slug: 'entwurf', spaPath: '/entwurf' },
|
||||
]
|
||||
routeState.docs.posts = [
|
||||
{ _status: 'published', slug: 'sichtbar', spaPath: '/presse/blog/sichtbar' },
|
||||
{ _status: 'draft', slug: 'entwurf', spaPath: '/presse/blog/entwurf' },
|
||||
]
|
||||
|
||||
const locations = [
|
||||
...(await sitemapLocations(await getPagesSitemap())),
|
||||
...(await sitemapLocations(await getPostsSitemap())),
|
||||
]
|
||||
|
||||
expect(locations).toContain(`${productionURL}/sichtbar`)
|
||||
expect(locations).toContain(`${productionURL}/presse/blog/sichtbar`)
|
||||
expect(locations.some((location) => location.includes('entwurf'))).toBe(false)
|
||||
expect(routeState.queries).toHaveLength(4)
|
||||
expect(routeState.queries).toEqual(
|
||||
expect.arrayContaining([
|
||||
expect.objectContaining({
|
||||
draft: false,
|
||||
overrideAccess: false,
|
||||
where: { _status: { equals: 'published' } },
|
||||
}),
|
||||
]),
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
describe('sitemap production base URL', () => {
|
||||
it.each([
|
||||
[
|
||||
'trailing path and slash',
|
||||
'https://www.bayerischer-mittelstandspreis.de/path///',
|
||||
productionURL,
|
||||
],
|
||||
['bare deployment hostname', 'bmp-production.vercel.app', 'https://bmp-production.vercel.app'],
|
||||
])('normalizes a %s', (_label, configuredURL, expectedURL) => {
|
||||
expect(normalizeSitemapBaseURL(configuredURL)).toBe(expectedURL)
|
||||
})
|
||||
|
||||
it('rejects a localhost production origin', () => {
|
||||
expect(() => normalizeSitemapBaseURL('http://localhost:3000', { production: true })).toThrow(
|
||||
'Production sitemap base URL must be a public HTTPS origin.',
|
||||
)
|
||||
})
|
||||
|
||||
it('fails instead of emitting a fallback when no public URL is configured', () => {
|
||||
expect(() => getSitemapBaseURL({ NODE_ENV: 'production' })).toThrow(
|
||||
'Sitemap base URL is not configured.',
|
||||
)
|
||||
})
|
||||
|
||||
it('uses the normalized production origin for the robots sitemap URLs', () => {
|
||||
const require = createRequire(import.meta.url)
|
||||
const configPath = require.resolve('../../next-sitemap.config.cjs')
|
||||
|
||||
delete require.cache[configPath]
|
||||
process.env.NEXT_PUBLIC_SERVER_URL = `${productionURL}/deployment/path/`
|
||||
|
||||
const config = require(configPath) as {
|
||||
robotsTxtOptions: { additionalSitemaps: string[] }
|
||||
siteUrl: string
|
||||
}
|
||||
|
||||
expect(config.siteUrl).toBe(productionURL)
|
||||
expect(config.robotsTxtOptions.additionalSitemaps).toEqual([
|
||||
`${productionURL}/pages-sitemap.xml`,
|
||||
`${productionURL}/posts-sitemap.xml`,
|
||||
])
|
||||
})
|
||||
})
|
||||
@@ -7,6 +7,6 @@ export default defineConfig({
|
||||
test: {
|
||||
environment: 'jsdom',
|
||||
setupFiles: ['./vitest.setup.ts'],
|
||||
include: ['tests/int/**/*.int.spec.ts'],
|
||||
include: ['tests/int/**/*.int.spec.{ts,tsx}'],
|
||||
},
|
||||
})
|
||||
|
||||
Reference in New Issue
Block a user