Add migration scripts and preload participation awards script

- Created migration index file to manage database migrations.
- Added script to preload participation awards and related blog posts into the CMS.
- Introduced new types and default data for application phases.
- Added award articles data structure for the upcoming awards.
This commit is contained in:
syntaxbullet
2026-06-30 19:10:53 +02:00
parent 9545f2513d
commit 7ff405d1a2
47 changed files with 68728 additions and 986 deletions

View File

@@ -1,8 +1,8 @@
# Database connection string # SQLite database connection string
DATABASE_URL=mongodb://127.0.0.1/your-database-name DATABASE_URL=file:./content.db
# Or use a PG connection string # VPS production example:
#DATABASE_URL=postgresql://127.0.0.1:5432/your-database-name # DATABASE_URL=file:/var/www/bmp-website/shared/content.db
# Used to encrypt JWT tokens # Used to encrypt JWT tokens
PAYLOAD_SECRET=YOUR_SECRET_HERE PAYLOAD_SECRET=YOUR_SECRET_HERE

View File

@@ -1,71 +0,0 @@
# To use this Dockerfile, you have to set `output: 'standalone'` in your next.config.js file.
# From https://github.com/vercel/next.js/blob/canary/examples/with-docker/Dockerfile
FROM node:22.17.0-alpine AS base
# Install dependencies only when needed
FROM base AS deps
# Check https://github.com/nodejs/docker-node/tree/b4117f9333da4138b03a546ec926ef50a31506c3#nodealpine to understand why libc6-compat might be needed.
RUN apk add --no-cache libc6-compat
WORKDIR /app
# Install dependencies based on the preferred package manager
COPY package.json yarn.lock* package-lock.json* pnpm-lock.yaml* ./
RUN \
if [ -f yarn.lock ]; then yarn --frozen-lockfile; \
elif [ -f package-lock.json ]; then npm ci; \
elif [ -f pnpm-lock.yaml ]; then corepack enable pnpm && pnpm i --frozen-lockfile; \
else echo "Lockfile not found." && exit 1; \
fi
# Rebuild the source code only when needed
FROM base AS builder
WORKDIR /app
COPY --from=deps /app/node_modules ./node_modules
COPY . .
# Next.js collects completely anonymous telemetry data about general usage.
# Learn more here: https://nextjs.org/telemetry
# Uncomment the following line in case you want to disable telemetry during the build.
# ENV NEXT_TELEMETRY_DISABLED 1
RUN \
if [ -f yarn.lock ]; then yarn run build; \
elif [ -f package-lock.json ]; then npm run build; \
elif [ -f pnpm-lock.yaml ]; then corepack enable pnpm && pnpm run build; \
else echo "Lockfile not found." && exit 1; \
fi
# Production image, copy all the files and run next
FROM base AS runner
WORKDIR /app
ENV NODE_ENV production
# Uncomment the following line in case you want to disable telemetry during runtime.
# ENV NEXT_TELEMETRY_DISABLED 1
RUN addgroup --system --gid 1001 nodejs
RUN adduser --system --uid 1001 nextjs
# Remove this line if you do not have this folder
COPY --from=builder /app/public ./public
# Set the correct permission for prerender cache
RUN mkdir .next
RUN chown nextjs:nodejs .next
# Automatically leverage output traces to reduce image size
# https://nextjs.org/docs/advanced-features/output-file-tracing
COPY --from=builder --chown=nextjs:nodejs /app/.next/standalone ./
COPY --from=builder --chown=nextjs:nodejs /app/.next/static ./.next/static
USER nextjs
EXPOSE 3000
ENV PORT 3000
# server.js is created by next build from the standalone output
# https://nextjs.org/docs/pages/api-reference/next-config-js/output
CMD HOSTNAME="0.0.0.0" node server.js

View File

@@ -212,16 +212,6 @@ pnpm payload migrate
This command will check for any migrations that have not yet been run and try to run them and it will keep a record of migrations that have been run in the database. This command will check for any migrations that have not yet been run and try to run them and it will keep a record of migrations that have been run in the database.
### Docker
Alternatively, you can use [Docker](https://www.docker.com) to spin up this template locally. To do so, follow these steps:
1. Follow [steps 1 and 2 from above](#development), the docker-compose file will automatically use the `.env` file in your project root
1. Next run `docker-compose up`
1. Follow [steps 4 and 5 from above](#development) to login and create your first admin user
That's it! The Docker instance will help you get up and running quickly while also standardizing the development environment across your teams.
### Seed ### Seed
To seed the database with a few pages, posts, and projects you can click the 'seed database' link from the admin panel. To seed the database with a few pages, posts, and projects you can click the 'seed database' link from the admin panel.
@@ -294,7 +284,8 @@ There is also a simplified [one click deploy](https://github.com/payloadcms/payl
Before deploying your app, you need to: Before deploying your app, you need to:
1. Ensure your app builds and serves in production. See [Production](#production) for more details. 1. Ensure your app builds and serves in production. See [Production](#production) for more details.
2. You can then deploy Payload as you would any other Node.js or Next.js application either directly on a VPS, DigitalOcean's Apps Platform, via Coolify or more. More guides coming soon. 2. For VPS hosting without Docker, follow [docs/vps-deployment.md](docs/vps-deployment.md).
3. You can also deploy Payload as you would any other Node.js or Next.js application either directly on a VPS, DigitalOcean's Apps Platform, via Coolify or more.
You can also deploy your app manually, check out the [deployment documentation](https://payloadcms.com/docs/production/deployment) for full details. You can also deploy your app manually, check out the [deployment documentation](https://payloadcms.com/docs/production/deployment) for full details.

View File

@@ -1,31 +0,0 @@
version: '3'
services:
payload:
image: node:18-alpine
ports:
- '3000:3000'
volumes:
- .:/home/node/app
- node_modules:/home/node/app/node_modules
working_dir: /home/node/app/
command: sh -c "yarn install && yarn dev"
depends_on:
- mongo
env_file:
- .env
mongo:
image: mongo:latest
ports:
- '27017:27017'
command:
- --storageEngine=wiredTiger
volumes:
- data:/data/db
logging:
driver: none
volumes:
data:
node_modules:

218
docs/vps-deployment.md Normal file
View File

@@ -0,0 +1,218 @@
# VPS deployment
This project is a Node/Next/Payload application that currently uses:
- SQLite via `DATABASE_URL`
- local filesystem media in `public/media`
- no Docker
Treat code and persistent state separately. Git or rsync can move the code, but
the database and media directory must be copied and backed up explicitly.
## Persistent paths
Use one stable directory on the VPS:
```bash
APP_ROOT=/var/www/bmp-website
APP_USER=deploy
```
Recommended layout:
```text
/var/www/bmp-website/
current/ # synced application code
shared/
.env # production secrets
content.db # SQLite database
media/ # Payload uploads
backups/
```
Create it on the VPS:
```bash
sudo mkdir -p "$APP_ROOT"/{current,shared/media,backups}
sudo chown -R "$APP_USER":"$APP_USER" "$APP_ROOT"
```
## Production environment
Create `/var/www/bmp-website/shared/.env` on the VPS:
```dotenv
DATABASE_URL=file:/var/www/bmp-website/shared/content.db
PAYLOAD_SECRET=replace-with-existing-secret
NEXT_PUBLIC_SERVER_URL=https://example.com
CRON_SECRET=replace-with-existing-secret
PREVIEW_SECRET=replace-with-existing-secret
PORT=3000
NODE_ENV=production
```
Keep `PAYLOAD_SECRET`, `CRON_SECRET`, and `PREVIEW_SECRET` stable once the site
is live. Do not commit the production `.env`.
`NEXT_PUBLIC_SERVER_URL` is read during the Next build, so set it correctly
before running `npm run build`.
If you already have the correct local `.env`, transfer it separately from the
application code and lock down its permissions:
```bash
scp .env "$SSH_TARGET:$APP_ROOT/shared/.env"
ssh "$SSH_TARGET" "chmod 600 $APP_ROOT/shared/.env"
```
If you need to package secrets in an archive, make that archive secret-only and
encrypt it before transfer:
```bash
tar -czf bmp-website-secrets.tar.gz .env
gpg -c bmp-website-secrets.tar.gz
scp bmp-website-secrets.tar.gz.gpg "$SSH_TARGET:$APP_ROOT/shared/"
```
Then decrypt it on the VPS, move `.env` into place, and remove the temporary
archive files.
## Initial transfer
Run these locally from the repository root. Replace the SSH target and domain
values first.
```bash
SSH_TARGET=deploy@example.com
APP_ROOT=/var/www/bmp-website
```
Stop any local dev server before copying `content.db`.
Copy persistent state once:
```bash
rsync -az content.db "$SSH_TARGET:$APP_ROOT/shared/content.db"
rsync -az public/media/ "$SSH_TARGET:$APP_ROOT/shared/media/"
```
Copy application code without local state or build output:
```bash
rsync -az --delete \
--exclude '.env' \
--exclude '.next/' \
--exclude 'node_modules/' \
--exclude 'content.db' \
--exclude 'public/media/' \
./ "$SSH_TARGET:$APP_ROOT/current/"
```
Link the shared media directory into the app:
```bash
ssh "$SSH_TARGET" "mkdir -p $APP_ROOT/current/public && rm -rf $APP_ROOT/current/public/media && ln -s $APP_ROOT/shared/media $APP_ROOT/current/public/media"
ssh "$SSH_TARGET" "ln -sfn $APP_ROOT/shared/.env $APP_ROOT/current/.env"
```
Do not use `--delete` when syncing media unless the source is intentionally the
source of truth and you have a current backup.
## Build on the VPS
Install Node.js 22 or another version accepted by `package.json`, then run:
```bash
cd /var/www/bmp-website/current
npm ci
npm run build
```
## systemd service
Create `/etc/systemd/system/bmp-website.service`:
```ini
[Unit]
Description=BMP website
After=network.target
[Service]
Type=simple
User=deploy
Group=deploy
WorkingDirectory=/var/www/bmp-website/current
EnvironmentFile=/var/www/bmp-website/shared/.env
ExecStart=/usr/bin/env npm run start
Restart=always
RestartSec=5
[Install]
WantedBy=multi-user.target
```
Enable and start it:
```bash
sudo systemctl daemon-reload
sudo systemctl enable --now bmp-website
sudo systemctl status bmp-website --no-pager
```
## nginx reverse proxy
Example server block:
```nginx
server {
listen 80;
server_name example.com www.example.com;
client_max_body_size 25m;
location / {
proxy_pass http://127.0.0.1:3000;
proxy_http_version 1.1;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection "upgrade";
}
}
```
After enabling the site, issue TLS certificates with your normal ACME client
such as Certbot.
## Repeat deploys
Before every deploy, back up the live SQLite database on the VPS:
```bash
APP_ROOT=/var/www/bmp-website
sqlite3 "$APP_ROOT/shared/content.db" ".backup '$APP_ROOT/backups/content-$(date +%Y%m%d-%H%M%S).db'"
```
Then sync code, rebuild, and restart:
```bash
rsync -az --delete \
--exclude '.env' \
--exclude '.next/' \
--exclude 'node_modules/' \
--exclude 'content.db' \
--exclude 'public/media/' \
./ "$SSH_TARGET:$APP_ROOT/current/"
ssh "$SSH_TARGET" "cd $APP_ROOT/current && npm ci && npm run build && sudo systemctl restart bmp-website"
```
Verify:
```bash
ssh "$SSH_TARGET" "systemctl status bmp-website --no-pager"
curl -I https://example.com
curl -I https://example.com/admin
```

View File

@@ -29,6 +29,7 @@
"preload:impressum-page-cms": "cross-env NODE_OPTIONS=--no-deprecation tsx src/scripts/preload-impressum-page-cms.ts", "preload:impressum-page-cms": "cross-env NODE_OPTIONS=--no-deprecation tsx src/scripts/preload-impressum-page-cms.ts",
"preload:mitglied-werden-page-cms": "cross-env NODE_OPTIONS=--no-deprecation tsx src/scripts/preload-mitglied-werden-page-cms.ts", "preload:mitglied-werden-page-cms": "cross-env NODE_OPTIONS=--no-deprecation tsx src/scripts/preload-mitglied-werden-page-cms.ts",
"preload:netzwerk-page-cms": "cross-env NODE_OPTIONS=--no-deprecation tsx src/scripts/preload-netzwerk-page-cms.ts", "preload:netzwerk-page-cms": "cross-env NODE_OPTIONS=--no-deprecation tsx src/scripts/preload-netzwerk-page-cms.ts",
"preload:participation-awards-cms": "cross-env NODE_OPTIONS=--no-deprecation tsx src/scripts/preload-participation-awards-cms.ts",
"preload:participation-page-cms": "cross-env NODE_OPTIONS=--no-deprecation tsx src/scripts/preload-participation-page-cms.ts", "preload:participation-page-cms": "cross-env NODE_OPTIONS=--no-deprecation tsx src/scripts/preload-participation-page-cms.ts",
"preload:preistraeger-detail-cms": "cross-env NODE_OPTIONS=--no-deprecation tsx src/scripts/preload-preistraeger-detail-cms.ts", "preload:preistraeger-detail-cms": "cross-env NODE_OPTIONS=--no-deprecation tsx src/scripts/preload-preistraeger-detail-cms.ts",
"preload:preistraeger-index-page-cms": "cross-env NODE_OPTIONS=--no-deprecation tsx src/scripts/preload-preistraeger-index-page-cms.ts", "preload:preistraeger-index-page-cms": "cross-env NODE_OPTIONS=--no-deprecation tsx src/scripts/preload-preistraeger-index-page-cms.ts",

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.8 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 14 KiB

View File

@@ -0,0 +1,81 @@
import type { Field, GlobalConfig } from 'payload'
import { defaultApplicationPhaseData } from '@/spa/applicationPhase'
const text = (name: string, label: string, defaultValue?: string | null): Field => ({
name,
type: 'text',
label,
defaultValue: defaultValue || undefined,
})
const textarea = (name: string, label: string, defaultValue?: string | null): Field => ({
name,
type: 'textarea',
label,
defaultValue: defaultValue || undefined,
})
const linkGroup = (labelDefault = '', urlDefault = ''): Field[] => [
text('label', 'Button text', labelDefault),
text('url', 'Link URL', urlDefault),
]
const phaseFields: Field[] = [
text('num', 'Phase number'),
text('tag', 'Badge'),
{ name: 'pulse', label: 'Show pulse dot', type: 'checkbox', defaultValue: true },
text('phase', 'Phase label'),
text('headline', 'Headline'),
textarea('body', 'Body copy'),
{ name: 'cta', label: 'Primary CTA', type: 'group', fields: linkGroup() },
text('accent', 'Accent color'),
text('bg', 'Background color'),
text('glow', 'Glow color'),
text('meta', 'Meta label'),
text('successApplications', 'Success applications number'),
text('successWinners', 'Success winners number'),
{
name: 'membershipCta',
label: 'Membership CTA',
type: 'group',
fields: linkGroup('Mitglied werden', '/mitglied-werden'),
},
]
export const ApplicationPhase: GlobalConfig = {
slug: 'application-phase',
access: {
read: () => true,
},
admin: {
group: 'Site Settings',
},
fields: [
{
name: 'activePhase',
label: 'Current phase',
type: 'select',
defaultValue: defaultApplicationPhaseData.activePhase,
options: [
{ label: '01 · Bewerbungsphase offen', value: '0' },
{ label: '02 · Auswertung / Jury bewertet', value: '1' },
{ label: '03 · Preisverleihung abgeschlossen', value: '2' },
],
admin: {
description: 'Controls phase-dependent frontend rendering across the site.',
},
},
{
name: 'phases',
label: 'Phase content',
type: 'array',
maxRows: 3,
defaultValue: defaultApplicationPhaseData.phases,
fields: phaseFields,
},
text('noActionLabel', 'No CTA fallback label', defaultApplicationPhaseData.noActionLabel),
text('successApplicationsLabel', 'Success applications label', defaultApplicationPhaseData.successApplicationsLabel),
text('successWinnersLabel', 'Success winners label', defaultApplicationPhaseData.successWinnersLabel),
],
}

View File

@@ -51,8 +51,6 @@ export const Header: GlobalConfig = {
{ name: 'closeLabel', type: 'text', defaultValue: defaultHeaderData.closeLabel, required: true }, { name: 'closeLabel', type: 'text', defaultValue: defaultHeaderData.closeLabel, required: true },
{ name: 'dialogLabel', type: 'text', defaultValue: defaultHeaderData.dialogLabel, required: true }, { name: 'dialogLabel', type: 'text', defaultValue: defaultHeaderData.dialogLabel, required: true },
{ name: 'overviewLabel', type: 'text', defaultValue: defaultHeaderData.overviewLabel, required: true }, { name: 'overviewLabel', type: 'text', defaultValue: defaultHeaderData.overviewLabel, required: true },
{ name: 'loadingBrand', type: 'text', defaultValue: defaultHeaderData.loadingBrand, required: true },
{ name: 'loadingLabel', type: 'text', defaultValue: defaultHeaderData.loadingLabel, required: true },
], ],
}, },
{ {
@@ -103,7 +101,7 @@ export const Header: GlobalConfig = {
options: [ options: [
{ label: 'Primary gold button', value: 'primary' }, { label: 'Primary gold button', value: 'primary' },
{ label: 'Secondary outline button', value: 'secondary' }, { label: 'Secondary outline button', value: 'secondary' },
{ label: 'Upload dashed link', value: 'upload' }, { label: 'Login system link', value: 'login' },
], ],
}, },
], ],
@@ -113,7 +111,7 @@ export const Header: GlobalConfig = {
], ],
}, },
{ {
label: 'Social & Language', label: 'Social',
fields: [ fields: [
{ {
name: 'socialLinks', name: 'socialLinks',
@@ -122,13 +120,6 @@ export const Header: GlobalConfig = {
fields: socialFields, fields: socialFields,
admin: { initCollapsed: true }, admin: { initCollapsed: true },
}, },
{
name: 'languages',
type: 'array',
defaultValue: defaultHeaderData.languages.map((label) => ({ label })),
fields: [{ name: 'label', type: 'text', required: true }],
maxRows: 4,
},
], ],
}, },
], ],

View File

@@ -5,6 +5,7 @@ import { draftMode } from 'next/headers'
import { notFound } from 'next/navigation' import { notFound } from 'next/navigation'
import NextApp from '@/spa/NextApp' import NextApp from '@/spa/NextApp'
import { normalizeApplicationPhaseData, type SpaApplicationPhaseData } from '@/spa/applicationPhase'
import { normalizeFooterData, normalizeHeaderData } from '@/spa/cmsNavigation' import { normalizeFooterData, normalizeHeaderData } from '@/spa/cmsNavigation'
import type { CmsRouteDoc } from '@/spa/cmsRoute' import type { CmsRouteDoc } from '@/spa/cmsRoute'
import { normalizeSiteSettings, type SpaSiteSettingsData } from '@/spa/siteSettings' import { normalizeSiteSettings, type SpaSiteSettingsData } from '@/spa/siteSettings'
@@ -69,16 +70,34 @@ async function findList(payload: Awaited<ReturnType<typeof getPayload>>, collect
return result.docs return result.docs
} }
async function findHomePage(payload: Awaited<ReturnType<typeof getPayload>>, draft: boolean) {
const result = await payload.find({
collection: 'pages',
depth: 1,
draft,
overrideAccess: draft,
limit: 1,
pagination: false,
where: {
or: [{ spaPath: { equals: '/' } }, { slug: { equals: 'startseite' } }, { slug: { equals: 'home' } }],
},
})
return result.docs[0]
}
export default async function Page({ params }: Args) { export default async function Page({ params }: Args) {
const { slug = [] } = await params const { slug = [] } = await params
const pathname = `/${slug.join('/')}`.replace(/\/$/, '') || '/' const pathname = `/${slug.join('/')}`.replace(/\/$/, '') || '/'
const payload = await getPayload({ config: configPromise }) const payload = await getPayload({ config: configPromise })
const { isEnabled: draft } = await draftMode() const { isEnabled: draft } = await draftMode()
const [header, footer, siteSettings, route, preistraeger, events, posts] = await Promise.all([ const [header, footer, siteSettings, applicationPhase, route, homePage, preistraeger, events, posts] = await Promise.all([
payload.findGlobal({ slug: 'header', depth: 1 }).catch(() => undefined), payload.findGlobal({ slug: 'header', depth: 1 }).catch(() => undefined),
payload.findGlobal({ slug: 'footer', depth: 1 }).catch(() => undefined), payload.findGlobal({ slug: 'footer', depth: 1 }).catch(() => undefined),
payload.findGlobal({ slug: 'site-settings', depth: 1 }).catch(() => undefined), payload.findGlobal({ slug: 'site-settings', depth: 1 }).catch(() => undefined),
payload.findGlobal({ slug: 'application-phase', depth: 0 }).catch(() => undefined),
findRouteDoc(payload, pathname, draft), findRouteDoc(payload, pathname, draft),
findHomePage(payload, draft).catch(() => undefined),
findList(payload, 'preistraeger', draft).catch(() => []), findList(payload, 'preistraeger', draft).catch(() => []),
findList(payload, 'events', draft).catch(() => []), findList(payload, 'events', draft).catch(() => []),
findList(payload, 'posts', draft).catch(() => []), findList(payload, 'posts', draft).catch(() => []),
@@ -92,10 +111,12 @@ export default async function Page({ params }: Args) {
header={normalizeHeaderData(header)} header={normalizeHeaderData(header)}
footer={normalizeFooterData(footer)} footer={normalizeFooterData(footer)}
siteSettings={normalizeSiteSettings(siteSettings as Partial<SpaSiteSettingsData> | undefined)} siteSettings={normalizeSiteSettings(siteSettings as Partial<SpaSiteSettingsData> | undefined)}
applicationPhase={normalizeApplicationPhaseData(applicationPhase as Partial<SpaApplicationPhaseData> | undefined)}
cmsRoute={{ cmsRoute={{
collection: route.collection, collection: route.collection,
doc: route.doc as unknown as CmsRouteDoc, doc: route.doc as unknown as CmsRouteDoc,
lists: { lists: {
pages: homePage ? [homePage as unknown as CmsRouteDoc] : [],
preistraeger: preistraeger as unknown as CmsRouteDoc[], preistraeger: preistraeger as unknown as CmsRouteDoc[],
events: events as unknown as CmsRouteDoc[], events: events as unknown as CmsRouteDoc[],
posts: posts as unknown as CmsRouteDoc[], posts: posts as unknown as CmsRouteDoc[],

View File

@@ -103,7 +103,7 @@ export const homeFields: Field[] = [
name: 'quickCheck', name: 'quickCheck',
label: '02 · Schnell-Check eligibility section', label: '02 · Schnell-Check eligibility section',
type: 'group', type: 'group',
admin: sectionAdmin('Cream text panel plus right-side stage image and five eligibility rows.'), admin: sectionAdmin('White text panel plus right-side stage image and five eligibility rows.'),
fields: [ fields: [
text('eyebrow', 'Eyebrow', homeContent.quickCheck.eyebrow), text('eyebrow', 'Eyebrow', homeContent.quickCheck.eyebrow),
textarea('heading', 'Heading', homeContent.quickCheck.heading), textarea('heading', 'Heading', homeContent.quickCheck.heading),
@@ -155,55 +155,9 @@ export const homeFields: Field[] = [
{ name: 'cta', label: 'CTA', type: 'group', fields: linkGroup(homeContent.intro.cta.label, homeContent.intro.cta.url) }, { name: 'cta', label: 'CTA', type: 'group', fields: linkGroup(homeContent.intro.cta.label, homeContent.intro.cta.url) },
], ],
}, },
{
name: 'status',
label: '04 · Application phase slider',
type: 'group',
admin: sectionAdmin('Three-slide status module shown after the award intro.'),
fields: [
{
name: 'activePhase',
label: 'Current phase shown on the homepage',
type: 'select',
defaultValue: homeContent.status.activePhase,
options: [
{ label: '01 · Bewerbungsphase offen', value: '0' },
{ label: '02 · Auswertung / Jury bewertet', value: '1' },
{ label: '03 · Preisverleihung abgeschlossen', value: '2' },
],
admin: { description: 'Only this selected phase is rendered on the frontend; visitors do not see a slider.' },
},
{
name: 'phases',
label: 'Slides',
type: 'array',
maxRows: 3,
defaultValue: homeContent.status.phases,
fields: [
text('num', 'Slide number'),
text('tag', 'Badge'),
{ name: 'pulse', label: 'Show pulse dot', type: 'checkbox', defaultValue: true },
text('phase', 'Phase label'),
text('headline', 'Headline'),
textarea('body', 'Body copy'),
{ name: 'cta', label: 'Slide CTA', type: 'group', fields: linkGroup('', '') },
text('accent', 'Accent color'),
text('bg', 'Background color'),
text('glow', 'Glow color'),
text('meta', 'Meta label'),
text('successApplications', 'Success applications number'),
text('successWinners', 'Success winners number'),
{ name: 'membershipCta', label: 'Membership CTA', type: 'group', fields: linkGroup('Mitglied werden', '/mitglied-werden') },
],
},
text('noActionLabel', 'No CTA fallback label', homeContent.status.noActionLabel),
text('successApplicationsLabel', 'Success applications label', homeContent.status.successApplicationsLabel),
text('successWinnersLabel', 'Success winners label', homeContent.status.successWinnersLabel),
],
},
{ {
name: 'winners', name: 'winners',
label: '05 · Preisträger card grid', label: '04 · Preisträger card grid',
type: 'group', type: 'group',
admin: sectionAdmin('Blue card grid with featured winner relationships.'), admin: sectionAdmin('Blue card grid with featured winner relationships.'),
fields: [ fields: [
@@ -225,7 +179,7 @@ export const homeFields: Field[] = [
}, },
{ {
name: 'testimonials', name: 'testimonials',
label: '06 · Testimonials carousel', label: '05 · Testimonials carousel',
type: 'group', type: 'group',
admin: sectionAdmin('Portrait carousel and quote copy.'), admin: sectionAdmin('Portrait carousel and quote copy.'),
fields: [ fields: [
@@ -247,9 +201,9 @@ export const homeFields: Field[] = [
}, },
{ {
name: 'benefits', name: 'benefits',
label: '07 · Participation benefits', label: '06 · Participation benefits',
type: 'group', type: 'group',
admin: sectionAdmin('Cream left panel and networking image.'), admin: sectionAdmin('White left panel and networking image.'),
fields: [ fields: [
text('eyebrow', 'Eyebrow', homeContent.benefits.eyebrow), text('eyebrow', 'Eyebrow', homeContent.benefits.eyebrow),
textarea('heading', 'Heading', homeContent.benefits.heading), textarea('heading', 'Heading', homeContent.benefits.heading),
@@ -263,7 +217,7 @@ export const homeFields: Field[] = [
}, },
{ {
name: 'application', name: 'application',
label: '08 · Application form section', label: '07 · Application form section',
type: 'group', type: 'group',
admin: sectionAdmin('Final application pitch, award image, embedded form heading, and footnote.'), admin: sectionAdmin('Final application pitch, award image, embedded form heading, and footnote.'),
fields: [ fields: [
@@ -282,7 +236,7 @@ export const homeFields: Field[] = [
}, },
{ {
name: 'form', name: 'form',
label: '09 · Embedded application form copy and logic labels', label: '08 · Embedded application form copy and logic labels',
type: 'group', type: 'group',
admin: sectionAdmin('Text, options, and eligibility thresholds used inside the home page multi-step application form.'), admin: sectionAdmin('Text, options, and eligibility thresholds used inside the home page multi-step application form.'),
fields: [ fields: [
@@ -398,10 +352,28 @@ export const homeFields: Field[] = [
}, },
{ {
name: 'videoModal', name: 'videoModal',
label: '10 · Video modal placeholder', label: '10 · Video modal',
type: 'group', type: 'group',
admin: sectionAdmin('Copy shown when the hero video button is clicked.'), admin: sectionAdmin('Video and fallback copy shown when the hero video button is clicked.'),
fields: [text('title', 'Title', homeContent.videoModal.title), text('description', 'Description', homeContent.videoModal.description), text('closeLabel', 'Close button label', homeContent.videoModal.closeLabel)], fields: [
{
name: 'video',
type: 'upload',
relationTo: 'media',
label: 'Video',
filterOptions: {
mimeType: {
contains: 'video',
},
},
admin: {
description: 'Choose a video from the media collection. If empty, the frontend shows the fallback copy below.',
},
},
text('title', 'Title', homeContent.videoModal.title),
text('description', 'Description', homeContent.videoModal.description),
text('closeLabel', 'Close button label', homeContent.videoModal.closeLabel),
],
}, },
], ],
}, },

View File

@@ -76,6 +76,7 @@ const ctaGroup = (name: string, label: string, labelDefault: string, urlDefault:
type: 'group', type: 'group',
fields: linkGroup(labelDefault, urlDefault), fields: linkGroup(labelDefault, urlDefault),
}) })
const awardCardDefaults = participationContent.awardsGrid.cards.map(({ imageFilename: _imageFilename, ...card }) => card)
export const participationFields: Field[] = [ export const participationFields: Field[] = [
{ {
@@ -153,31 +154,9 @@ export const participationFields: Field[] = [
ctaGroup('nudgeCta', 'Bottom CTA', participationContent.eligibility.nudgeCta.label, participationContent.eligibility.nudgeCta.url), ctaGroup('nudgeCta', 'Bottom CTA', participationContent.eligibility.nudgeCta.label, participationContent.eligibility.nudgeCta.url),
], ],
}, },
{
name: 'evaluation',
label: '04 · Criteria / evaluation section',
type: 'group',
admin: sectionAdmin('Navy evaluation section with image, link, and criteria grid.'),
fields: [
uploadField('image', 'Image', `Current frontend image: /images/${participationContent.evaluation.imageFilename}`),
text('imageAlt', 'Image alt text', participationContent.evaluation.imageAlt),
text('eyebrow', 'Eyebrow', participationContent.evaluation.eyebrow),
textarea('heading', 'Heading', participationContent.evaluation.heading),
textarea('body', 'Body copy', participationContent.evaluation.body),
ctaGroup('link', 'External criteria link', participationContent.evaluation.link.label, participationContent.evaluation.link.url),
{
name: 'criteria',
label: 'Evaluation criteria',
type: 'array',
dbName: 'eval_crit',
defaultValue: participationContent.evaluation.criteria,
fields: factFields,
},
],
},
{ {
name: 'applicationWays', name: 'applicationWays',
label: '05 · Application paths', label: '04 · Application paths',
type: 'group', type: 'group',
admin: sectionAdmin('Cream section explaining proposal, self-application, and special award paths.'), admin: sectionAdmin('Cream section explaining proposal, self-application, and special award paths.'),
fields: [ fields: [
@@ -236,7 +215,7 @@ export const participationFields: Field[] = [
}, },
{ {
name: 'datesBanner', name: 'datesBanner',
label: '06 · Dates banner', label: '05 · Dates banner',
type: 'group', type: 'group',
admin: sectionAdmin('Gold important-dates banner. Mobile and desktop labels differ slightly in the current design.'), admin: sectionAdmin('Gold important-dates banner. Mobile and desktop labels differ slightly in the current design.'),
fields: [ fields: [
@@ -260,6 +239,33 @@ export const participationFields: Field[] = [
}, },
], ],
}, },
{
name: 'awardsGrid',
label: '06 · Awards grid',
type: 'group',
admin: sectionAdmin('Three-column awards grid rendered directly after the dates banner.'),
fields: [
text('eyebrow', 'Eyebrow', participationContent.awardsGrid.eyebrow),
textarea('heading', 'Heading', participationContent.awardsGrid.heading),
textarea('description', 'Intro paragraph', participationContent.awardsGrid.description),
{
name: 'cards',
label: 'Award cards',
type: 'array',
dbName: 'award_cards',
defaultValue: awardCardDefaults,
fields: [
text('label', 'Card label'),
uploadField('image', 'Card image'),
text('imageAlt', 'Image alt text'),
textarea('title', 'Title'),
textarea('description', 'Description'),
ctaGroup('articleCta', 'Article CTA', 'Artikel lesen', '/presse'),
ctaGroup('mailtoCta', 'Mail CTA', 'Kontakt aufnehmen', 'mailto:info@bmp-bayern.de'),
],
},
],
},
{ {
name: 'applicationForm', name: 'applicationForm',
label: '07 · Application form wrapper', label: '07 · Application form wrapper',

View File

@@ -50,7 +50,7 @@ export const preistraegerIndexFields: Field[] = [
name: 'hero', name: 'hero',
label: '01 · Hero image', label: '01 · Hero image',
type: 'group', type: 'group',
admin: sectionAdmin('Large image above the filters. Upload an image or keep the migrated URL fallback.'), admin: sectionAdmin('Large image above the year selector. Upload an image or keep the migrated URL fallback.'),
fields: [ fields: [
uploadField('image', 'Hero image'), uploadField('image', 'Hero image'),
text('imageUrl', 'Fallback image URL', preistraegerIndexContent.hero.imageUrl), text('imageUrl', 'Fallback image URL', preistraegerIndexContent.hero.imageUrl),
@@ -64,37 +64,23 @@ export const preistraegerIndexFields: Field[] = [
admin: sectionAdmin('Small page label below the image.'), admin: sectionAdmin('Small page label below the image.'),
fields: [text('label', 'Label', preistraegerIndexContent.breadcrumb.label)], fields: [text('label', 'Label', preistraegerIndexContent.breadcrumb.label)],
}, },
{
name: 'filters',
label: '03 · Filters',
type: 'group',
admin: sectionAdmin('Filter placeholders, toggle label, and reset label.'),
fields: [
text('categoryPlaceholder', 'Category placeholder', preistraegerIndexContent.filters.categoryPlaceholder),
text('searchPlaceholder', 'Search placeholder', preistraegerIndexContent.filters.searchPlaceholder),
textarea('storyHeading', 'Story heading', preistraegerIndexContent.filters.storyHeading),
text('mediaIcon', 'Media icon', preistraegerIndexContent.filters.mediaIcon),
text('mediaLabel', 'Media label', preistraegerIndexContent.filters.mediaLabel),
text('resetLabel', 'Reset label', preistraegerIndexContent.filters.resetLabel),
],
},
{ {
name: 'count', name: 'count',
label: '04 · Count bar', label: '03 · Count bar',
type: 'group', type: 'group',
admin: sectionAdmin('Label after the filtered result count.'), admin: sectionAdmin('Label after the selected-year result count.'),
fields: [text('label', 'Count label', preistraegerIndexContent.count.label)], fields: [text('label', 'Count label', preistraegerIndexContent.count.label)],
}, },
{ {
name: 'empty', name: 'empty',
label: '05 · Empty state', label: '04 · Empty state',
type: 'group', type: 'group',
admin: sectionAdmin('Message when filters return no winners.'), admin: sectionAdmin('Message when the selected year returns no winners.'),
fields: [textarea('message', 'Empty message', preistraegerIndexContent.empty.message)], fields: [textarea('message', 'Empty message', preistraegerIndexContent.empty.message)],
}, },
{ {
name: 'card', name: 'card',
label: '06 · Winner cards', label: '05 · Winner cards',
type: 'group', type: 'group',
admin: sectionAdmin('Hover label shown on winner cards.'), admin: sectionAdmin('Hover label shown on winner cards.'),
fields: [ fields: [
@@ -116,7 +102,7 @@ export const preistraegerIndexFields: Field[] = [
}, },
{ {
name: 'cta', name: 'cta',
label: '07 · Bottom CTA', label: '06 · Bottom CTA',
type: 'group', type: 'group',
admin: sectionAdmin('Bottom application and membership CTA.'), admin: sectionAdmin('Bottom application and membership CTA.'),
fields: [ fields: [

View File

@@ -9,6 +9,7 @@ import { imageHero1 } from './image-hero-1'
import { post1 } from './post-1' import { post1 } from './post-1'
import { post2 } from './post-2' import { post2 } from './post-2'
import { post3 } from './post-3' import { post3 } from './post-3'
import { defaultApplicationPhaseData } from '@/spa/applicationPhase'
import { defaultFooterData, defaultHeaderData } from '@/spa/cmsNavigation' import { defaultFooterData, defaultHeaderData } from '@/spa/cmsNavigation'
import { defaultSiteSettings } from '@/spa/siteSettings' import { defaultSiteSettings } from '@/spa/siteSettings'
@@ -22,7 +23,7 @@ const collections: CollectionSlug[] = [
'search', 'search',
] ]
const globals: GlobalSlug[] = ['header', 'footer', 'site-settings'] const globals: GlobalSlug[] = ['header', 'footer', 'site-settings', 'application-phase']
const categories = ['Technology', 'News', 'Finance', 'Design', 'Software', 'Engineering'] const categories = ['Technology', 'News', 'Finance', 'Design', 'Software', 'Engineering']
@@ -233,6 +234,10 @@ export const seed = async ({
slug: 'site-settings', slug: 'site-settings',
data: defaultSiteSettings as never, data: defaultSiteSettings as never,
}), }),
payload.updateGlobal({
slug: 'application-phase',
data: defaultApplicationPhaseData as never,
}),
]) ])
payload.logger.info('Seeded database successfully!') payload.logger.info('Seeded database successfully!')

File diff suppressed because one or more lines are too long

View File

@@ -0,0 +1,17 @@
import { MigrateDownArgs, MigrateUpArgs, sql } from '@payloadcms/db-sqlite'
export async function up({ db }: MigrateUpArgs): Promise<void> {
await db.run(sql`ALTER TABLE \`pages\` ADD \`home_video_modal_video_id\` integer REFERENCES \`media\`(\`id\`) ON UPDATE no action ON DELETE set null;`)
await db.run(sql`CREATE INDEX \`pages_home_video_modal_home_video_modal_video_idx\` ON \`pages\` (\`home_video_modal_video_id\`);`)
await db.run(sql`ALTER TABLE \`_pages_v\` ADD \`version_home_video_modal_video_id\` integer REFERENCES \`media\`(\`id\`) ON UPDATE no action ON DELETE set null;`)
await db.run(sql`CREATE INDEX \`_pages_v_version_home_video_modal_version_home_video_mod_idx\` ON \`_pages_v\` (\`version_home_video_modal_video_id\`);`)
}
export async function down({ db }: MigrateDownArgs): Promise<void> {
await db.run(sql`DROP INDEX IF EXISTS \`_pages_v_version_home_video_modal_version_home_video_mod_idx\`;`)
await db.run(sql`ALTER TABLE \`_pages_v\` DROP COLUMN \`version_home_video_modal_video_id\`;`)
await db.run(sql`DROP INDEX IF EXISTS \`pages_home_video_modal_home_video_modal_video_idx\`;`)
await db.run(sql`ALTER TABLE \`pages\` DROP COLUMN \`home_video_modal_video_id\`;`)
}

File diff suppressed because one or more lines are too long

View File

@@ -0,0 +1,326 @@
import { MigrateUpArgs, MigrateDownArgs, sql } from '@payloadcms/db-sqlite'
export async function up({ db, payload, req }: MigrateUpArgs): Promise<void> {
await db.run(sql`CREATE TABLE \`award_cards\` (
\`_order\` integer NOT NULL,
\`_parent_id\` integer NOT NULL,
\`id\` text PRIMARY KEY NOT NULL,
\`label\` text,
\`image_id\` integer,
\`image_alt\` text,
\`title\` text,
\`description\` text,
\`article_cta_label\` text DEFAULT 'Artikel lesen',
\`article_cta_url\` text DEFAULT '/presse',
\`mailto_cta_label\` text DEFAULT 'Kontakt aufnehmen',
\`mailto_cta_url\` text DEFAULT 'mailto:info@bmp-bayern.de',
FOREIGN KEY (\`image_id\`) REFERENCES \`media\`(\`id\`) ON UPDATE no action ON DELETE set null,
FOREIGN KEY (\`_parent_id\`) REFERENCES \`pages\`(\`id\`) ON UPDATE no action ON DELETE cascade
);
`)
await db.run(sql`CREATE INDEX \`award_cards_order_idx\` ON \`award_cards\` (\`_order\`);`)
await db.run(sql`CREATE INDEX \`award_cards_parent_id_idx\` ON \`award_cards\` (\`_parent_id\`);`)
await db.run(sql`CREATE INDEX \`award_cards_image_idx\` ON \`award_cards\` (\`image_id\`);`)
await db.run(sql`CREATE TABLE \`_award_cards_v\` (
\`_order\` integer NOT NULL,
\`_parent_id\` integer NOT NULL,
\`id\` integer PRIMARY KEY NOT NULL,
\`label\` text,
\`image_id\` integer,
\`image_alt\` text,
\`title\` text,
\`description\` text,
\`article_cta_label\` text DEFAULT 'Artikel lesen',
\`article_cta_url\` text DEFAULT '/presse',
\`mailto_cta_label\` text DEFAULT 'Kontakt aufnehmen',
\`mailto_cta_url\` text DEFAULT 'mailto:info@bmp-bayern.de',
\`_uuid\` text,
FOREIGN KEY (\`image_id\`) REFERENCES \`media\`(\`id\`) ON UPDATE no action ON DELETE set null,
FOREIGN KEY (\`_parent_id\`) REFERENCES \`_pages_v\`(\`id\`) ON UPDATE no action ON DELETE cascade
);
`)
await db.run(sql`CREATE INDEX \`_award_cards_v_order_idx\` ON \`_award_cards_v\` (\`_order\`);`)
await db.run(sql`CREATE INDEX \`_award_cards_v_parent_id_idx\` ON \`_award_cards_v\` (\`_parent_id\`);`)
await db.run(sql`CREATE INDEX \`_award_cards_v_image_idx\` ON \`_award_cards_v\` (\`image_id\`);`)
await db.run(sql`PRAGMA foreign_keys=OFF;`)
await db.run(sql`CREATE TABLE \`__new_posts\` (
\`id\` integer PRIMARY KEY NOT NULL,
\`title\` text,
\`hero_image_id\` integer,
\`excerpt\` text,
\`cat\` text,
\`date\` text,
\`author_name\` text,
\`author_role\` text,
\`read_time\` text,
\`detail_page_fallback_image_id\` integer,
\`detail_page_not_found_title\` text DEFAULT 'Artikel nicht gefunden',
\`detail_page_not_found_back_label\` text DEFAULT 'Zurück zur Presse',
\`detail_page_not_found_back_url\` text DEFAULT '/presse',
\`detail_page_hero_back_link_label\` text DEFAULT 'Newsroom',
\`detail_page_hero_back_link_url\` text DEFAULT '/presse',
\`detail_page_fallback_article_title\` text DEFAULT 'Presse',
\`detail_page_fallback_article_category\` text DEFAULT 'Presse',
\`detail_page_fallback_article_date\` text DEFAULT 'Aktuell',
\`detail_page_fallback_article_author_name\` text DEFAULT 'BMP Redaktion',
\`detail_page_fallback_article_author_role\` text DEFAULT 'Presse',
\`detail_page_fallback_article_read_time\` text DEFAULT '1 min',
\`detail_page_fallback_article_excerpt\` text DEFAULT 'Dieser Pressebeitrag wird aus Payload CMS geladen.',
\`detail_page_fallback_article_tag\` text DEFAULT 'Presse',
\`detail_page_category_colors\` text DEFAULT '{"Auszeichnung":"#111D55","Innovation":"#2563EB","Engagement":"#16A34A","Wirtschaft":"#9333EA"}',
\`detail_page_meta_copy_read_time_suffix\` text DEFAULT 'Lesezeit',
\`detail_page_sidebar_cta_heading\` text DEFAULT 'Jetzt bewerben',
\`detail_page_sidebar_cta_text\` text DEFAULT 'Ist Ihr Unternehmen bereit für den Bayerischen Mittelstandspreis 2026?',
\`detail_page_sidebar_cta_label\` text DEFAULT 'Zur Bewerbung',
\`detail_page_sidebar_cta_url\` text DEFAULT '/teilnahme',
\`detail_page_related_heading\` text DEFAULT 'Weitere Artikel',
\`detail_page_related_read_more_label\` text DEFAULT 'Weiterlesen',
\`content\` text,
\`meta_title\` text,
\`meta_image_id\` integer,
\`meta_description\` text,
\`spa_path\` text,
\`published_at\` text,
\`generate_slug\` integer DEFAULT true,
\`slug\` text,
\`updated_at\` text DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ', 'now')) NOT NULL,
\`created_at\` text DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ', 'now')) NOT NULL,
\`_status\` text DEFAULT 'draft',
FOREIGN KEY (\`hero_image_id\`) REFERENCES \`media\`(\`id\`) ON UPDATE no action ON DELETE set null,
FOREIGN KEY (\`detail_page_fallback_image_id\`) REFERENCES \`media\`(\`id\`) ON UPDATE no action ON DELETE set null,
FOREIGN KEY (\`meta_image_id\`) REFERENCES \`media\`(\`id\`) ON UPDATE no action ON DELETE set null
);
`)
await db.run(sql`INSERT INTO \`__new_posts\`("id", "title", "hero_image_id", "excerpt", "cat", "date", "author_name", "author_role", "read_time", "detail_page_fallback_image_id", "detail_page_not_found_title", "detail_page_not_found_back_label", "detail_page_not_found_back_url", "detail_page_hero_back_link_label", "detail_page_hero_back_link_url", "detail_page_fallback_article_title", "detail_page_fallback_article_category", "detail_page_fallback_article_date", "detail_page_fallback_article_author_name", "detail_page_fallback_article_author_role", "detail_page_fallback_article_read_time", "detail_page_fallback_article_excerpt", "detail_page_fallback_article_tag", "detail_page_category_colors", "detail_page_meta_copy_read_time_suffix", "detail_page_sidebar_cta_heading", "detail_page_sidebar_cta_text", "detail_page_sidebar_cta_label", "detail_page_sidebar_cta_url", "detail_page_related_heading", "detail_page_related_read_more_label", "content", "meta_title", "meta_image_id", "meta_description", "spa_path", "published_at", "generate_slug", "slug", "updated_at", "created_at", "_status") SELECT "id", "title", "hero_image_id", "excerpt", "cat", "date", "author_name", "author_role", "read_time", "detail_page_fallback_image_id", "detail_page_not_found_title", "detail_page_not_found_back_label", "detail_page_not_found_back_url", "detail_page_hero_back_link_label", "detail_page_hero_back_link_url", "detail_page_fallback_article_title", "detail_page_fallback_article_category", "detail_page_fallback_article_date", "detail_page_fallback_article_author_name", "detail_page_fallback_article_author_role", "detail_page_fallback_article_read_time", "detail_page_fallback_article_excerpt", "detail_page_fallback_article_tag", "detail_page_category_colors", "detail_page_meta_copy_read_time_suffix", "detail_page_sidebar_cta_heading", "detail_page_sidebar_cta_text", "detail_page_sidebar_cta_label", "detail_page_sidebar_cta_url", "detail_page_related_heading", "detail_page_related_read_more_label", "content", "meta_title", "meta_image_id", "meta_description", "spa_path", "published_at", "generate_slug", "slug", "updated_at", "created_at", "_status" FROM \`posts\`;`)
await db.run(sql`DROP TABLE \`posts\`;`)
await db.run(sql`ALTER TABLE \`__new_posts\` RENAME TO \`posts\`;`)
await db.run(sql`PRAGMA foreign_keys=ON;`)
await db.run(sql`CREATE INDEX \`posts_hero_image_idx\` ON \`posts\` (\`hero_image_id\`);`)
await db.run(sql`CREATE INDEX \`posts_detail_page_detail_page_fallback_image_idx\` ON \`posts\` (\`detail_page_fallback_image_id\`);`)
await db.run(sql`CREATE INDEX \`posts_meta_meta_image_idx\` ON \`posts\` (\`meta_image_id\`);`)
await db.run(sql`CREATE INDEX \`posts_spa_path_idx\` ON \`posts\` (\`spa_path\`);`)
await db.run(sql`CREATE UNIQUE INDEX \`posts_slug_idx\` ON \`posts\` (\`slug\`);`)
await db.run(sql`CREATE INDEX \`posts_updated_at_idx\` ON \`posts\` (\`updated_at\`);`)
await db.run(sql`CREATE INDEX \`posts_created_at_idx\` ON \`posts\` (\`created_at\`);`)
await db.run(sql`CREATE INDEX \`posts__status_idx\` ON \`posts\` (\`_status\`);`)
await db.run(sql`CREATE TABLE \`__new__posts_v\` (
\`id\` integer PRIMARY KEY NOT NULL,
\`parent_id\` integer,
\`version_title\` text,
\`version_hero_image_id\` integer,
\`version_excerpt\` text,
\`version_cat\` text,
\`version_date\` text,
\`version_author_name\` text,
\`version_author_role\` text,
\`version_read_time\` text,
\`version_detail_page_fallback_image_id\` integer,
\`version_detail_page_not_found_title\` text DEFAULT 'Artikel nicht gefunden',
\`version_detail_page_not_found_back_label\` text DEFAULT 'Zurück zur Presse',
\`version_detail_page_not_found_back_url\` text DEFAULT '/presse',
\`version_detail_page_hero_back_link_label\` text DEFAULT 'Newsroom',
\`version_detail_page_hero_back_link_url\` text DEFAULT '/presse',
\`version_detail_page_fallback_article_title\` text DEFAULT 'Presse',
\`version_detail_page_fallback_article_category\` text DEFAULT 'Presse',
\`version_detail_page_fallback_article_date\` text DEFAULT 'Aktuell',
\`version_detail_page_fallback_article_author_name\` text DEFAULT 'BMP Redaktion',
\`version_detail_page_fallback_article_author_role\` text DEFAULT 'Presse',
\`version_detail_page_fallback_article_read_time\` text DEFAULT '1 min',
\`version_detail_page_fallback_article_excerpt\` text DEFAULT 'Dieser Pressebeitrag wird aus Payload CMS geladen.',
\`version_detail_page_fallback_article_tag\` text DEFAULT 'Presse',
\`version_detail_page_category_colors\` text DEFAULT '{"Auszeichnung":"#111D55","Innovation":"#2563EB","Engagement":"#16A34A","Wirtschaft":"#9333EA"}',
\`version_detail_page_meta_copy_read_time_suffix\` text DEFAULT 'Lesezeit',
\`version_detail_page_sidebar_cta_heading\` text DEFAULT 'Jetzt bewerben',
\`version_detail_page_sidebar_cta_text\` text DEFAULT 'Ist Ihr Unternehmen bereit für den Bayerischen Mittelstandspreis 2026?',
\`version_detail_page_sidebar_cta_label\` text DEFAULT 'Zur Bewerbung',
\`version_detail_page_sidebar_cta_url\` text DEFAULT '/teilnahme',
\`version_detail_page_related_heading\` text DEFAULT 'Weitere Artikel',
\`version_detail_page_related_read_more_label\` text DEFAULT 'Weiterlesen',
\`version_content\` text,
\`version_meta_title\` text,
\`version_meta_image_id\` integer,
\`version_meta_description\` text,
\`version_spa_path\` text,
\`version_published_at\` text,
\`version_generate_slug\` integer DEFAULT true,
\`version_slug\` text,
\`version_updated_at\` text,
\`version_created_at\` text,
\`version__status\` text DEFAULT 'draft',
\`created_at\` text DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ', 'now')) NOT NULL,
\`updated_at\` text DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ', 'now')) NOT NULL,
\`latest\` integer,
\`autosave\` integer,
FOREIGN KEY (\`parent_id\`) REFERENCES \`posts\`(\`id\`) ON UPDATE no action ON DELETE set null,
FOREIGN KEY (\`version_hero_image_id\`) REFERENCES \`media\`(\`id\`) ON UPDATE no action ON DELETE set null,
FOREIGN KEY (\`version_detail_page_fallback_image_id\`) REFERENCES \`media\`(\`id\`) ON UPDATE no action ON DELETE set null,
FOREIGN KEY (\`version_meta_image_id\`) REFERENCES \`media\`(\`id\`) ON UPDATE no action ON DELETE set null
);
`)
await db.run(sql`INSERT INTO \`__new__posts_v\`("id", "parent_id", "version_title", "version_hero_image_id", "version_excerpt", "version_cat", "version_date", "version_author_name", "version_author_role", "version_read_time", "version_detail_page_fallback_image_id", "version_detail_page_not_found_title", "version_detail_page_not_found_back_label", "version_detail_page_not_found_back_url", "version_detail_page_hero_back_link_label", "version_detail_page_hero_back_link_url", "version_detail_page_fallback_article_title", "version_detail_page_fallback_article_category", "version_detail_page_fallback_article_date", "version_detail_page_fallback_article_author_name", "version_detail_page_fallback_article_author_role", "version_detail_page_fallback_article_read_time", "version_detail_page_fallback_article_excerpt", "version_detail_page_fallback_article_tag", "version_detail_page_category_colors", "version_detail_page_meta_copy_read_time_suffix", "version_detail_page_sidebar_cta_heading", "version_detail_page_sidebar_cta_text", "version_detail_page_sidebar_cta_label", "version_detail_page_sidebar_cta_url", "version_detail_page_related_heading", "version_detail_page_related_read_more_label", "version_content", "version_meta_title", "version_meta_image_id", "version_meta_description", "version_spa_path", "version_published_at", "version_generate_slug", "version_slug", "version_updated_at", "version_created_at", "version__status", "created_at", "updated_at", "latest", "autosave") SELECT "id", "parent_id", "version_title", "version_hero_image_id", "version_excerpt", "version_cat", "version_date", "version_author_name", "version_author_role", "version_read_time", "version_detail_page_fallback_image_id", "version_detail_page_not_found_title", "version_detail_page_not_found_back_label", "version_detail_page_not_found_back_url", "version_detail_page_hero_back_link_label", "version_detail_page_hero_back_link_url", "version_detail_page_fallback_article_title", "version_detail_page_fallback_article_category", "version_detail_page_fallback_article_date", "version_detail_page_fallback_article_author_name", "version_detail_page_fallback_article_author_role", "version_detail_page_fallback_article_read_time", "version_detail_page_fallback_article_excerpt", "version_detail_page_fallback_article_tag", "version_detail_page_category_colors", "version_detail_page_meta_copy_read_time_suffix", "version_detail_page_sidebar_cta_heading", "version_detail_page_sidebar_cta_text", "version_detail_page_sidebar_cta_label", "version_detail_page_sidebar_cta_url", "version_detail_page_related_heading", "version_detail_page_related_read_more_label", "version_content", "version_meta_title", "version_meta_image_id", "version_meta_description", "version_spa_path", "version_published_at", "version_generate_slug", "version_slug", "version_updated_at", "version_created_at", "version__status", "created_at", "updated_at", "latest", "autosave" FROM \`_posts_v\`;`)
await db.run(sql`DROP TABLE \`_posts_v\`;`)
await db.run(sql`ALTER TABLE \`__new__posts_v\` RENAME TO \`_posts_v\`;`)
await db.run(sql`CREATE INDEX \`_posts_v_parent_idx\` ON \`_posts_v\` (\`parent_id\`);`)
await db.run(sql`CREATE INDEX \`_posts_v_version_version_hero_image_idx\` ON \`_posts_v\` (\`version_hero_image_id\`);`)
await db.run(sql`CREATE INDEX \`_posts_v_version_detail_page_version_detail_page_fallbac_idx\` ON \`_posts_v\` (\`version_detail_page_fallback_image_id\`);`)
await db.run(sql`CREATE INDEX \`_posts_v_version_meta_version_meta_image_idx\` ON \`_posts_v\` (\`version_meta_image_id\`);`)
await db.run(sql`CREATE INDEX \`_posts_v_version_version_spa_path_idx\` ON \`_posts_v\` (\`version_spa_path\`);`)
await db.run(sql`CREATE INDEX \`_posts_v_version_version_slug_idx\` ON \`_posts_v\` (\`version_slug\`);`)
await db.run(sql`CREATE INDEX \`_posts_v_version_version_updated_at_idx\` ON \`_posts_v\` (\`version_updated_at\`);`)
await db.run(sql`CREATE INDEX \`_posts_v_version_version_created_at_idx\` ON \`_posts_v\` (\`version_created_at\`);`)
await db.run(sql`CREATE INDEX \`_posts_v_version_version__status_idx\` ON \`_posts_v\` (\`version__status\`);`)
await db.run(sql`CREATE INDEX \`_posts_v_created_at_idx\` ON \`_posts_v\` (\`created_at\`);`)
await db.run(sql`CREATE INDEX \`_posts_v_updated_at_idx\` ON \`_posts_v\` (\`updated_at\`);`)
await db.run(sql`CREATE INDEX \`_posts_v_latest_idx\` ON \`_posts_v\` (\`latest\`);`)
await db.run(sql`CREATE INDEX \`_posts_v_autosave_idx\` ON \`_posts_v\` (\`autosave\`);`)
await db.run(sql`ALTER TABLE \`pages\` ADD \`participation_awards_grid_eyebrow\` text DEFAULT 'Auszeichnungen 2026';`)
await db.run(sql`ALTER TABLE \`pages\` ADD \`participation_awards_grid_heading\` text DEFAULT 'DREI AUSZEICHNUNGEN.
EIN ANSPRUCH.';`)
await db.run(sql`ALTER TABLE \`pages\` ADD \`participation_awards_grid_description\` text DEFAULT 'Direkt im Bewerbungsjahr 2026 stehen drei Auszeichnungen im Fokus: die Goldene Bavaria, der Roland-Berger-Zukunftspreis und der Studentenpreis Bavarian Future Award.';`)
await db.run(sql`ALTER TABLE \`_pages_v\` ADD \`version_participation_awards_grid_eyebrow\` text DEFAULT 'Auszeichnungen 2026';`)
await db.run(sql`ALTER TABLE \`_pages_v\` ADD \`version_participation_awards_grid_heading\` text DEFAULT 'DREI AUSZEICHNUNGEN.
EIN ANSPRUCH.';`)
await db.run(sql`ALTER TABLE \`_pages_v\` ADD \`version_participation_awards_grid_description\` text DEFAULT 'Direkt im Bewerbungsjahr 2026 stehen drei Auszeichnungen im Fokus: die Goldene Bavaria, der Roland-Berger-Zukunftspreis und der Studentenpreis Bavarian Future Award.';`)
}
export async function down({ db, payload, req }: MigrateDownArgs): Promise<void> {
await db.run(sql`DROP TABLE \`award_cards\`;`)
await db.run(sql`DROP TABLE \`_award_cards_v\`;`)
await db.run(sql`PRAGMA foreign_keys=OFF;`)
await db.run(sql`CREATE TABLE \`__new_posts\` (
\`id\` integer PRIMARY KEY NOT NULL,
\`title\` text,
\`hero_image_id\` integer,
\`excerpt\` text,
\`cat\` text,
\`date\` text,
\`author_name\` text,
\`author_role\` text,
\`read_time\` text,
\`detail_page_fallback_image_id\` integer,
\`detail_page_not_found_title\` text DEFAULT 'Artikel nicht gefunden',
\`detail_page_not_found_back_label\` text DEFAULT 'Zurück zur Presse',
\`detail_page_not_found_back_url\` text DEFAULT '/presse',
\`detail_page_hero_back_link_label\` text DEFAULT 'Newsroom',
\`detail_page_hero_back_link_url\` text DEFAULT '/presse',
\`detail_page_fallback_article_title\` text DEFAULT 'Presse',
\`detail_page_fallback_article_category\` text DEFAULT 'Presse',
\`detail_page_fallback_article_date\` text DEFAULT 'Aktuell',
\`detail_page_fallback_article_author_name\` text DEFAULT 'BMP Redaktion',
\`detail_page_fallback_article_author_role\` text DEFAULT 'Presse',
\`detail_page_fallback_article_read_time\` text DEFAULT '1 min',
\`detail_page_fallback_article_excerpt\` text DEFAULT 'Dieser Pressebeitrag wird aus Payload CMS geladen.',
\`detail_page_fallback_article_tag\` text DEFAULT 'Presse',
\`detail_page_category_colors\` text DEFAULT '{"Innovation":"#2563EB","Engagement":"#16A34A","Wirtschaft":"#9333EA"}',
\`detail_page_meta_copy_read_time_suffix\` text DEFAULT 'Lesezeit',
\`detail_page_sidebar_cta_heading\` text DEFAULT 'Jetzt bewerben',
\`detail_page_sidebar_cta_text\` text DEFAULT 'Ist Ihr Unternehmen bereit für den Bayerischen Mittelstandspreis 2026?',
\`detail_page_sidebar_cta_label\` text DEFAULT 'Zur Bewerbung',
\`detail_page_sidebar_cta_url\` text DEFAULT '/teilnahme',
\`detail_page_related_heading\` text DEFAULT 'Weitere Artikel',
\`detail_page_related_read_more_label\` text DEFAULT 'Weiterlesen',
\`content\` text,
\`meta_title\` text,
\`meta_image_id\` integer,
\`meta_description\` text,
\`spa_path\` text,
\`published_at\` text,
\`generate_slug\` integer DEFAULT true,
\`slug\` text,
\`updated_at\` text DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ', 'now')) NOT NULL,
\`created_at\` text DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ', 'now')) NOT NULL,
\`_status\` text DEFAULT 'draft',
FOREIGN KEY (\`hero_image_id\`) REFERENCES \`media\`(\`id\`) ON UPDATE no action ON DELETE set null,
FOREIGN KEY (\`detail_page_fallback_image_id\`) REFERENCES \`media\`(\`id\`) ON UPDATE no action ON DELETE set null,
FOREIGN KEY (\`meta_image_id\`) REFERENCES \`media\`(\`id\`) ON UPDATE no action ON DELETE set null
);
`)
await db.run(sql`INSERT INTO \`__new_posts\`("id", "title", "hero_image_id", "excerpt", "cat", "date", "author_name", "author_role", "read_time", "detail_page_fallback_image_id", "detail_page_not_found_title", "detail_page_not_found_back_label", "detail_page_not_found_back_url", "detail_page_hero_back_link_label", "detail_page_hero_back_link_url", "detail_page_fallback_article_title", "detail_page_fallback_article_category", "detail_page_fallback_article_date", "detail_page_fallback_article_author_name", "detail_page_fallback_article_author_role", "detail_page_fallback_article_read_time", "detail_page_fallback_article_excerpt", "detail_page_fallback_article_tag", "detail_page_category_colors", "detail_page_meta_copy_read_time_suffix", "detail_page_sidebar_cta_heading", "detail_page_sidebar_cta_text", "detail_page_sidebar_cta_label", "detail_page_sidebar_cta_url", "detail_page_related_heading", "detail_page_related_read_more_label", "content", "meta_title", "meta_image_id", "meta_description", "spa_path", "published_at", "generate_slug", "slug", "updated_at", "created_at", "_status") SELECT "id", "title", "hero_image_id", "excerpt", "cat", "date", "author_name", "author_role", "read_time", "detail_page_fallback_image_id", "detail_page_not_found_title", "detail_page_not_found_back_label", "detail_page_not_found_back_url", "detail_page_hero_back_link_label", "detail_page_hero_back_link_url", "detail_page_fallback_article_title", "detail_page_fallback_article_category", "detail_page_fallback_article_date", "detail_page_fallback_article_author_name", "detail_page_fallback_article_author_role", "detail_page_fallback_article_read_time", "detail_page_fallback_article_excerpt", "detail_page_fallback_article_tag", "detail_page_category_colors", "detail_page_meta_copy_read_time_suffix", "detail_page_sidebar_cta_heading", "detail_page_sidebar_cta_text", "detail_page_sidebar_cta_label", "detail_page_sidebar_cta_url", "detail_page_related_heading", "detail_page_related_read_more_label", "content", "meta_title", "meta_image_id", "meta_description", "spa_path", "published_at", "generate_slug", "slug", "updated_at", "created_at", "_status" FROM \`posts\`;`)
await db.run(sql`DROP TABLE \`posts\`;`)
await db.run(sql`ALTER TABLE \`__new_posts\` RENAME TO \`posts\`;`)
await db.run(sql`PRAGMA foreign_keys=ON;`)
await db.run(sql`CREATE INDEX \`posts_hero_image_idx\` ON \`posts\` (\`hero_image_id\`);`)
await db.run(sql`CREATE INDEX \`posts_detail_page_detail_page_fallback_image_idx\` ON \`posts\` (\`detail_page_fallback_image_id\`);`)
await db.run(sql`CREATE INDEX \`posts_meta_meta_image_idx\` ON \`posts\` (\`meta_image_id\`);`)
await db.run(sql`CREATE INDEX \`posts_spa_path_idx\` ON \`posts\` (\`spa_path\`);`)
await db.run(sql`CREATE UNIQUE INDEX \`posts_slug_idx\` ON \`posts\` (\`slug\`);`)
await db.run(sql`CREATE INDEX \`posts_updated_at_idx\` ON \`posts\` (\`updated_at\`);`)
await db.run(sql`CREATE INDEX \`posts_created_at_idx\` ON \`posts\` (\`created_at\`);`)
await db.run(sql`CREATE INDEX \`posts__status_idx\` ON \`posts\` (\`_status\`);`)
await db.run(sql`CREATE TABLE \`__new__posts_v\` (
\`id\` integer PRIMARY KEY NOT NULL,
\`parent_id\` integer,
\`version_title\` text,
\`version_hero_image_id\` integer,
\`version_excerpt\` text,
\`version_cat\` text,
\`version_date\` text,
\`version_author_name\` text,
\`version_author_role\` text,
\`version_read_time\` text,
\`version_detail_page_fallback_image_id\` integer,
\`version_detail_page_not_found_title\` text DEFAULT 'Artikel nicht gefunden',
\`version_detail_page_not_found_back_label\` text DEFAULT 'Zurück zur Presse',
\`version_detail_page_not_found_back_url\` text DEFAULT '/presse',
\`version_detail_page_hero_back_link_label\` text DEFAULT 'Newsroom',
\`version_detail_page_hero_back_link_url\` text DEFAULT '/presse',
\`version_detail_page_fallback_article_title\` text DEFAULT 'Presse',
\`version_detail_page_fallback_article_category\` text DEFAULT 'Presse',
\`version_detail_page_fallback_article_date\` text DEFAULT 'Aktuell',
\`version_detail_page_fallback_article_author_name\` text DEFAULT 'BMP Redaktion',
\`version_detail_page_fallback_article_author_role\` text DEFAULT 'Presse',
\`version_detail_page_fallback_article_read_time\` text DEFAULT '1 min',
\`version_detail_page_fallback_article_excerpt\` text DEFAULT 'Dieser Pressebeitrag wird aus Payload CMS geladen.',
\`version_detail_page_fallback_article_tag\` text DEFAULT 'Presse',
\`version_detail_page_category_colors\` text DEFAULT '{"Innovation":"#2563EB","Engagement":"#16A34A","Wirtschaft":"#9333EA"}',
\`version_detail_page_meta_copy_read_time_suffix\` text DEFAULT 'Lesezeit',
\`version_detail_page_sidebar_cta_heading\` text DEFAULT 'Jetzt bewerben',
\`version_detail_page_sidebar_cta_text\` text DEFAULT 'Ist Ihr Unternehmen bereit für den Bayerischen Mittelstandspreis 2026?',
\`version_detail_page_sidebar_cta_label\` text DEFAULT 'Zur Bewerbung',
\`version_detail_page_sidebar_cta_url\` text DEFAULT '/teilnahme',
\`version_detail_page_related_heading\` text DEFAULT 'Weitere Artikel',
\`version_detail_page_related_read_more_label\` text DEFAULT 'Weiterlesen',
\`version_content\` text,
\`version_meta_title\` text,
\`version_meta_image_id\` integer,
\`version_meta_description\` text,
\`version_spa_path\` text,
\`version_published_at\` text,
\`version_generate_slug\` integer DEFAULT true,
\`version_slug\` text,
\`version_updated_at\` text,
\`version_created_at\` text,
\`version__status\` text DEFAULT 'draft',
\`created_at\` text DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ', 'now')) NOT NULL,
\`updated_at\` text DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ', 'now')) NOT NULL,
\`latest\` integer,
\`autosave\` integer,
FOREIGN KEY (\`parent_id\`) REFERENCES \`posts\`(\`id\`) ON UPDATE no action ON DELETE set null,
FOREIGN KEY (\`version_hero_image_id\`) REFERENCES \`media\`(\`id\`) ON UPDATE no action ON DELETE set null,
FOREIGN KEY (\`version_detail_page_fallback_image_id\`) REFERENCES \`media\`(\`id\`) ON UPDATE no action ON DELETE set null,
FOREIGN KEY (\`version_meta_image_id\`) REFERENCES \`media\`(\`id\`) ON UPDATE no action ON DELETE set null
);
`)
await db.run(sql`INSERT INTO \`__new__posts_v\`("id", "parent_id", "version_title", "version_hero_image_id", "version_excerpt", "version_cat", "version_date", "version_author_name", "version_author_role", "version_read_time", "version_detail_page_fallback_image_id", "version_detail_page_not_found_title", "version_detail_page_not_found_back_label", "version_detail_page_not_found_back_url", "version_detail_page_hero_back_link_label", "version_detail_page_hero_back_link_url", "version_detail_page_fallback_article_title", "version_detail_page_fallback_article_category", "version_detail_page_fallback_article_date", "version_detail_page_fallback_article_author_name", "version_detail_page_fallback_article_author_role", "version_detail_page_fallback_article_read_time", "version_detail_page_fallback_article_excerpt", "version_detail_page_fallback_article_tag", "version_detail_page_category_colors", "version_detail_page_meta_copy_read_time_suffix", "version_detail_page_sidebar_cta_heading", "version_detail_page_sidebar_cta_text", "version_detail_page_sidebar_cta_label", "version_detail_page_sidebar_cta_url", "version_detail_page_related_heading", "version_detail_page_related_read_more_label", "version_content", "version_meta_title", "version_meta_image_id", "version_meta_description", "version_spa_path", "version_published_at", "version_generate_slug", "version_slug", "version_updated_at", "version_created_at", "version__status", "created_at", "updated_at", "latest", "autosave") SELECT "id", "parent_id", "version_title", "version_hero_image_id", "version_excerpt", "version_cat", "version_date", "version_author_name", "version_author_role", "version_read_time", "version_detail_page_fallback_image_id", "version_detail_page_not_found_title", "version_detail_page_not_found_back_label", "version_detail_page_not_found_back_url", "version_detail_page_hero_back_link_label", "version_detail_page_hero_back_link_url", "version_detail_page_fallback_article_title", "version_detail_page_fallback_article_category", "version_detail_page_fallback_article_date", "version_detail_page_fallback_article_author_name", "version_detail_page_fallback_article_author_role", "version_detail_page_fallback_article_read_time", "version_detail_page_fallback_article_excerpt", "version_detail_page_fallback_article_tag", "version_detail_page_category_colors", "version_detail_page_meta_copy_read_time_suffix", "version_detail_page_sidebar_cta_heading", "version_detail_page_sidebar_cta_text", "version_detail_page_sidebar_cta_label", "version_detail_page_sidebar_cta_url", "version_detail_page_related_heading", "version_detail_page_related_read_more_label", "version_content", "version_meta_title", "version_meta_image_id", "version_meta_description", "version_spa_path", "version_published_at", "version_generate_slug", "version_slug", "version_updated_at", "version_created_at", "version__status", "created_at", "updated_at", "latest", "autosave" FROM \`_posts_v\`;`)
await db.run(sql`DROP TABLE \`_posts_v\`;`)
await db.run(sql`ALTER TABLE \`__new__posts_v\` RENAME TO \`_posts_v\`;`)
await db.run(sql`CREATE INDEX \`_posts_v_parent_idx\` ON \`_posts_v\` (\`parent_id\`);`)
await db.run(sql`CREATE INDEX \`_posts_v_version_version_hero_image_idx\` ON \`_posts_v\` (\`version_hero_image_id\`);`)
await db.run(sql`CREATE INDEX \`_posts_v_version_detail_page_version_detail_page_fallbac_idx\` ON \`_posts_v\` (\`version_detail_page_fallback_image_id\`);`)
await db.run(sql`CREATE INDEX \`_posts_v_version_meta_version_meta_image_idx\` ON \`_posts_v\` (\`version_meta_image_id\`);`)
await db.run(sql`CREATE INDEX \`_posts_v_version_version_spa_path_idx\` ON \`_posts_v\` (\`version_spa_path\`);`)
await db.run(sql`CREATE INDEX \`_posts_v_version_version_slug_idx\` ON \`_posts_v\` (\`version_slug\`);`)
await db.run(sql`CREATE INDEX \`_posts_v_version_version_updated_at_idx\` ON \`_posts_v\` (\`version_updated_at\`);`)
await db.run(sql`CREATE INDEX \`_posts_v_version_version_created_at_idx\` ON \`_posts_v\` (\`version_created_at\`);`)
await db.run(sql`CREATE INDEX \`_posts_v_version_version__status_idx\` ON \`_posts_v\` (\`version__status\`);`)
await db.run(sql`CREATE INDEX \`_posts_v_created_at_idx\` ON \`_posts_v\` (\`created_at\`);`)
await db.run(sql`CREATE INDEX \`_posts_v_updated_at_idx\` ON \`_posts_v\` (\`updated_at\`);`)
await db.run(sql`CREATE INDEX \`_posts_v_latest_idx\` ON \`_posts_v\` (\`latest\`);`)
await db.run(sql`CREATE INDEX \`_posts_v_autosave_idx\` ON \`_posts_v\` (\`autosave\`);`)
await db.run(sql`ALTER TABLE \`pages\` DROP COLUMN \`participation_awards_grid_eyebrow\`;`)
await db.run(sql`ALTER TABLE \`pages\` DROP COLUMN \`participation_awards_grid_heading\`;`)
await db.run(sql`ALTER TABLE \`pages\` DROP COLUMN \`participation_awards_grid_description\`;`)
await db.run(sql`ALTER TABLE \`_pages_v\` DROP COLUMN \`version_participation_awards_grid_eyebrow\`;`)
await db.run(sql`ALTER TABLE \`_pages_v\` DROP COLUMN \`version_participation_awards_grid_heading\`;`)
await db.run(sql`ALTER TABLE \`_pages_v\` DROP COLUMN \`version_participation_awards_grid_description\`;`)
}

15
src/migrations/index.ts Normal file
View File

@@ -0,0 +1,15 @@
import * as migration_20260630_133242 from './20260630_133242';
import * as migration_20260630_165002_participation_awards_grid from './20260630_165002_participation_awards_grid';
export const migrations = [
{
up: migration_20260630_133242.up,
down: migration_20260630_133242.down,
name: '20260630_133242',
},
{
up: migration_20260630_165002_participation_awards_grid.up,
down: migration_20260630_165002_participation_awards_grid.down,
name: '20260630_165002_participation_awards_grid'
},
];

View File

@@ -117,11 +117,13 @@ export interface Config {
header: Header; header: Header;
footer: Footer; footer: Footer;
'site-settings': SiteSetting; 'site-settings': SiteSetting;
'application-phase': ApplicationPhase;
}; };
globalsSelect: { globalsSelect: {
header: HeaderSelect<false> | HeaderSelect<true>; header: HeaderSelect<false> | HeaderSelect<true>;
footer: FooterSelect<false> | FooterSelect<true>; footer: FooterSelect<false> | FooterSelect<true>;
'site-settings': SiteSettingsSelect<false> | SiteSettingsSelect<true>; 'site-settings': SiteSettingsSelect<false> | SiteSettingsSelect<true>;
'application-phase': ApplicationPhaseSelect<false> | ApplicationPhaseSelect<true>;
}; };
locale: null; locale: null;
widgets: { widgets: {
@@ -243,7 +245,7 @@ export interface Page {
| null; | null;
}; };
/** /**
* Cream text panel plus right-side stage image and five eligibility rows. * White text panel plus right-side stage image and five eligibility rows.
*/ */
quickCheck?: { quickCheck?: {
eyebrow?: string | null; eyebrow?: string | null;
@@ -292,43 +294,6 @@ export interface Page {
url?: string | null; url?: string | null;
}; };
}; };
/**
* Three-slide status module shown after the award intro.
*/
status?: {
/**
* Only this selected phase is rendered on the frontend; visitors do not see a slider.
*/
activePhase?: ('0' | '1' | '2') | null;
phases?:
| {
num?: string | null;
tag?: string | null;
pulse?: boolean | null;
phase?: string | null;
headline?: string | null;
body?: string | null;
cta?: {
label?: string | null;
url?: string | null;
};
accent?: string | null;
bg?: string | null;
glow?: string | null;
meta?: string | null;
successApplications?: string | null;
successWinners?: string | null;
membershipCta?: {
label?: string | null;
url?: string | null;
};
id?: string | null;
}[]
| null;
noActionLabel?: string | null;
successApplicationsLabel?: string | null;
successWinnersLabel?: string | null;
};
/** /**
* Blue card grid with featured winner relationships. * Blue card grid with featured winner relationships.
*/ */
@@ -375,7 +340,7 @@ export interface Page {
| null; | null;
}; };
/** /**
* Cream left panel and networking image. * White left panel and networking image.
*/ */
benefits?: { benefits?: {
eyebrow?: string | null; eyebrow?: string | null;
@@ -581,9 +546,13 @@ export interface Page {
}; };
}; };
/** /**
* Copy shown when the hero video button is clicked. * Video and fallback copy shown when the hero video button is clicked.
*/ */
videoModal?: { videoModal?: {
/**
* Choose a video from the media collection. If empty, the frontend shows the fallback copy below.
*/
video?: (number | null) | Media;
title?: string | null; title?: string | null;
description?: string | null; description?: string | null;
closeLabel?: string | null; closeLabel?: string | null;
@@ -843,30 +812,6 @@ export interface Page {
url?: string | null; url?: string | null;
}; };
}; };
/**
* Navy evaluation section with image, link, and criteria grid.
*/
evaluation?: {
/**
* Current frontend image: /images/buehne-gewinner.jpg
*/
image?: (number | null) | Media;
imageAlt?: string | null;
eyebrow?: string | null;
heading?: string | null;
body?: string | null;
link?: {
label?: string | null;
url?: string | null;
};
criteria?:
| {
label?: string | null;
desc?: string | null;
id?: string | null;
}[]
| null;
};
/** /**
* Cream section explaining proposal, self-application, and special award paths. * Cream section explaining proposal, self-application, and special award paths.
*/ */
@@ -948,6 +893,32 @@ export interface Page {
}[] }[]
| null; | null;
}; };
/**
* Three-column awards grid rendered directly after the dates banner.
*/
awardsGrid?: {
eyebrow?: string | null;
heading?: string | null;
description?: string | null;
cards?:
| {
label?: string | null;
image?: (number | null) | Media;
imageAlt?: string | null;
title?: string | null;
description?: string | null;
articleCta?: {
label?: string | null;
url?: string | null;
};
mailtoCta?: {
label?: string | null;
url?: string | null;
};
id?: string | null;
}[]
| null;
};
/** /**
* Navy/gold section containing the application form and PDF upload links. * Navy/gold section containing the application form and PDF upload links.
*/ */
@@ -1427,7 +1398,7 @@ export interface Page {
*/ */
preistraegerIndex?: { preistraegerIndex?: {
/** /**
* Large image above the filters. Upload an image or keep the migrated URL fallback. * Large image above the year selector. Upload an image or keep the migrated URL fallback.
*/ */
hero?: { hero?: {
image?: (number | null) | Media; image?: (number | null) | Media;
@@ -1441,24 +1412,13 @@ export interface Page {
label?: string | null; label?: string | null;
}; };
/** /**
* Filter placeholders, toggle label, and reset label. * Label after the selected-year result count.
*/
filters?: {
categoryPlaceholder?: string | null;
searchPlaceholder?: string | null;
storyHeading?: string | null;
mediaIcon?: string | null;
mediaLabel?: string | null;
resetLabel?: string | null;
};
/**
* Label after the filtered result count.
*/ */
count?: { count?: {
label?: string | null; label?: string | null;
}; };
/** /**
* Message when filters return no winners. * Message when the selected year returns no winners.
*/ */
empty?: { empty?: {
message?: string | null; message?: string | null;
@@ -3396,43 +3356,6 @@ export interface PagesSelect<T extends boolean = true> {
url?: T; url?: T;
}; };
}; };
status?:
| T
| {
activePhase?: T;
phases?:
| T
| {
num?: T;
tag?: T;
pulse?: T;
phase?: T;
headline?: T;
body?: T;
cta?:
| T
| {
label?: T;
url?: T;
};
accent?: T;
bg?: T;
glow?: T;
meta?: T;
successApplications?: T;
successWinners?: T;
membershipCta?:
| T
| {
label?: T;
url?: T;
};
id?: T;
};
noActionLabel?: T;
successApplicationsLabel?: T;
successWinnersLabel?: T;
};
winners?: winners?:
| T | T
| { | {
@@ -3652,6 +3575,7 @@ export interface PagesSelect<T extends boolean = true> {
videoModal?: videoModal?:
| T | T
| { | {
video?: T;
title?: T; title?: T;
description?: T; description?: T;
closeLabel?: T; closeLabel?: T;
@@ -3851,28 +3775,6 @@ export interface PagesSelect<T extends boolean = true> {
url?: T; url?: T;
}; };
}; };
evaluation?:
| T
| {
image?: T;
imageAlt?: T;
eyebrow?: T;
heading?: T;
body?: T;
link?:
| T
| {
label?: T;
url?: T;
};
criteria?:
| T
| {
label?: T;
desc?: T;
id?: T;
};
};
applicationWays?: applicationWays?:
| T | T
| { | {
@@ -3937,6 +3839,35 @@ export interface PagesSelect<T extends boolean = true> {
id?: T; id?: T;
}; };
}; };
awardsGrid?:
| T
| {
eyebrow?: T;
heading?: T;
description?: T;
cards?:
| T
| {
label?: T;
image?: T;
imageAlt?: T;
title?: T;
description?: T;
articleCta?:
| T
| {
label?: T;
url?: T;
};
mailtoCta?:
| T
| {
label?: T;
url?: T;
};
id?: T;
};
};
applicationForm?: applicationForm?:
| T | T
| { | {
@@ -4358,16 +4289,6 @@ export interface PagesSelect<T extends boolean = true> {
| { | {
label?: T; label?: T;
}; };
filters?:
| T
| {
categoryPlaceholder?: T;
searchPlaceholder?: T;
storyHeading?: T;
mediaIcon?: T;
mediaLabel?: T;
resetLabel?: T;
};
count?: count?:
| T | T
| { | {
@@ -5802,8 +5723,6 @@ export interface Header {
closeLabel: string; closeLabel: string;
dialogLabel: string; dialogLabel: string;
overviewLabel: string; overviewLabel: string;
loadingBrand: string;
loadingLabel: string;
navItems?: navItems?:
| { | {
/** /**
@@ -5834,7 +5753,7 @@ export interface Header {
| { | {
label: string; label: string;
path: string; path: string;
variant?: ('primary' | 'secondary' | 'upload') | null; variant?: ('primary' | 'secondary' | 'login') | null;
id?: string | null; id?: string | null;
}[] }[]
| null; | null;
@@ -5846,12 +5765,6 @@ export interface Header {
id?: string | null; id?: string | null;
}[] }[]
| null; | null;
languages?:
| {
label: string;
id?: string | null;
}[]
| null;
updatedAt?: string | null; updatedAt?: string | null;
createdAt?: string | null; createdAt?: string | null;
} }
@@ -5927,6 +5840,47 @@ export interface SiteSetting {
updatedAt?: string | null; updatedAt?: string | null;
createdAt?: string | null; createdAt?: string | null;
} }
/**
* This interface was referenced by `Config`'s JSON-Schema
* via the `definition` "application-phase".
*/
export interface ApplicationPhase {
id: number;
/**
* Controls phase-dependent frontend rendering across the site.
*/
activePhase?: ('0' | '1' | '2') | null;
phases?:
| {
num?: string | null;
tag?: string | null;
pulse?: boolean | null;
phase?: string | null;
headline?: string | null;
body?: string | null;
cta?: {
label?: string | null;
url?: string | null;
};
accent?: string | null;
bg?: string | null;
glow?: string | null;
meta?: string | null;
successApplications?: string | null;
successWinners?: string | null;
membershipCta?: {
label?: string | null;
url?: string | null;
};
id?: string | null;
}[]
| null;
noActionLabel?: string | null;
successApplicationsLabel?: string | null;
successWinnersLabel?: string | null;
updatedAt?: string | null;
createdAt?: string | null;
}
/** /**
* This interface was referenced by `Config`'s JSON-Schema * This interface was referenced by `Config`'s JSON-Schema
* via the `definition` "header_select". * via the `definition` "header_select".
@@ -5942,8 +5896,6 @@ export interface HeaderSelect<T extends boolean = true> {
closeLabel?: T; closeLabel?: T;
dialogLabel?: T; dialogLabel?: T;
overviewLabel?: T; overviewLabel?: T;
loadingBrand?: T;
loadingLabel?: T;
navItems?: navItems?:
| T | T
| { | {
@@ -5983,12 +5935,6 @@ export interface HeaderSelect<T extends boolean = true> {
icon?: T; icon?: T;
id?: T; id?: T;
}; };
languages?:
| T
| {
label?: T;
id?: T;
};
updatedAt?: T; updatedAt?: T;
createdAt?: T; createdAt?: T;
globalType?: T; globalType?: T;
@@ -6061,6 +6007,48 @@ export interface SiteSettingsSelect<T extends boolean = true> {
createdAt?: T; createdAt?: T;
globalType?: T; globalType?: T;
} }
/**
* This interface was referenced by `Config`'s JSON-Schema
* via the `definition` "application-phase_select".
*/
export interface ApplicationPhaseSelect<T extends boolean = true> {
activePhase?: T;
phases?:
| T
| {
num?: T;
tag?: T;
pulse?: T;
phase?: T;
headline?: T;
body?: T;
cta?:
| T
| {
label?: T;
url?: T;
};
accent?: T;
bg?: T;
glow?: T;
meta?: T;
successApplications?: T;
successWinners?: T;
membershipCta?:
| T
| {
label?: T;
url?: T;
};
id?: T;
};
noActionLabel?: T;
successApplicationsLabel?: T;
successWinnersLabel?: T;
updatedAt?: T;
createdAt?: T;
globalType?: T;
}
/** /**
* This interface was referenced by `Config`'s JSON-Schema * This interface was referenced by `Config`'s JSON-Schema
* via the `definition` "collections_widget". * via the `definition` "collections_widget".

View File

@@ -11,6 +11,7 @@ import { Pages } from './collections/Pages'
import { Posts } from './collections/Posts' import { Posts } from './collections/Posts'
import { Preistraeger } from './collections/Preistraeger' import { Preistraeger } from './collections/Preistraeger'
import { Users } from './collections/Users' import { Users } from './collections/Users'
import { ApplicationPhase } from './ApplicationPhase/config'
import { Footer } from './Footer/config' import { Footer } from './Footer/config'
import { Header } from './Header/config' import { Header } from './Header/config'
import { plugins } from './plugins' import { plugins } from './plugins'
@@ -67,7 +68,7 @@ export default buildConfig({
}), }),
collections: [Pages, Posts, Events, Preistraeger, Media, Categories, Users], collections: [Pages, Posts, Events, Preistraeger, Media, Categories, Users],
cors: [getServerSideURL()].filter(Boolean), cors: [getServerSideURL()].filter(Boolean),
globals: [Header, Footer, SiteSettings], globals: [Header, Footer, SiteSettings, ApplicationPhase],
plugins, plugins,
secret: process.env.PAYLOAD_SECRET, secret: process.env.PAYLOAD_SECRET,
sharp, sharp,

View File

@@ -1,9 +1,10 @@
import 'dotenv/config' import 'dotenv/config'
import config from '@payload-config' import config from '@payload-config'
import { getPayload, type Payload } from 'payload' import { getPayload } from 'payload'
import { mediaByFilename } from './mediaByFilename' import { mediaByFilename } from './mediaByFilename'
import { defaultApplicationPhaseData } from '@/spa/applicationPhase'
import { defaultFooterData, defaultHeaderData } from '@/spa/cmsNavigation' import { defaultFooterData, defaultHeaderData } from '@/spa/cmsNavigation'
import { defaultSiteSettings } from '@/spa/siteSettings' import { defaultSiteSettings } from '@/spa/siteSettings'
@@ -17,7 +18,7 @@ async function main() {
mediaByFilename(payload, 'munich-skyline.jpg'), mediaByFilename(payload, 'munich-skyline.jpg'),
]) ])
const { logo: _headerLogo, languages, ...headerData } = defaultHeaderData const { logo: _headerLogo, ...headerData } = defaultHeaderData
const { logo: _footerLogo, partnerLogos: _defaultPartnerLogos, ...footerData } = defaultFooterData const { logo: _footerLogo, partnerLogos: _defaultPartnerLogos, ...footerData } = defaultFooterData
const { skylineImageUrl: _skylineImageUrl, ...siteSettingsData } = defaultSiteSettings const { skylineImageUrl: _skylineImageUrl, ...siteSettingsData } = defaultSiteSettings
@@ -28,7 +29,6 @@ async function main() {
data: { data: {
...headerData, ...headerData,
logo, logo,
languages: languages.map((label) => ({ label })),
} as never, } as never,
}), }),
payload.updateGlobal({ payload.updateGlobal({
@@ -54,6 +54,11 @@ async function main() {
skylineImage, skylineImage,
} as never, } as never,
}), }),
payload.updateGlobal({
slug: 'application-phase',
context: { disableRevalidate: true },
data: defaultApplicationPhaseData as never,
}),
]) ])
payload.logger.info('Preloaded global CMS settings.') payload.logger.info('Preloaded global CMS settings.')

View File

@@ -84,7 +84,6 @@ async function main() {
...introContent, ...introContent,
image: introImage, image: introImage,
}, },
status: homeContent.status,
winners: { winners: {
...winnersContent, ...winnersContent,
fallbackImage: fallbackWinnerImage, fallbackImage: fallbackWinnerImage,

View File

@@ -0,0 +1,218 @@
import 'dotenv/config'
import config from '@payload-config'
import fs from 'fs/promises'
import path from 'path'
import { getPayload, type File, type Payload } from 'payload'
import { awardArticles, participationAwardsGrid } from '@/spa/awardArticles'
import { blogDetailContent } from '@/spa/blogDetailContent'
import { findMediaByFilename, mediaByFilename } from './mediaByFilename'
process.env.PAYLOAD_MIGRATING ||= 'true'
const root = process.cwd()
const publicImagesDir = path.join(root, 'public/images')
const mimeByExt: Record<string, string> = {
'.jpg': 'image/jpeg',
'.jpeg': 'image/jpeg',
'.png': 'image/png',
'.webp': 'image/webp',
}
const emptyRichText = (text: string) => ({
root: {
type: 'root',
children: [
{
type: 'paragraph',
children: [
{
type: 'text',
detail: 0,
format: 0,
mode: 'normal',
style: '',
text,
version: 1,
},
],
direction: 'ltr' as const,
format: '' as const,
indent: 0,
version: 1,
},
],
direction: 'ltr' as const,
format: '' as const,
indent: 0,
version: 1,
},
})
async function fileForImage(filename: string): Promise<File> {
const fullPath = path.join(publicImagesDir, filename)
const data = await fs.readFile(fullPath)
const ext = path.extname(filename).toLowerCase()
return {
name: filename,
data,
mimetype: mimeByExt[ext] || 'application/octet-stream',
size: data.byteLength,
}
}
async function ensureMedia(payload: Payload, filename: string, alt: string) {
const existing = await findMediaByFilename(payload, filename)
if (existing) return existing.id
const doc = await payload.create({
collection: 'media',
data: { alt },
file: await fileForImage(filename),
overrideAccess: true,
})
return doc.id
}
async function findExistingPost(payload: Payload, slug: string) {
const result = await payload.find({
collection: 'posts',
depth: 0,
draft: true,
limit: 1,
pagination: false,
overrideAccess: true,
where: {
or: [{ slug: { equals: slug } }, { spaPath: { equals: `/presse/blog/${slug}` } }],
},
})
return result.docs[0] as { id: string | number; publishedAt?: string | null } | undefined
}
async function upsertAwardPosts(payload: Payload, mediaIds: Map<string, string | number>) {
const fallbackImage = await mediaByFilename(payload, blogDetailContent.fallbackImageFilename)
const { fallbackImageFilename: _fallbackImageFilename, ...detailContent } = blogDetailContent
const publishedAt = new Date('2026-06-30T12:00:00.000Z').toISOString()
for (const article of awardArticles) {
const existing = await findExistingPost(payload, article.slug)
const heroImage = mediaIds.get(article.imageFilename)
const articleBody = article.sections.map((section) => [section.heading, section.body].filter(Boolean).join('\n')).join('\n\n')
const data = {
title: article.title,
slug: article.slug,
spaPath: `/presse/blog/${article.slug}`,
_status: 'published' as const,
meta: {
title: article.title,
description: article.excerpt,
image: heroImage,
},
heroImage,
excerpt: article.excerpt,
cat: article.cat,
date: article.date,
authorName: article.author,
authorRole: article.authorRole,
readTime: article.readTime,
sections: article.sections,
tags: article.tags.map((tag) => ({ tag })),
relatedSlugs: article.relatedSlugs.map((slug) => ({ slug })),
detailPage: {
...detailContent,
fallbackImage,
metaCopy: blogDetailContent.meta,
sidebarCta: article.detailSidebarCta,
},
content: emptyRichText(articleBody),
publishedAt: existing?.publishedAt || publishedAt,
relatedPosts: [],
categories: [],
authors: [],
}
if (existing) {
await payload.update({
collection: 'posts',
id: existing.id,
overrideAccess: true,
context: { disableRevalidate: true },
data: data as never,
})
payload.logger.info(`updated award post: ${article.slug}`)
} else {
await payload.create({
collection: 'posts',
overrideAccess: true,
context: { disableRevalidate: true },
data: data as never,
})
payload.logger.info(`created award post: ${article.slug}`)
}
}
}
async function preloadParticipationGrid(payload: Payload, mediaIds: Map<string, string | number>) {
const pageResult = await payload.find({
collection: 'pages',
depth: 0,
limit: 1,
pagination: false,
draft: true,
overrideAccess: true,
where: {
or: [{ spaPath: { equals: '/teilnahme' } }, { slug: { equals: 'teilnahme' } }, { slug: { equals: 'participation' } }],
} as never,
})
const page = pageResult.docs[0] as { id: string | number; participation?: Record<string, unknown> } | undefined
if (!page) throw new Error('Participation page not found. Expected spaPath=/teilnahme or slug=teilnahme/participation.')
const gridData = {
...participationAwardsGrid,
cards: participationAwardsGrid.cards.map(({ imageFilename: _imageFilename, ...card }) => ({
...card,
image: mediaIds.get(_imageFilename),
})),
}
await payload.update({
collection: 'pages',
id: page.id,
overrideAccess: true,
context: { disableRevalidate: true },
data: {
participation: {
...(page.participation || {}),
awardsGrid: gridData,
},
} as never,
})
}
async function main() {
const payload = await getPayload({ config })
const mediaIds = new Map<string, string | number>()
for (const article of awardArticles) {
if (!mediaIds.has(article.imageFilename)) {
mediaIds.set(article.imageFilename, await ensureMedia(payload, article.imageFilename, article.imageAlt))
}
}
await upsertAwardPosts(payload, mediaIds)
await preloadParticipationGrid(payload, mediaIds)
payload.logger.info(`Preloaded participation awards grid and ${awardArticles.length} award posts`)
process.exit(0)
}
main().catch((error) => {
console.error(error)
process.exit(1)
})

View File

@@ -1,7 +1,7 @@
import 'dotenv/config' import 'dotenv/config'
import config from '@payload-config' import config from '@payload-config'
import { getPayload, type Payload } from 'payload' import { getPayload } from 'payload'
import { mediaByFilename } from './mediaByFilename' import { mediaByFilename } from './mediaByFilename'
import { participationContent } from '@/spa/participationContent' import { participationContent } from '@/spa/participationContent'
@@ -23,14 +23,14 @@ async function main() {
const page = pageResult.docs[0] const page = pageResult.docs[0]
if (!page) throw new Error('Participation page not found. Expected spaPath=/teilnahme or slug=teilnahme/participation.') if (!page) throw new Error('Participation page not found. Expected spaPath=/teilnahme or slug=teilnahme/participation.')
const [heroImage, evaluationImage, formImage] = await Promise.all([ const [heroImage, formImage, ...awardCardImages] = await Promise.all([
mediaByFilename(payload, participationContent.hero.backgroundImageFilename), mediaByFilename(payload, participationContent.hero.backgroundImageFilename),
mediaByFilename(payload, participationContent.evaluation.imageFilename),
mediaByFilename(payload, participationContent.applicationForm.imageFilename), mediaByFilename(payload, participationContent.applicationForm.imageFilename),
...participationContent.awardsGrid.cards.map((card) => mediaByFilename(payload, card.imageFilename)),
]) ])
const { backgroundImageFilename: _heroFilename, ...heroContent } = participationContent.hero const { backgroundImageFilename: _heroFilename, ...heroContent } = participationContent.hero
const { imageFilename: _evaluationFilename, ...evaluationContent } = participationContent.evaluation
const { imageFilename: _formFilename, ...applicationFormContent } = participationContent.applicationForm const { imageFilename: _formFilename, ...applicationFormContent } = participationContent.applicationForm
const { cards: awardCards, ...awardsGridContent } = participationContent.awardsGrid
await payload.update({ await payload.update({
collection: 'pages', collection: 'pages',
@@ -45,12 +45,15 @@ async function main() {
}, },
process: participationContent.process, process: participationContent.process,
eligibility: participationContent.eligibility, eligibility: participationContent.eligibility,
evaluation: {
...evaluationContent,
image: evaluationImage,
},
applicationWays: participationContent.applicationWays, applicationWays: participationContent.applicationWays,
datesBanner: participationContent.datesBanner, datesBanner: participationContent.datesBanner,
awardsGrid: {
...awardsGridContent,
cards: awardCards.map(({ imageFilename: _imageFilename, ...card }, index) => ({
...card,
image: awardCardImages[index],
})),
},
applicationForm: { applicationForm: {
...applicationFormContent, ...applicationFormContent,
image: formImage, image: formImage,

View File

@@ -14,7 +14,7 @@ const orderedScripts = [
path: 'src/scripts/migrate-spa-pages.ts', path: 'src/scripts/migrate-spa-pages.ts',
}, },
{ {
label: 'Store header, footer, and site settings defaults', label: 'Store header, footer, site settings, and application phase defaults',
path: 'src/scripts/preload-globals-cms.ts', path: 'src/scripts/preload-globals-cms.ts',
}, },
{ {
@@ -65,6 +65,10 @@ const orderedScripts = [
label: 'Preload participation page', label: 'Preload participation page',
path: 'src/scripts/preload-participation-page-cms.ts', path: 'src/scripts/preload-participation-page-cms.ts',
}, },
{
label: 'Preload participation awards and award posts',
path: 'src/scripts/preload-participation-awards-cms.ts',
},
{ {
label: 'Preload Preisträger index page', label: 'Preload Preisträger index page',
path: 'src/scripts/preload-preistraeger-index-page-cms.ts', path: 'src/scripts/preload-preistraeger-index-page-cms.ts',

View File

@@ -1,10 +1,11 @@
'use client' 'use client'
import React, { useEffect, useState } from 'react' import React from 'react'
import Layout from '@/spa/components/layout/Layout' import Layout from '@/spa/components/layout/Layout'
import ScrollToTop from '@/spa/components/layout/ScrollToTop' import ScrollToTop from '@/spa/components/layout/ScrollToTop'
import { defaultHeaderData, type SpaFooterData, type SpaHeaderData } from '@/spa/cmsNavigation' import type { SpaApplicationPhaseData } from '@/spa/applicationPhase'
import { type SpaFooterData, type SpaHeaderData } from '@/spa/cmsNavigation'
import { CmsRouteProvider, type CmsRouteData } from '@/spa/cmsRoute' import { CmsRouteProvider, type CmsRouteData } from '@/spa/cmsRoute'
import type { SpaSiteSettingsData } from '@/spa/siteSettings' import type { SpaSiteSettingsData } from '@/spa/siteSettings'
import About from '@/spa/pages/About' import About from '@/spa/pages/About'
@@ -60,42 +61,20 @@ export default function NextApp({
footer, footer,
cmsRoute, cmsRoute,
siteSettings, siteSettings,
applicationPhase,
}: { }: {
pathname: string pathname: string
header?: SpaHeaderData header?: SpaHeaderData
footer?: SpaFooterData footer?: SpaFooterData
cmsRoute?: CmsRouteData cmsRoute?: CmsRouteData
siteSettings?: SpaSiteSettingsData siteSettings?: SpaSiteSettingsData
applicationPhase?: SpaApplicationPhaseData
}) { }) {
const [loading, setLoading] = useState(true)
const headerData = header || defaultHeaderData
useEffect(() => {
const timer = window.setTimeout(() => setLoading(false), 1500)
return () => window.clearTimeout(timer)
}, [])
if (loading) {
return (
<div className="fixed inset-0 z-[1000] bg-primary flex flex-col items-center justify-center">
<div className="relative">
<div className="w-24 h-24 border-2 border-accent/20 border-t-accent rounded-full animate-spin" />
<div className="absolute inset-0 flex items-center justify-center">
<span className="text-accent font-display font-bold text-2xl">{headerData.loadingBrand}</span>
</div>
</div>
<div className="mt-8 text-white/40 uppercase tracking-[0.4em] text-[10px] font-bold animate-pulse">
{headerData.loadingLabel}
</div>
</div>
)
}
const page = React.createElement(resolvePage(pathname)) const page = React.createElement(resolvePage(pathname))
return ( return (
<> <>
<CmsRouteProvider value={cmsRoute ? { ...cmsRoute, siteSettings } : undefined}> <CmsRouteProvider value={cmsRoute ? { ...cmsRoute, siteSettings, applicationPhase } : undefined}>
<ScrollToTop /> <ScrollToTop />
<Layout header={header} footer={footer}> <Layout header={header} footer={footer}>
{page} {page}

View File

@@ -0,0 +1,97 @@
export type SpaApplicationPhaseLink = {
label?: string | null
url?: string | null
to?: string | null
}
export type SpaApplicationPhaseItem = {
num?: string | null
tag?: string | null
pulse?: boolean | null
phase?: string | null
headline?: string | null
body?: string | null
cta?: SpaApplicationPhaseLink | null
accent?: string | null
bg?: string | null
glow?: string | null
meta?: string | null
successApplications?: string | null
successWinners?: string | null
membershipCta?: SpaApplicationPhaseLink | null
}
export type SpaApplicationPhaseData = {
activePhase: string
noActionLabel: string
successApplicationsLabel: string
successWinnersLabel: string
phases: SpaApplicationPhaseItem[]
}
export const defaultApplicationPhaseData: SpaApplicationPhaseData = {
activePhase: '0',
noActionLabel: 'Kein Handlungsbedarf',
successApplicationsLabel: 'Bewerbungen',
successWinnersLabel: 'Preisträger',
phases: [
{
num: '01',
tag: 'JETZT OFFEN',
pulse: true,
phase: 'Bewerbungsphase 2026',
headline: 'BEWERBEN SIE SICH JETZT.',
body:
'Die Bewerbungsphase 2026 ist eröffnet – es ist noch nicht zu spät. Reichen Sie Ihre Unterlagen bis zum 30. Juni kostenlos ein und zeigen Sie, was den bayerischen Mittelstand ausmacht.',
cta: { label: 'Jetzt kostenlos bewerben', url: '/teilnahme' },
accent: '#EFBF04',
bg: '#020A1E',
glow: 'rgba(239,191,4,0.14)',
meta: 'Deadline: 30. Juni 2026',
membershipCta: { label: 'Mitglied werden', url: '/mitglied-werden' },
},
{
num: '02',
tag: 'IN AUSWERTUNG',
pulse: true,
phase: 'Auswertungsphase 2026',
headline: 'DIE JURY BEWERTET GERADE.',
body:
'Die Anmeldephase 2026 ist geschlossen. Unsere unabhängige Expertenjury aus Wissenschaft und Praxis sichtet und bewertet derzeit alle Einreichungen.',
cta: {},
accent: '#4A8FC9',
bg: '#060D1C',
glow: 'rgba(74,143,201,0.12)',
meta: 'Ergebnis: August 2026',
membershipCta: { label: 'Mitglied werden', url: '/mitglied-werden' },
},
{
num: '03',
tag: 'ABGESCHLOSSEN',
pulse: false,
phase: 'Preisverleihung · Oktober 2026',
headline: 'DER BMP 2026 WAR EIN ERFOLG.',
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' },
accent: '#EFBF04',
bg: '#06090F',
glow: 'rgba(239,191,4,0.08)',
meta: 'Gala · Oktober 2026 · München',
successApplications: '247',
successWinners: '3',
},
],
}
export function normalizeApplicationPhaseData(
value?: Partial<SpaApplicationPhaseData> | null,
): SpaApplicationPhaseData {
return {
activePhase: value?.activePhase || defaultApplicationPhaseData.activePhase,
noActionLabel: value?.noActionLabel || defaultApplicationPhaseData.noActionLabel,
successApplicationsLabel: value?.successApplicationsLabel || defaultApplicationPhaseData.successApplicationsLabel,
successWinnersLabel: value?.successWinnersLabel || defaultApplicationPhaseData.successWinnersLabel,
phases: value?.phases?.length ? value.phases : defaultApplicationPhaseData.phases,
}
}

186
src/spa/awardArticles.ts Normal file
View File

@@ -0,0 +1,186 @@
export const AWARD_CONTACT_EMAIL = 'info@bmp-bayern.de'
const mailto = (subject: string) => `mailto:${AWARD_CONTACT_EMAIL}?subject=${encodeURIComponent(subject)}`
export const awardArticles = [
{
slug: 'goldene-bavaria',
title: 'Die Goldene Bavaria',
cat: 'Auszeichnung',
date: '30. Juni 2026',
author: 'BMP Redaktion',
authorRole: 'Bayerischer Mittelstandspreis',
readTime: '4 min',
imageFilename: 'goldene-bavaria.png',
imageAlt: 'Goldene Bavaria Illustration',
excerpt:
'Die höchste Auszeichnung des Bayerischen Mittelstandspreises steht für unternehmerische Exzellenz, gesellschaftliche Verantwortung und den Mut, Zukunft aktiv zu gestalten.',
sections: [
{
heading: 'Was ist die Goldene Bavaria?',
body:
'Die Goldene Bavaria ist die bedeutendste Einzelauszeichnung des Bayerischen Mittelstandspreises. Sie steht für unternehmerische Exzellenz, gesellschaftliche Verantwortung und den Mut, Zukunft aktiv zu gestalten. Werte, die den bayerischen Unternehmergeist seit Generationen prägen.',
},
{
body:
'Als physische Trophäe verkörpert sie diesen Anspruch auf einzigartige Weise: ein dreidimensionales, maßstabgetreues Abbild der Original-Bavaria auf der Theresienwiese. 30 Zentimeter hoch, in Bronze gegossen und vollständig vergoldet. Ein Stück Bayern, das bleibt.',
},
{
heading: 'Ein Preis mit Geschichte und Tradition',
body:
'Die Goldene Bavaria ist ein Wanderpokal. Wer sie erhält, trägt sie nicht nur nach Hause, sondern übernimmt Verantwortung. Im nächsten Jahr übergibt der Vorjahressieger die Trophäe persönlich an den neuen Würdigen. Eine Geste, die Verbundenheit schafft und aus einem Preis eine lebendige Gemeinschaft macht.',
},
{
heading: 'Neu im Jahr 2026',
body:
'Erstmals werden in diesem Jahr drei Goldene Bavaria verliehen: der Roland-Berger-Zukunftspreis für visionäre Unternehmensführung, der Bavarian Future Award als Sonderpreis der HAM und ein Sonderpreis für Gesellschaft und Bildung.',
},
{
heading: 'Wie sieht sie aus?',
body:
'Die Goldene Bavaria ist kein generisches Pokal-Design. Sie ist ein Kunstobjekt: gegossen nach den exakten dreidimensionalen Daten der weltbekannten Münchner Bavaria-Statue, gefertigt in Bronze und veredelt durch echte Vergoldung.',
},
{
body:
'Ein Foto der Goldenen Bavaria zeigen wir nach der feierlichen Erstverleihung am 17. Juli 2026. Aus Wertschätzung gegenüber den ersten drei Preisträgern gebührt ihnen der erste Blick.',
},
],
tags: ['Goldene Bavaria', 'Auszeichnung', 'Mittelstand', 'Bayern'],
relatedSlugs: ['roland-berger-zukunftspreis', 'bavarian-future-award'],
card: {
label: 'Auszeichnung 01',
title: 'Goldene\nBavaria',
description: 'Die höchste Auszeichnung des Bayerischen Mittelstandspreises.',
},
articleCta: { label: 'Artikel lesen', url: '/presse/blog/goldene-bavaria' },
mailtoCta: { label: 'Kontakt aufnehmen', url: mailto('Interesse an der Goldenen Bavaria') },
detailSidebarCta: {
heading: 'Zur Goldenen Bavaria',
text: 'Sie haben Fragen zur Auszeichnung oder zur Teilnahme am Bayerischen Mittelstandspreis?',
label: 'Kontakt aufnehmen',
url: mailto('Frage zur Goldenen Bavaria'),
},
},
{
slug: 'roland-berger-zukunftspreis',
title: 'Roland-Berger-Zukunftspreis',
cat: 'Auszeichnung',
date: '30. Juni 2026',
author: 'BMP Redaktion',
authorRole: 'Bayerischer Mittelstandspreis',
readTime: '3 min',
imageFilename: 'prof-roland-berger.jpg',
imageAlt: 'Prof. Dr. h.c. Roland Berger',
excerpt:
'Der Roland-Berger-Zukunftspreis würdigt Menschen, Unternehmen und Initiativen, die Zukunft nicht nur vorhersagen, sondern aktiv gestalten.',
sections: [
{
heading: 'Zukunft aktiv gestalten',
body:
'Mit dem Roland-Berger-Zukunftspreis wird 2026 erstmals eine besondere Auszeichnung im Rahmen des Bayerischen Mittelstandspreises vergeben. Der Preis richtet den Blick auf Menschen, Unternehmen und Initiativen, die Zukunft nicht nur vorhersagen, sondern aktiv gestalten.',
},
{
heading: 'Der Namensgeber',
body:
'Namensgeber des Preises ist Prof. Dr. h.c. Roland Berger, einer der renommiertesten Unternehmer und Vordenker Europas. Als Gründer der internationalen Strategieberatung Roland Berger sowie stellvertretender Vorsitzender des Stiftungsrats von Wir Eigentümerunternehmer steht er seit Jahrzehnten für unternehmerische Verantwortung, Innovationskraft und zukunftsorientiertes Denken.',
},
{
heading: 'Wofür der Preis steht',
body:
'Der Roland-Berger-Zukunftspreis würdigt herausragende Beiträge, die mit Robustheit, Resilienz, Zukunftsfähigkeit, Innovation, Nachhaltigkeit und Verantwortung überzeugen. Ausgezeichnet werden diejenigen, die Wandel aktiv gestalten und als Gestaltungskräfte Impulse für die Zukunft setzen.',
},
{
heading: 'Besonderer Stellenwert',
body:
'Die Auszeichnung persönlich von Prof. Dr. h.c. Roland Berger überreicht zu bekommen, ist eine hohe Anerkennung für herausragendes Engagement und wegweisende Leistungen. Der Preis verbindet unternehmerische Exzellenz mit dem Anspruch, nachhaltige Impulse für Wirtschaft und Gesellschaft zu setzen.',
},
{
body:
'Da der Roland-Berger-Zukunftspreis 2026 erstmals verliehen wird, folgen weitere Informationen zu Hintergründen, Kriterien und Besonderheiten dieser neuen Auszeichnung.',
},
],
tags: ['Roland Berger', 'Zukunftspreis', 'Innovation', 'Verantwortung'],
relatedSlugs: ['goldene-bavaria', 'bavarian-future-award'],
card: {
label: 'Auszeichnung 02',
title: 'Roland-Berger-\nZukunftspreis',
description: 'Für visionäre Unternehmensführung und zukunftsorientiertes Denken.',
},
articleCta: { label: 'Artikel lesen', url: '/presse/blog/roland-berger-zukunftspreis' },
mailtoCta: { label: 'Kontakt aufnehmen', url: mailto('Interesse am Roland-Berger-Zukunftspreis') },
detailSidebarCta: {
heading: 'Zum Zukunftspreis',
text: 'Sie möchten mehr über den Roland-Berger-Zukunftspreis oder eine passende Einreichung erfahren?',
label: 'Kontakt aufnehmen',
url: mailto('Frage zum Roland-Berger-Zukunftspreis'),
},
},
{
slug: 'bavarian-future-award',
title: 'Bavarian Future Award',
cat: 'Auszeichnung',
date: '30. Juni 2026',
author: 'BMP Redaktion',
authorRole: 'Bayerischer Mittelstandspreis',
readTime: '3 min',
imageFilename: 'goldene-bavaria.png',
imageAlt: 'Bavarian Future Award Illustration',
excerpt:
'Der Sonderpreis der Hochschule für angewandtes Management macht vorbildliche Unternehmensnachfolge im bayerischen Mittelstand sichtbar.',
sections: [
{
heading: 'Sonderpreis der Studierenden',
body:
'Der Bavarian Future Award ist ein Sonderpreis im Rahmen des Bayerischen Mittelstandspreises und wird von Studierenden des Masterstudiengangs Management (M.A.) der Hochschule für angewandtes Management vergeben.',
},
{
heading: 'Nachfolge als Zukunftsaufgabe',
body:
'Der Award zeichnet Unternehmen aus, die ihre Unternehmensnachfolge beispielhaft gestalten und damit die Zukunftsfähigkeit des Mittelstands sichern. Eine erfolgreiche Nachfolge ist weit mehr als die Übergabe von Verantwortung. Sie ist ein strategischer Prozess, der die Grundlage für langfristige Stabilität, Wachstum und unternehmerische Weiterentwicklung schafft.',
},
{
heading: 'Bewertungsschwerpunkte',
body:
'Im Mittelpunkt stehen Nachfolgelösungen, die zeigen, wie der Generationswechsel erfolgreich gelingen kann. Bewertet werden frühzeitige strategische Planung, transparente Kommunikation, klares Rollenverständnis, aktive Einbindung von Mitarbeitenden und die Fähigkeit, bewährte Traditionen mit neuen Impulsen zu verbinden.',
},
{
body:
'Unternehmen, die diese Faktoren berücksichtigen, schaffen Vertrauen, sichern wertvolles Know-how und legen den Grundstein für nachhaltigen Erfolg über Generationen hinweg.',
},
{
heading: 'Sichtbarkeit für vorbildliche Lösungen',
body:
'Der Bavarian Future Award macht diese Nachfolgelösungen sichtbar und würdigt Unternehmen, die Verantwortung übernehmen, Wandel aktiv gestalten und damit ein wichtiges Zeichen für die Zukunft des bayerischen Mittelstands setzen.',
},
],
tags: ['Bavarian Future Award', 'HAM', 'Nachfolge', 'Studentenpreis'],
relatedSlugs: ['goldene-bavaria', 'roland-berger-zukunftspreis'],
card: {
label: 'Auszeichnung 03',
title: 'Studentenpreis /\nBavarian Future Award',
description: 'Der Sonderpreis der HAM für vorbildlich gestaltete Unternehmensnachfolge.',
},
articleCta: { label: 'Artikel lesen', url: '/presse/blog/bavarian-future-award' },
mailtoCta: { label: 'Kontakt aufnehmen', url: mailto('Interesse am Bavarian Future Award') },
detailSidebarCta: {
heading: 'Zum Future Award',
text: 'Sie haben Fragen zum Studentenpreis oder möchten ein Unternehmen ins Gespräch bringen?',
label: 'Kontakt aufnehmen',
url: mailto('Frage zum Bavarian Future Award'),
},
},
]
export const participationAwardsGrid = {
eyebrow: 'Auszeichnungen 2026',
heading: 'DREI AUSZEICHNUNGEN.\nEIN ANSPRUCH.',
description:
'Direkt im Bewerbungsjahr 2026 stehen drei Auszeichnungen im Fokus: die Goldene Bavaria, der Roland-Berger-Zukunftspreis und der Studentenpreis Bavarian Future Award.',
cards: awardArticles.map(({ imageFilename, imageAlt, card, articleCta, mailtoCta }) => ({
...card,
imageFilename,
imageAlt,
articleCta,
mailtoCta,
})),
}

View File

@@ -20,6 +20,7 @@ export const blogDetailContent = {
tag: 'Presse', tag: 'Presse',
}, },
categoryColors: { categoryColors: {
Auszeichnung: '#111D55',
Innovation: '#2563EB', Innovation: '#2563EB',
Engagement: '#16A34A', Engagement: '#16A34A',
Wirtschaft: '#9333EA', Wirtschaft: '#9333EA',

View File

@@ -3,7 +3,7 @@ import type { Footer, Header, Media } from '@/payload-types'
export type SpaIconName = 'instagram' | 'linkedin' | 'facebook' export type SpaIconName = 'instagram' | 'linkedin' | 'facebook'
export type SpaLink = { label: string; path: string } export type SpaLink = { label: string; path: string }
export type SpaSocialLink = { label: string; href: string; icon: SpaIconName } export type SpaSocialLink = { label: string; href: string; icon: SpaIconName }
export type SpaCtaLink = SpaLink & { variant?: 'primary' | 'secondary' | 'subtle' | 'upload' } export type SpaCtaLink = SpaLink & { variant?: 'primary' | 'secondary' | 'subtle' | 'login' }
export type SpaMedia = { url?: string; alt?: string } export type SpaMedia = { url?: string; alt?: string }
export type SpaHeaderNavItem = SpaLink & { export type SpaHeaderNavItem = SpaLink & {
@@ -23,13 +23,10 @@ export type SpaHeaderData = {
closeLabel: string closeLabel: string
dialogLabel: string dialogLabel: string
overviewLabel: string overviewLabel: string
loadingBrand: string
loadingLabel: string
navItems: SpaHeaderNavItem[] navItems: SpaHeaderNavItem[]
secondaryLinks: SpaLink[] secondaryLinks: SpaLink[]
socialLinks: SpaSocialLink[] socialLinks: SpaSocialLink[]
ctas: SpaCtaLink[] ctas: SpaCtaLink[]
languages: string[]
} }
export type SpaFooterLinkColumn = { label: string; links: SpaLink[] } export type SpaFooterLinkColumn = { label: string; links: SpaLink[] }
@@ -69,9 +66,6 @@ export const defaultHeaderData: SpaHeaderData = {
closeLabel: 'Menü schließen', closeLabel: 'Menü schließen',
dialogLabel: 'Navigation', dialogLabel: 'Navigation',
overviewLabel: 'Überblick', overviewLabel: 'Überblick',
loadingBrand: 'BMP',
loadingLabel: 'Exzellenz wird geladen...',
languages: ['DE', 'EN'],
socialLinks: defaultSocialLinks, socialLinks: defaultSocialLinks,
navItems: [ navItems: [
{ {
@@ -85,7 +79,6 @@ export const defaultHeaderData: SpaHeaderData = {
key: 'teilnahme', label: 'Ihr Weg zum Preis', path: '/teilnahme', subLabel: 'IHR WEG ZUM PREIS', key: 'teilnahme', label: 'Ihr Weg zum Preis', path: '/teilnahme', subLabel: 'IHR WEG ZUM PREIS',
sub: [ sub: [
{ label: 'Teilnahmevoraussetzungen', path: '/teilnahme#voraussetzungen' }, { label: 'Teilnahmevoraussetzungen', path: '/teilnahme#voraussetzungen' },
{ label: 'Kriterien & Bewertung', path: '/teilnahme#kriterien' },
{ label: 'Fristen & Termine', path: '/teilnahme#fristen' }, { label: 'Fristen & Termine', path: '/teilnahme#fristen' },
{ label: 'Bewerbungsformular', path: '/teilnahme#bewerben' }, { label: 'Bewerbungsformular', path: '/teilnahme#bewerben' },
], ],
@@ -124,7 +117,7 @@ export const defaultHeaderData: SpaHeaderData = {
ctas: [ ctas: [
{ label: 'Jetzt bewerben', path: '/teilnahme#bewerben', variant: 'primary' }, { label: 'Jetzt bewerben', path: '/teilnahme#bewerben', variant: 'primary' },
{ label: 'Mitglied werden', path: '/mitglied-werden', variant: 'secondary' }, { label: 'Mitglied werden', path: '/mitglied-werden', variant: 'secondary' },
{ label: 'Formular hochladen', path: '/formular-hochladen', variant: 'upload' }, { label: 'Login', path: '/admin', variant: 'login' },
], ],
} }
@@ -173,7 +166,25 @@ const mediaToSpa = (value: unknown, fallback?: SpaMedia): SpaMedia | undefined =
return fallback return fallback
} }
type HeaderLanguage = string | { label?: string } const isParticipationCriteriaLink = (link?: { label?: string; path?: string }) => {
const normalizedLabel = link?.label?.trim().toLowerCase()
return (
link?.path === '/teilnahme#kriterien' ||
normalizedLabel === 'kriterien & bewertung' ||
normalizedLabel === 'kriterien und bewertung'
)
}
const withoutParticipationCriteriaLink = (items: SpaHeaderNavItem[]) =>
items.map((item) =>
item.key === 'teilnahme'
? {
...item,
sub: item.sub?.filter((subItem) => !isParticipationCriteriaLink(subItem)),
}
: item,
)
type FooterPartnerLogo = { type FooterPartnerLogo = {
alt?: string alt?: string
@@ -181,13 +192,15 @@ type FooterPartnerLogo = {
} }
export function normalizeHeaderData(header?: Partial<Header>): SpaHeaderData { export function normalizeHeaderData(header?: Partial<Header>): SpaHeaderData {
const navItems = header?.navItems?.length
? (header.navItems as unknown as SpaHeaderNavItem[])
: defaultHeaderData.navItems
return { return {
...defaultHeaderData, ...defaultHeaderData,
...header, ...header,
logo: mediaToSpa(header?.logo, defaultHeaderData.logo), logo: mediaToSpa(header?.logo, defaultHeaderData.logo),
navItems: header?.navItems?.length navItems: withoutParticipationCriteriaLink(navItems),
? (header.navItems as unknown as SpaHeaderNavItem[])
: defaultHeaderData.navItems,
secondaryLinks: header?.secondaryLinks?.length secondaryLinks: header?.secondaryLinks?.length
? (header.secondaryLinks as unknown as SpaLink[]) ? (header.secondaryLinks as unknown as SpaLink[])
: defaultHeaderData.secondaryLinks, : defaultHeaderData.secondaryLinks,
@@ -195,11 +208,6 @@ export function normalizeHeaderData(header?: Partial<Header>): SpaHeaderData {
? (header.socialLinks as unknown as SpaSocialLink[]) ? (header.socialLinks as unknown as SpaSocialLink[])
: defaultHeaderData.socialLinks, : defaultHeaderData.socialLinks,
ctas: header?.ctas?.length ? (header.ctas as unknown as SpaCtaLink[]) : defaultHeaderData.ctas, ctas: header?.ctas?.length ? (header.ctas as unknown as SpaCtaLink[]) : defaultHeaderData.ctas,
languages: header?.languages?.length
? (header.languages as HeaderLanguage[])
.map((item) => (typeof item === 'string' ? item : item.label))
.filter((label): label is string => Boolean(label))
: defaultHeaderData.languages,
} }
} }

View File

@@ -2,6 +2,7 @@
import React, { createContext, useContext } from 'react' import React, { createContext, useContext } from 'react'
import type { SpaApplicationPhaseData } from '@/spa/applicationPhase'
import type { SpaSiteSettingsData } from '@/spa/siteSettings' import type { SpaSiteSettingsData } from '@/spa/siteSettings'
export type CmsRouteCollection = 'pages' | 'posts' | 'events' | 'preistraeger' export type CmsRouteCollection = 'pages' | 'posts' | 'events' | 'preistraeger'
@@ -24,6 +25,7 @@ export type CmsRouteData = {
doc: CmsRouteDoc doc: CmsRouteDoc
lists?: Partial<Record<CmsRouteCollection, CmsRouteDoc[]>> lists?: Partial<Record<CmsRouteCollection, CmsRouteDoc[]>>
siteSettings?: SpaSiteSettingsData siteSettings?: SpaSiteSettingsData
applicationPhase?: SpaApplicationPhaseData
} }
const CmsRouteContext = createContext<CmsRouteData | undefined>(undefined) const CmsRouteContext = createContext<CmsRouteData | undefined>(undefined)
@@ -39,3 +41,7 @@ export function useCmsRoute() {
export function useCmsCollection(collection: CmsRouteCollection) { export function useCmsCollection(collection: CmsRouteCollection) {
return useCmsRoute()?.lists?.[collection] || [] return useCmsRoute()?.lists?.[collection] || []
} }
export function useApplicationPhase() {
return useCmsRoute()?.applicationPhase
}

View File

@@ -4,7 +4,7 @@ import Image from '@/spa/components/ui/UnoptimizedImage'
import React, { useState, useEffect, useRef } from 'react'; import React, { useState, useEffect, useRef } from 'react';
import { Link, useLocation } from '@/spa/router'; import { Link, useLocation } from '@/spa/router';
import { ArrowRight, Upload } from 'lucide-react'; import { ArrowRight } from 'lucide-react';
import LoadingScreen from '@/spa/components/ui/LoadingScreen'; import LoadingScreen from '@/spa/components/ui/LoadingScreen';
import { useIsMobile } from '@/spa/hooks/useIsMobile'; import { useIsMobile } from '@/spa/hooks/useIsMobile';
import { import {
@@ -36,6 +36,10 @@ const SEP = 'rgba(239,191,4,0.2)';
const LINE = 'rgba(239,191,4,0.35)'; const LINE = 'rgba(239,191,4,0.35)';
const FF_DISPLAY = '"IBM Plex Sans", sans-serif'; const FF_DISPLAY = '"IBM Plex Sans", sans-serif';
const FF_BODY = '"Inter", sans-serif'; const FF_BODY = '"Inter", sans-serif';
const HIDE_MEMBERSHIP_FOOTER_ENTRY = true;
const isMembershipFooterLink = (link?: { label?: string; path?: string }) =>
link?.path === '/mitglied-werden' || link?.label?.trim().toLowerCase() === 'mitglied werden';
/* ── SocialLink helper ─────────────────────────────────── */ /* ── SocialLink helper ─────────────────────────────────── */
const SocialLink: React.FC<{ href:string; icon:SpaIconName; label:string }> = ({ href, icon, label }) => { const SocialLink: React.FC<{ href:string; icon:SpaIconName; label:string }> = ({ href, icon, label }) => {
@@ -54,24 +58,29 @@ const SocialLink: React.FC<{ href:string; icon:SpaIconName; label:string }> = ({
const Layout: React.FC<LayoutProps> = ({ children, header = defaultHeaderData, footer = defaultFooterData }) => { const Layout: React.FC<LayoutProps> = ({ children, header = defaultHeaderData, footer = defaultFooterData }) => {
const NAV = header.navItems; const NAV = header.navItems;
const SECONDARY = header.secondaryLinks; const SECONDARY = header.secondaryLinks;
const SOCIAL = header.socialLinks; const linkedInHeaderSocial = header.socialLinks.filter((item) => item.icon === 'linkedin');
const linkedInFooterSocial = footer.socialLinks.filter((item) => item.icon === 'linkedin');
const HEADER_SOCIAL = linkedInHeaderSocial.length
? linkedInHeaderSocial
: defaultHeaderData.socialLinks.filter((item) => item.icon === 'linkedin');
const FOOTER_SOCIAL = linkedInFooterSocial.length
? linkedInFooterSocial
: defaultFooterData.socialLinks.filter((item) => item.icon === 'linkedin');
const logo = header.logo?.url || defaultHeaderData.logo?.url || ''; const logo = header.logo?.url || defaultHeaderData.logo?.url || '';
const footerLogo = footer.logo?.url || defaultFooterData.logo?.url || ''; const footerLogo = footer.logo?.url || defaultFooterData.logo?.url || '';
const headerCtas = header.ctas; const headerCtas = header.ctas;
const footerCtas = footer.ctas; const footerCtas = footer.ctas;
const fallbackHeaderCtas = defaultHeaderData.ctas; const fallbackHeaderCtas = defaultHeaderData.ctas;
const fallbackFooterCtas = defaultFooterData.ctas; const fallbackFooterCtas = defaultFooterData.ctas;
const footerMembershipCta = footerCtas[1] || fallbackFooterCtas[1];
const showFooterMembershipCta =
!HIDE_MEMBERSHIP_FOOTER_ENTRY || !isMembershipFooterLink(footerMembershipCta);
const [open, setOpen] = useState(false); // desktop overlay const [open, setOpen] = useState(false); // desktop overlay
const [activeKey, setActive] = useState<string | null>(null); // desktop hover subnav const [activeKey, setActive] = useState<string | null>(null); // desktop hover subnav
const [mobileOpen, setMobileOpen] = useState(false); // mobile sidebar overlay const [mobileOpen, setMobileOpen] = useState(false); // mobile sidebar overlay
const [expandKey, setExpand] = useState<string | null>(null); // mobile accordion const [expandKey, setExpand] = useState<string | null>(null); // mobile accordion
const [lang, setLang] = useState(header.languages[0] || defaultHeaderData.languages[0]);
const [mounted, setMounted] = useState(false); const [mounted, setMounted] = useState(false);
const [showLoader, setShowLoader] = useState(() => { const [showLoader, setShowLoader] = useState(true);
if (typeof window === 'undefined') return false;
sessionStorage.removeItem('bmp-intro-seen');
return true;
});
const [formVisible, setFormVisible] = useState(false); const [formVisible, setFormVisible] = useState(false);
const location = useLocation(); const location = useLocation();
const firstLinkRef = useRef<HTMLAnchorElement>(null); const firstLinkRef = useRef<HTMLAnchorElement>(null);
@@ -220,24 +229,10 @@ const Layout: React.FC<LayoutProps> = ({ children, header = defaultHeaderData, f
<span style={{ fontSize:8, color:'rgba(5,26,135,0.5)', letterSpacing:2, userSelect:'none' }}>◆</span> <span style={{ fontSize:8, color:'rgba(5,26,135,0.5)', letterSpacing:2, userSelect:'none' }}>◆</span>
</div> </div>
{/* RIGHT – social + lang */} {/* RIGHT – social */}
<div style={{ flex:1, display:'flex', alignItems:'center', justifyContent:'flex-end', gap:20, padding:'0 28px', height:'100%' }}> <div style={{ flex:1, display:'flex', alignItems:'center', justifyContent:'flex-end', padding:'0 28px', height:'100%' }}>
<div style={{ display:'flex', gap:14, alignItems:'center' }}> <div style={{ display:'flex', gap:14, alignItems:'center' }}>
{SOCIAL.map(s => <SocialLink key={s.label} {...s} />)} {HEADER_SOCIAL.map(s => <SocialLink key={s.label} {...s} />)}
</div>
<div style={{ width:1, height:20, background:'rgba(239,191,4,0.3)' }} />
<div style={{ display:'flex', alignItems:'center', gap:8 }}>
{header.languages.map((l, i) => (
<React.Fragment key={l}>
{i===1 && <span style={{ color:'rgba(239,191,4,.35)', fontSize: 14 }}>|</span>}
<button
onClick={() => setLang(l)}
style={{ fontSize:10, fontWeight:600, letterSpacing:'.1em', textTransform:'uppercase', background:'none', border:'none', cursor:'pointer', padding:'1px 0', lineHeight:1, color:lang===l ? GOLD : MUTED, transition:'color .2s', fontFamily:FF_DISPLAY }}
onMouseEnter={e => { if (lang !== l) e.currentTarget.style.color = '#fff'; }}
onMouseLeave={e => { if (lang !== l) e.currentTarget.style.color = MUTED; }}
>{l}</button>
</React.Fragment>
))}
</div> </div>
</div> </div>
@@ -326,7 +321,7 @@ const Layout: React.FC<LayoutProps> = ({ children, header = defaultHeaderData, f
transition:'opacity .2s ease, transform .2s ease', transition:'opacity .2s ease, transform .2s ease',
pointerEvents: activeKey===item.key ? 'all' : 'none', pointerEvents: activeKey===item.key ? 'all' : 'none',
}}> }}>
<div style={{ fontSize:11, fontWeight:700, textTransform:'uppercase', letterSpacing:'.22em', color:'#009EE0', marginBottom:16, fontFamily:FF_DISPLAY }}> <div style={{ fontSize:11, fontWeight:700, textTransform:'uppercase', letterSpacing:'.22em', color:'rgba(255,255,255,.4)', marginBottom:16, fontFamily:FF_DISPLAY }}>
{header.overviewLabel} {header.overviewLabel}
</div> </div>
<div style={{ fontSize: 16, fontWeight:700, textTransform:'uppercase', letterSpacing:'.1em', color:'#fff', marginBottom:20, fontFamily:FF_DISPLAY }}> <div style={{ fontSize: 16, fontWeight:700, textTransform:'uppercase', letterSpacing:'.1em', color:'#fff', marginBottom:20, fontFamily:FF_DISPLAY }}>
@@ -352,20 +347,8 @@ const Layout: React.FC<LayoutProps> = ({ children, header = defaultHeaderData, f
<div style={{ padding:'40px 80px 40px 48px', display:'flex', flexDirection:'column', gap:24 }}> <div style={{ padding:'40px 80px 40px 48px', display:'flex', flexDirection:'column', gap:24 }}>
<div style={{ display:'flex', alignItems:'center', gap:20, flexWrap:'wrap' }}> <div style={{ display:'flex', alignItems:'center', gap:20, flexWrap:'wrap' }}>
<div style={{ display:'flex', alignItems:'center', gap:8 }}>
{header.languages.map((l,i) => (
<React.Fragment key={l}>
{i===1 && <span style={{ color:'rgba(239,191,4,.35)', fontSize: 14 }}>|</span>}
<button onClick={() => setLang(l)}
style={{ fontSize:12, fontWeight:600, letterSpacing:'.1em', textTransform:'uppercase', background:'none', border:'none', cursor:'pointer', padding:0, color:lang===l ? GOLD : MUTED, transition:'color .2s' }}
onMouseEnter={e=>{ if(lang!==l) e.currentTarget.style.color='#fff'; }}
onMouseLeave={e=>{ if(lang!==l) e.currentTarget.style.color=MUTED; }}
>{l}</button>
</React.Fragment>
))}
</div>
<div style={{ display:'flex', gap:14 }}> <div style={{ display:'flex', gap:14 }}>
{SOCIAL.map(s => <SocialLink key={s.label} {...s} />)} {HEADER_SOCIAL.map(s => <SocialLink key={s.label} {...s} />)}
</div> </div>
</div> </div>
@@ -385,39 +368,34 @@ const Layout: React.FC<LayoutProps> = ({ children, header = defaultHeaderData, f
onMouseEnter={e=>{ e.currentTarget.style.background='#FFD130'; e.currentTarget.style.boxShadow='0 0 18px rgba(239,191,4,0.65), 0 0 40px rgba(239,191,4,0.3)'; }} onMouseEnter={e=>{ e.currentTarget.style.background='#FFD130'; e.currentTarget.style.boxShadow='0 0 18px rgba(239,191,4,0.65), 0 0 40px rgba(239,191,4,0.3)'; }}
onMouseLeave={e=>{ e.currentTarget.style.background='#EFBF04'; e.currentTarget.style.boxShadow='none'; }} onMouseLeave={e=>{ e.currentTarget.style.background='#EFBF04'; e.currentTarget.style.boxShadow='none'; }}
>{headerCtas[0]?.label || fallbackHeaderCtas[0].label}</Link> >{headerCtas[0]?.label || fallbackHeaderCtas[0].label}</Link>
<Link to={headerCtas[1]?.path || fallbackHeaderCtas[1].path} onClick={() => setOpen(false)}
style={{ display:'inline-block', background:'transparent', color:MUTED, fontSize: 15, fontWeight:700, textTransform:'uppercase', letterSpacing:'.12em', padding:'10px 24px', textDecoration:'none', transition:'border-color .2s, color .2s', borderRadius:0, fontFamily:FF_DISPLAY, border:'1.5px solid rgba(239,191,4,0.35)' }}
onMouseEnter={e=>{ e.currentTarget.style.borderColor='#EFBF04'; e.currentTarget.style.color='#EFBF04'; }}
onMouseLeave={e=>{ e.currentTarget.style.borderColor='rgba(239,191,4,0.35)'; e.currentTarget.style.color=MUTED; }}
>{headerCtas[1]?.label || fallbackHeaderCtas[1].label}</Link>
<div style={{ height:'1px', background:'rgba(239,191,4,0.12)', margin:'4px 0' }} /> <div style={{ height:'1px', background:'rgba(239,191,4,0.12)', margin:'4px 0' }} />
<Link to={headerCtas[2]?.path || fallbackHeaderCtas[2].path} onClick={() => setOpen(false)} <Link to={headerCtas[2]?.path || fallbackHeaderCtas[2].path} onClick={() => setOpen(false)}
style={{ style={{
display:'flex', alignItems:'center', gap:10, display:'flex', alignItems:'center', gap:10,
background:'rgba(255,255,255,0.04)', background:'transparent',
color:'rgba(255,255,255,0.45)', color:MUTED,
fontSize:11, fontWeight:700, fontSize:11, fontWeight:700,
textTransform:'uppercase', letterSpacing:'.14em', textTransform:'uppercase', letterSpacing:'.14em',
padding:'10px 18px', padding:'10px 18px',
textDecoration:'none', textDecoration:'none',
fontFamily:FF_DISPLAY, fontFamily:FF_DISPLAY,
border:'1px dashed rgba(255,255,255,0.15)', border:'1px solid rgba(239,191,4,0.28)',
transition:'color .2s, border-color .2s, background .2s', transition:'color .2s, border-color .2s, background .2s',
}} }}
onMouseEnter={e=>{ onMouseEnter={e=>{
e.currentTarget.style.color='#fff'; e.currentTarget.style.color=GOLD;
e.currentTarget.style.borderColor='rgba(255,255,255,0.4)'; e.currentTarget.style.borderColor='rgba(239,191,4,0.7)';
e.currentTarget.style.background='rgba(255,255,255,0.07)'; e.currentTarget.style.background='rgba(239,191,4,0.05)';
}} }}
onMouseLeave={e=>{ onMouseLeave={e=>{
e.currentTarget.style.color='rgba(255,255,255,0.45)'; e.currentTarget.style.color=MUTED;
e.currentTarget.style.borderColor='rgba(255,255,255,0.15)'; e.currentTarget.style.borderColor='rgba(239,191,4,0.28)';
e.currentTarget.style.background='rgba(255,255,255,0.04)'; e.currentTarget.style.background='transparent';
}} }}
> >
<Upload size={12} strokeWidth={2} /> {headerCtas[2]?.label || fallbackHeaderCtas[2].label} {headerCtas[2]?.label || fallbackHeaderCtas[2].label}
</Link> </Link>
</div> </div>
@@ -560,31 +538,16 @@ const Layout: React.FC<LayoutProps> = ({ children, header = defaultHeaderData, f
<Link to={headerCtas[0]?.path || fallbackHeaderCtas[0].path} onClick={() => setMobileOpen(false)} <Link to={headerCtas[0]?.path || fallbackHeaderCtas[0].path} onClick={() => setMobileOpen(false)}
style={{ display:'flex', alignItems:'center', justifyContent:'center', minHeight:52, background:GOLD, color:'#101828', fontSize:14, fontWeight:700, textTransform:'uppercase', letterSpacing:'.12em', textDecoration:'none', fontFamily:FF_DISPLAY }} style={{ display:'flex', alignItems:'center', justifyContent:'center', minHeight:52, background:GOLD, color:'#101828', fontSize:14, fontWeight:700, textTransform:'uppercase', letterSpacing:'.12em', textDecoration:'none', fontFamily:FF_DISPLAY }}
>{headerCtas[0]?.label || fallbackHeaderCtas[0].label}</Link> >{headerCtas[0]?.label || fallbackHeaderCtas[0].label}</Link>
<Link to={headerCtas[1]?.path || fallbackHeaderCtas[1].path} onClick={() => setMobileOpen(false)}
style={{ display:'flex', alignItems:'center', justifyContent:'center', minHeight:50, background:'transparent', color:'#fff', fontSize:14, fontWeight:700, textTransform:'uppercase', letterSpacing:'.12em', textDecoration:'none', fontFamily:FF_DISPLAY, border:`1.5px solid ${LINE}` }}
>{headerCtas[1]?.label || fallbackHeaderCtas[1].label}</Link>
<Link to={headerCtas[2]?.path || fallbackHeaderCtas[2].path} onClick={() => setMobileOpen(false)} <Link to={headerCtas[2]?.path || fallbackHeaderCtas[2].path} onClick={() => setMobileOpen(false)}
style={{ display:'flex', alignItems:'center', justifyContent:'center', gap:8, minHeight:46, background:'none', color:'rgba(255,255,255,0.5)', fontSize:11, fontWeight:700, textTransform:'uppercase', letterSpacing:'.14em', textDecoration:'none', fontFamily:FF_DISPLAY, border:'1px dashed rgba(255,255,255,0.2)' }} style={{ display:'flex', alignItems:'center', justifyContent:'center', minHeight:46, background:'none', color:MUTED, fontSize:11, fontWeight:700, textTransform:'uppercase', letterSpacing:'.14em', textDecoration:'none', fontFamily:FF_DISPLAY, border:'1px solid rgba(239,191,4,0.28)' }}
><Upload size={13} strokeWidth={2} /> {headerCtas[2]?.label || fallbackHeaderCtas[2].label}</Link> >{headerCtas[2]?.label || fallbackHeaderCtas[2].label}</Link>
</div> </div>
{/* Footer zone: lang + social + secondary */} {/* Footer zone: social + secondary */}
<div style={{ marginTop:8, padding:'20px 24px 28px', borderTop:`1px solid ${SEP}`, display:'flex', flexDirection:'column', gap:18 }}> <div style={{ marginTop:8, padding:'20px 24px 28px', borderTop:`1px solid ${SEP}`, display:'flex', flexDirection:'column', gap:18 }}>
{/* Lang + social row */} {/* Social row */}
<div style={{ display:'flex', alignItems:'center', justifyContent:'space-between' }}> <div style={{ display:'flex', gap:18 }}>
<div style={{ display:'flex', alignItems:'center', gap:10 }}> {HEADER_SOCIAL.map(s => <SocialLink key={s.label} {...s} />)}
{header.languages.map((l, i) => (
<React.Fragment key={l}>
{i === 1 && <span style={{ color:LINE, fontSize:13 }}>|</span>}
<button onClick={() => setLang(l)}
style={{ fontSize:12, fontWeight:600, letterSpacing:'.1em', textTransform:'uppercase', background:'none', border:'none', cursor:'pointer', color:lang===l ? GOLD : MUTED, padding:'4px 0', lineHeight:1, fontFamily:FF_DISPLAY }}
>{l}</button>
</React.Fragment>
))}
</div>
<div style={{ display:'flex', gap:18 }}>
{SOCIAL.map(s => <SocialLink key={s.label} {...s} />)}
</div>
</div> </div>
{/* Secondary links */} {/* Secondary links */}
<div style={{ display:'flex', flexWrap:'wrap', gap:'0 20px' }}> <div style={{ display:'flex', flexWrap:'wrap', gap:'0 20px' }}>
@@ -770,31 +733,33 @@ const Layout: React.FC<LayoutProps> = ({ children, header = defaultHeaderData, f
}} }}
>{footerCtas[0]?.label || fallbackFooterCtas[0].label}</Link> >{footerCtas[0]?.label || fallbackFooterCtas[0].label}</Link>
<Link {showFooterMembershipCta && (
to={footerCtas[1]?.path || fallbackFooterCtas[1].path} <Link
style={{ to={footerCtas[1]?.path || fallbackFooterCtas[1].path}
fontFamily: FF_DISPLAY, style={{
fontSize: 16, fontFamily: FF_DISPLAY,
fontWeight: 700, fontSize: 16,
textTransform: 'uppercase', fontWeight: 700,
letterSpacing: '0.12em', textTransform: 'uppercase',
color: MUTED, letterSpacing: '0.12em',
border: '1.5px solid rgba(239,191,4,0.35)', color: MUTED,
padding: '8px 22px', border: '1.5px solid rgba(239,191,4,0.35)',
textDecoration: 'none', padding: '8px 22px',
display: 'inline-block', textDecoration: 'none',
borderRadius: 2, display: 'inline-block',
transition: 'border-color 0.15s, color 0.15s', borderRadius: 2,
}} transition: 'border-color 0.15s, color 0.15s',
onMouseEnter={e => { }}
e.currentTarget.style.borderColor = GOLD; onMouseEnter={e => {
e.currentTarget.style.color = GOLD; e.currentTarget.style.borderColor = GOLD;
}} e.currentTarget.style.color = GOLD;
onMouseLeave={e => { }}
e.currentTarget.style.borderColor = 'rgba(239,191,4,0.35)'; onMouseLeave={e => {
e.currentTarget.style.color = MUTED; e.currentTarget.style.borderColor = 'rgba(239,191,4,0.35)';
}} e.currentTarget.style.color = MUTED;
>{footerCtas[1]?.label || fallbackFooterCtas[1].label}</Link> }}
>{footerCtas[1]?.label || fallbackFooterCtas[1].label}</Link>
)}
<Link <Link
to={footerCtas[2]?.path || fallbackFooterCtas[2].path} to={footerCtas[2]?.path || fallbackFooterCtas[2].path}
@@ -937,7 +902,7 @@ const Layout: React.FC<LayoutProps> = ({ children, header = defaultHeaderData, f
{footer.copyright} {footer.copyright}
</p> </p>
<div style={{ display: 'flex', gap: 20, alignItems: 'center' }}> <div style={{ display: 'flex', gap: 20, alignItems: 'center' }}>
{SOCIAL.map(s => <SocialLink key={s.label} {...s} />)} {FOOTER_SOCIAL.map(s => <SocialLink key={s.label} {...s} />)}
</div> </div>
</div> </div>
</footer> </footer>

View File

@@ -9,7 +9,7 @@ import { Link } from '@/spa/router'
const ANTHRA = '#1A2030' const ANTHRA = '#1A2030'
const NAVY = '#111D55' const NAVY = '#111D55'
const GOLD = '#EFBF04' const GOLD = '#EFBF04'
const CREAM = '#EFE5E3' const PAGE_BG = '#fff'
const FF = '"IBM Plex Sans", sans-serif' const FF = '"IBM Plex Sans", sans-serif'
const FB = '"Inter", sans-serif' const FB = '"Inter", sans-serif'
@@ -51,7 +51,7 @@ export default function LegalPage({ content, cmsKey }: { content: LegalPageConte
})) }))
return ( return (
<div style={{ background: CREAM, minHeight: '100vh' }}> <div style={{ background: PAGE_BG, minHeight: '100vh' }}>
<section style={{ background: ANTHRA, padding: isMobile ? '48px 24px 40px' : '64px 80px 52px', position: 'relative', overflow: 'hidden' }}> <section style={{ background: ANTHRA, padding: isMobile ? '48px 24px 40px' : '64px 80px 52px', position: 'relative', overflow: 'hidden' }}>
<div style={{ position: 'absolute', bottom: 0, left: 0, right: 0, height: 2, background: `linear-gradient(to right, ${GOLD}, rgba(239,191,4,0.2), transparent)` }} /> <div style={{ position: 'absolute', bottom: 0, left: 0, right: 0, height: 2, background: `linear-gradient(to right, ${GOLD}, rgba(239,191,4,0.2), transparent)` }} />
<Link <Link

View File

@@ -63,6 +63,7 @@ function useContainerScrollContext() {
export const ContainerScroll = ({ export const ContainerScroll = ({
children, children,
className, className,
style,
...props ...props
}: React.HtmlHTMLAttributes<HTMLDivElement>) => { }: React.HtmlHTMLAttributes<HTMLDivElement>) => {
const scrollRef = React.useRef<HTMLDivElement>(null) const scrollRef = React.useRef<HTMLDivElement>(null)
@@ -74,6 +75,7 @@ export const ContainerScroll = ({
<div <div
ref={scrollRef} ref={scrollRef}
className={cn("relative min-h-[120vh]", className)} className={cn("relative min-h-[120vh]", className)}
style={{ position: "relative", ...style }}
{...props} {...props}
> >
{children} {children}
@@ -126,7 +128,7 @@ export const ProcessCard: React.FC<ProcessCardProps> = ({
const { scrollYProgress } = useContainerScrollContext() const { scrollYProgress } = useContainerScrollContext()
const start = index / itemsLength const start = index / itemsLength
const end = start + 1 / itemsLength const end = start + 1 / itemsLength
const { innerWidth } = window const innerWidth = typeof window === "undefined" ? 0 : window.innerWidth
const [ref, { width }] = useMeasure() const [ref, { width }] = useMeasure()
const x = useTransform( const x = useTransform(

View File

@@ -61,60 +61,6 @@ export const homeContent = {
'Wir suchen Vorbilder, die sich trotz aller Risiken nicht scheuen, Visionen in Businesspläne und Ideen in Unternehmungen zu verwandeln, und das mit großem persönlichen Einsatz und Verantwortung für Ihre Mitarbeiter. Seit vielen Jahren würdigen wir herausragende unternehmerische Leistungen im Freistaat.', 'Wir suchen Vorbilder, die sich trotz aller Risiken nicht scheuen, Visionen in Businesspläne und Ideen in Unternehmungen zu verwandeln, und das mit großem persönlichen Einsatz und Verantwortung für Ihre Mitarbeiter. Seit vielen Jahren würdigen wir herausragende unternehmerische Leistungen im Freistaat.',
cta: { label: 'Mehr über den Preis', url: '/der-bmp' }, cta: { label: 'Mehr über den Preis', url: '/der-bmp' },
}, },
status: {
activePhase: '0',
noActionLabel: 'Kein Handlungsbedarf',
successApplicationsLabel: 'Bewerbungen',
successWinnersLabel: 'Preisträger',
phases: [
{
num: '01',
tag: 'JETZT OFFEN',
pulse: true,
phase: 'Bewerbungsphase 2026',
headline: 'BEWERBEN SIE SICH JETZT.',
body:
'Die Bewerbungsphase 2026 ist eröffnet – es ist noch nicht zu spät. Reichen Sie Ihre Unterlagen bis zum 30. Juni kostenlos ein und zeigen Sie, was den bayerischen Mittelstand ausmacht.',
cta: { label: 'Jetzt kostenlos bewerben', url: '/teilnahme' },
accent: '#EFBF04',
bg: '#020A1E',
glow: 'rgba(239,191,4,0.14)',
meta: 'Deadline: 30. Juni 2026',
membershipCta: { label: 'Mitglied werden', url: '/mitglied-werden' },
},
{
num: '02',
tag: 'IN AUSWERTUNG',
pulse: true,
phase: 'Auswertungsphase 2026',
headline: 'DIE JURY BEWERTET GERADE.',
body:
'Die Anmeldephase 2026 ist geschlossen. Unsere unabhängige Expertenjury aus Wissenschaft und Praxis sichtet und bewertet derzeit alle Einreichungen.',
cta: {},
accent: '#4A8FC9',
bg: '#060D1C',
glow: 'rgba(74,143,201,0.12)',
meta: 'Ergebnis: August 2026',
membershipCta: { label: 'Mitglied werden', url: '/mitglied-werden' },
},
{
num: '03',
tag: 'ABGESCHLOSSEN',
pulse: false,
phase: 'Preisverleihung · Oktober 2026',
headline: 'DER BMP 2026 WAR EIN ERFOLG.',
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' },
accent: '#EFBF04',
bg: '#06090F',
glow: 'rgba(239,191,4,0.08)',
meta: 'Gala · Oktober 2026 · München',
successApplications: '247',
successWinners: '3',
},
],
},
winners: { winners: {
eyebrow: 'Success Stories', eyebrow: 'Success Stories',
heading: 'DIE BESTEN UNTERNEHMEN IN BAYERN', heading: 'DIE BESTEN UNTERNEHMEN IN BAYERN',

View File

@@ -16,6 +16,7 @@ const FB = '"Inter", sans-serif'
const NAVY = '#111D55' const NAVY = '#111D55'
const GOLD = '#EFBF04' const GOLD = '#EFBF04'
const CREAM = '#E4E2E3' const CREAM = '#E4E2E3'
const HIDE_MEMBERSHIP_ABOUT_CTA = true
type AboutCms = Partial<typeof aboutContent> & { type AboutCms = Partial<typeof aboutContent> & {
hero?: Partial<typeof aboutContent.hero> & { backgroundImage?: unknown } hero?: Partial<typeof aboutContent.hero> & { backgroundImage?: unknown }
@@ -36,6 +37,12 @@ const fallbackText = (value: unknown, fallback: string) => (typeof value === 'st
const fallbackArray = <T,>(value: unknown, fallback: T[]) => (Array.isArray(value) && value.length ? (value as T[]) : fallback) const fallbackArray = <T,>(value: unknown, fallback: T[]) => (Array.isArray(value) && value.length ? (value as T[]) : fallback)
const isMembershipCta = (cta?: { label?: string | null; url?: string | null } | null) => {
const normalizedLabel = cta?.label?.trim().toLowerCase()
return cta?.url === '/mitglied-werden' || normalizedLabel === 'mitglied werden' || normalizedLabel === 'vereinsmitglied werden'
}
function Lines({ text }: { text: string }) { function Lines({ text }: { text: string }) {
return ( return (
<> <>
@@ -117,6 +124,7 @@ const About: React.FC = () => {
const goals = { ...aboutContent.goals, ...(cms.goals || {}) } const goals = { ...aboutContent.goals, ...(cms.goals || {}) }
const values = { ...aboutContent.values, ...(cms.values || {}) } const values = { ...aboutContent.values, ...(cms.values || {}) }
const cta = { ...aboutContent.cta, ...(cms.cta || {}) } const cta = { ...aboutContent.cta, ...(cms.cta || {}) }
const showSecondaryCta = !HIDE_MEMBERSHIP_ABOUT_CTA || !isMembershipCta(cta.secondaryCta)
const highlightFallbackImage = mediaUrl( const highlightFallbackImage = mediaUrl(
highlights.fallbackImage, highlights.fallbackImage,
`/images/${aboutContent.highlights.fallbackImageFilename}`, `/images/${aboutContent.highlights.fallbackImageFilename}`,
@@ -359,7 +367,7 @@ const About: React.FC = () => {
</div> </div>
</div> </div>
<div style={{ background: '#EFE5E3', padding: isMobile ? '56px 24px' : '88px 80px', display: 'flex', flexDirection: 'column', justifyContent: 'center' }}> <div style={{ background: '#fff', padding: isMobile ? '56px 24px' : '88px 80px', display: 'flex', flexDirection: 'column', justifyContent: 'center' }}>
<span style={{ fontFamily: FF, fontSize: 10, color: '#4A8FC9', textTransform: 'uppercase', letterSpacing: '0.32em', fontWeight: 700, display: 'block', marginBottom: 16 }}>{fallbackText(goals.eyebrow, aboutContent.goals.eyebrow)}</span> <span style={{ fontFamily: FF, fontSize: 10, color: '#4A8FC9', textTransform: 'uppercase', letterSpacing: '0.32em', fontWeight: 700, display: 'block', marginBottom: 16 }}>{fallbackText(goals.eyebrow, aboutContent.goals.eyebrow)}</span>
<h2 style={{ fontFamily: FF, fontSize: 'clamp(2rem, 3vw, 2.8rem)', fontWeight: 900, color: NAVY, textTransform: 'uppercase', letterSpacing: '-0.03em', lineHeight: 1.0, margin: '0 0 40px' }}> <h2 style={{ fontFamily: FF, fontSize: 'clamp(2rem, 3vw, 2.8rem)', fontWeight: 900, color: NAVY, textTransform: 'uppercase', letterSpacing: '-0.03em', lineHeight: 1.0, margin: '0 0 40px' }}>
<Lines text={fallbackText(goals.heading, aboutContent.goals.heading)} /> <Lines text={fallbackText(goals.heading, aboutContent.goals.heading)} />
@@ -423,17 +431,19 @@ const About: React.FC = () => {
> >
{fallbackText(cta.primaryCta?.label, aboutContent.cta.primaryCta.label)} <ChevronRight size={16} /> {fallbackText(cta.primaryCta?.label, aboutContent.cta.primaryCta.label)} <ChevronRight size={16} />
</Link> </Link>
<div style={{ marginTop: 16, display: 'flex', justifyContent: 'center' }}> {showSecondaryCta && (
<span style={{ fontFamily: FF, fontSize: 11, color: 'rgba(255,255,255,0.3)', marginRight: 8 }}>{fallbackText(cta.secondaryPrefix, aboutContent.cta.secondaryPrefix)}</span> <div style={{ marginTop: 16, display: 'flex', justifyContent: 'center' }}>
<Link <span style={{ fontFamily: FF, fontSize: 11, color: 'rgba(255,255,255,0.3)', marginRight: 8 }}>{fallbackText(cta.secondaryPrefix, aboutContent.cta.secondaryPrefix)}</span>
to={fallbackText(cta.secondaryCta?.url, aboutContent.cta.secondaryCta.url)} <Link
style={{ fontFamily: FF, fontSize: 11, fontWeight: 700, letterSpacing: '0.12em', textTransform: 'uppercase', color: 'rgba(239,191,4,0.7)', textDecoration: 'none', display: 'inline-flex', alignItems: 'center', gap: 6, borderBottom: '1px solid rgba(239,191,4,0.3)', paddingBottom: 1, transition: 'color 0.15s, borderColor 0.15s' }} to={fallbackText(cta.secondaryCta?.url, aboutContent.cta.secondaryCta.url)}
onMouseEnter={(e) => { e.currentTarget.style.color = '#EFBF04'; e.currentTarget.style.borderBottomColor = '#EFBF04' }} style={{ fontFamily: FF, fontSize: 11, fontWeight: 700, letterSpacing: '0.12em', textTransform: 'uppercase', color: 'rgba(239,191,4,0.7)', textDecoration: 'none', display: 'inline-flex', alignItems: 'center', gap: 6, borderBottom: '1px solid rgba(239,191,4,0.3)', paddingBottom: 1, transition: 'color 0.15s, borderColor 0.15s' }}
onMouseLeave={(e) => { e.currentTarget.style.color = 'rgba(239,191,4,0.7)'; e.currentTarget.style.borderBottomColor = 'rgba(239,191,4,0.3)' }} onMouseEnter={(e) => { e.currentTarget.style.color = '#EFBF04'; e.currentTarget.style.borderBottomColor = '#EFBF04' }}
> onMouseLeave={(e) => { e.currentTarget.style.color = 'rgba(239,191,4,0.7)'; e.currentTarget.style.borderBottomColor = 'rgba(239,191,4,0.3)' }}
{fallbackText(cta.secondaryCta?.label, aboutContent.cta.secondaryCta.label)} <ChevronRight size={10} /> >
</Link> {fallbackText(cta.secondaryCta?.label, aboutContent.cta.secondaryCta.label)} <ChevronRight size={10} />
</div> </Link>
</div>
)}
</div> </div>
</section> </section>
</div> </div>

View File

@@ -11,7 +11,7 @@ import Image from '@/spa/components/ui/UnoptimizedImage'
const NAVY = '#111D55'; const NAVY = '#111D55';
const GOLD = '#EFBF04'; const GOLD = '#EFBF04';
const CREAM = '#EFE5E3'; const PAGE_BG = '#fff';
const INK = '#3A3A3A'; const INK = '#3A3A3A';
const FF = '"IBM Plex Sans", sans-serif'; const FF = '"IBM Plex Sans", sans-serif';
const FB = '"Inter", sans-serif'; const FB = '"Inter", sans-serif';
@@ -81,10 +81,21 @@ export default function BlogDetail() {
const metaCopy = detail.metaCopy || blogDetailContent.meta; const metaCopy = detail.metaCopy || blogDetailContent.meta;
const sidebarCta = detail.sidebarCta || blogDetailContent.sidebarCta; const sidebarCta = detail.sidebarCta || blogDetailContent.sidebarCta;
const relatedCopy = detail.related || blogDetailContent.related; const relatedCopy = detail.related || blogDetailContent.related;
const sidebarCtaUrl = fallbackText(sidebarCta.url, blogDetailContent.sidebarCta.url);
const sidebarCtaIsExternal = /^(mailto:|tel:|https?:)/.test(sidebarCtaUrl);
const sidebarCtaStyle: React.CSSProperties = {
display: 'flex', alignItems: 'center', justifyContent: 'center', gap: 8,
padding: '13px 20px',
background: NAVY, color: '#fff',
fontFamily: FF, fontSize: 14, fontWeight: 700,
textTransform: 'uppercase', letterSpacing: '0.1em',
textDecoration: 'none',
transition: 'background 0.15s',
};
if (!article) { if (!article) {
return ( return (
<div style={{ minHeight: '60vh', display: 'flex', flexDirection: 'column', alignItems: 'center', justifyContent: 'center', gap: 24, padding: '80px 24px', background: CREAM }}> <div style={{ minHeight: '60vh', display: 'flex', flexDirection: 'column', alignItems: 'center', justifyContent: 'center', gap: 24, padding: '80px 24px', background: PAGE_BG }}>
<h2 style={{ fontFamily: FF, fontSize: '2rem', fontWeight: 900, color: NAVY }}>{fallbackText(notFound.title, blogDetailContent.notFound.title)}</h2> <h2 style={{ fontFamily: FF, fontSize: '2rem', fontWeight: 900, color: NAVY }}>{fallbackText(notFound.title, blogDetailContent.notFound.title)}</h2>
<Link to={fallbackText(notFound.backUrl, blogDetailContent.notFound.backUrl)} style={{ fontFamily: FF, fontSize: 16, fontWeight: 700, color: NAVY, textDecoration: 'none', display: 'flex', alignItems: 'center', gap: 6 }}> <Link to={fallbackText(notFound.backUrl, blogDetailContent.notFound.backUrl)} style={{ fontFamily: FF, fontSize: 16, fontWeight: 700, color: NAVY, textDecoration: 'none', display: 'flex', alignItems: 'center', gap: 6 }}>
<ArrowLeft size={14} /> {fallbackText(notFound.backLabel, blogDetailContent.notFound.backLabel)} <ArrowLeft size={14} /> {fallbackText(notFound.backLabel, blogDetailContent.notFound.backLabel)}
@@ -177,7 +188,7 @@ export default function BlogDetail() {
</section> </section>
{/* ── ARTICLE BODY ─────────────────────────────────────────────────── */} {/* ── ARTICLE BODY ─────────────────────────────────────────────────── */}
<section style={{ background: CREAM, padding: isMobile ? '48px 0 0' : '80px 0 0' }}> <section style={{ background: PAGE_BG, padding: isMobile ? '48px 0 0' : '80px 0 0' }}>
<div style={{ maxWidth: 1100, margin: '0 auto', padding: isMobile ? '0 24px' : '0 80px', display: 'grid', gridTemplateColumns: isMobile ? '1fr' : '1fr 320px', gap: isMobile ? 40 : 80 }}> <div style={{ maxWidth: 1100, margin: '0 auto', padding: isMobile ? '0 24px' : '0 80px', display: 'grid', gridTemplateColumns: isMobile ? '1fr' : '1fr 320px', gap: isMobile ? 40 : 80 }}>
{/* LEFT – article text */} {/* LEFT – article text */}
@@ -263,22 +274,25 @@ export default function BlogDetail() {
<p style={{ fontFamily: FB, fontSize: 16, color: 'rgba(58,58,58,0.55)', lineHeight: 1.7, marginBottom: 20 }}> <p style={{ fontFamily: FB, fontSize: 16, color: 'rgba(58,58,58,0.55)', lineHeight: 1.7, marginBottom: 20 }}>
{fallbackText(sidebarCta.text, blogDetailContent.sidebarCta.text)} {fallbackText(sidebarCta.text, blogDetailContent.sidebarCta.text)}
</p> </p>
<Link {sidebarCtaIsExternal ? (
to={fallbackText(sidebarCta.url, blogDetailContent.sidebarCta.url)} <a
style={{ href={sidebarCtaUrl}
display: 'flex', alignItems: 'center', justifyContent: 'center', gap: 8, style={sidebarCtaStyle}
padding: '13px 20px', onMouseEnter={e => (e.currentTarget.style.background = GOLD)}
background: NAVY, color: '#fff', onMouseLeave={e => (e.currentTarget.style.background = NAVY)}
fontFamily: FF, fontSize: 14, fontWeight: 700, >
textTransform: 'uppercase', letterSpacing: '0.1em', {fallbackText(sidebarCta.label, blogDetailContent.sidebarCta.label)} <ArrowRight size={12} />
textDecoration: 'none', </a>
transition: 'background 0.15s', ) : (
}} <Link
onMouseEnter={e => (e.currentTarget.style.background = GOLD)} to={sidebarCtaUrl}
onMouseLeave={e => (e.currentTarget.style.background = NAVY)} style={sidebarCtaStyle}
> onMouseEnter={e => (e.currentTarget.style.background = GOLD)}
{fallbackText(sidebarCta.label, blogDetailContent.sidebarCta.label)} <ArrowRight size={12} /> onMouseLeave={e => (e.currentTarget.style.background = NAVY)}
</Link> >
{fallbackText(sidebarCta.label, blogDetailContent.sidebarCta.label)} <ArrowRight size={12} />
</Link>
)}
</div> </div>
</div> </div>
</aside> </aside>

View File

@@ -1,4 +1,4 @@
import React, { useState } from 'react'; import React, { useEffect, useRef, useState } from 'react';
import { Button } from '@/spa/components/ui/button'; import { Button } from '@/spa/components/ui/button';
import { Play, ChevronRight, X, Star } from 'lucide-react'; import { Play, ChevronRight, X, Star } from 'lucide-react';
import { Link } from '@/spa/router'; import { Link } from '@/spa/router';
@@ -9,17 +9,86 @@ import TestimonialsSection from '@/spa/components/TestimonialsSection';
import MunichSkylineBg from '@/spa/components/ui/munich-skyline-bg'; import MunichSkylineBg from '@/spa/components/ui/munich-skyline-bg';
import { useIsMobile } from '@/spa/hooks/useIsMobile'; import { useIsMobile } from '@/spa/hooks/useIsMobile';
import Image from '@/spa/components/ui/UnoptimizedImage' import Image from '@/spa/components/ui/UnoptimizedImage'
import { useCmsCollection, useCmsRoute } from '@/spa/cmsRoute' import type { SpaApplicationPhaseData, SpaApplicationPhaseItem } from '@/spa/applicationPhase'
import { useApplicationPhase, useCmsCollection, useCmsRoute } from '@/spa/cmsRoute'
import { mediaAlt, mediaUrl } from '@/spa/cmsMediaField' import { mediaAlt, mediaUrl } from '@/spa/cmsMediaField'
import { homeContent } from '@/spa/homeContent' import { homeContent } from '@/spa/homeContent'
const FF = '"IBM Plex Sans", sans-serif'; const FF = '"IBM Plex Sans", sans-serif';
type CmsRecord = Record<string, unknown>; type CmsRecord = Record<string, unknown>;
type HomeCms = Record<string, any>; type CmsLink = { label?: string | null; to?: string | null; url?: string | null };
type StatusPhase = HomeCms & { type HomeHeroCms = {
cta?: { label?: string; to?: string; url?: string } | null; backgroundImage?: unknown;
membershipCta?: { label?: string; to?: string; url?: string } | null; badge?: string | null;
description?: string | null;
headingAccent?: string | null;
headingLine1?: string | null;
imageAlt?: string | null;
partners?: unknown;
partnersLabel?: string | null;
primaryCta?: CmsLink | null;
secondaryCta?: CmsLink | null;
videoLabel?: string | null;
};
type HomeSectionCms = CmsRecord & {
awardCaption?: string | null;
awardImage?: unknown;
awardImageAlt?: string | null;
benefits?: unknown;
body?: string | null;
cardFallbackTitle?: string | null;
criteria?: unknown;
cta?: CmsLink | null;
eyebrow?: string | null;
fallbackImage?: unknown;
featured?: unknown;
footnote?: string | null;
footnoteStrong?: string | null;
formEyebrow?: string | null;
formTitle?: string | null;
heading?: string | null;
hoverLabel?: string | null;
image?: unknown;
imageAlt?: string | null;
imageLabel?: string | null;
items?: unknown;
note?: string | null;
quote?: string | null;
stats?: unknown;
};
type HomeVideoModalCms = {
closeLabel?: string | null;
description?: string | null;
title?: string | null;
video?: unknown;
};
type HomeTestimonialsCms = {
desktopHeading?: string | null;
eyebrow?: string | null;
heading?: string | null;
items?: Array<Record<string, unknown>> | null;
nextLabel?: string | null;
previousLabel?: string | null;
slideLabelPrefix?: string | null;
yearPrefix?: string | null;
};
type HomeCms = {
application?: HomeSectionCms;
benefits?: HomeSectionCms;
form?: Partial<typeof homeContent.form>;
hero?: HomeHeroCms;
intro?: HomeSectionCms;
quickCheck?: HomeSectionCms;
testimonials?: HomeTestimonialsCms;
videoModal?: HomeVideoModalCms;
winners?: HomeSectionCms;
};
type RenderedStatusPhase = Omit<SpaApplicationPhaseItem, 'accent' | 'bg' | 'cta' | 'membershipCta'> & {
accent: string;
bg: string;
cta?: { label: string; to: string } | null;
membershipCta?: { label: string; to: string } | null;
}; };
type WinnerCardData = CmsRecord & { type WinnerCardData = CmsRecord & {
id?: string | number; id?: string | number;
@@ -41,6 +110,13 @@ const fallbackText = (value: unknown, fallback: string) =>
const fallbackArray = <T,>(value: unknown, fallback: readonly T[]) => const fallbackArray = <T,>(value: unknown, fallback: readonly T[]) =>
Array.isArray(value) && value.length > 0 ? (value as T[]) : fallback; Array.isArray(value) && value.length > 0 ? (value as T[]) : fallback;
const HIDE_MEMBERSHIP_HOME_HERO_CTA = true;
const isMembershipCta = (cta?: { label?: string; url?: string; to?: string } | null) =>
cta?.url === '/mitglied-werden' ||
cta?.to === '/mitglied-werden' ||
cta?.label?.trim().toLowerCase() === 'mitglied werden';
function Lines({ text }: { text: string }) { 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('\n').map((line, i) => <React.Fragment key={`${line}-${i}`}>{i > 0 && <br />}{line}</React.Fragment>)}</>;
} }
@@ -108,6 +184,7 @@ function HomeWinnerCard({
const Home: React.FC = () => { const Home: React.FC = () => {
const [showVideo, setShowVideo] = useState(false); const [showVideo, setShowVideo] = useState(false);
const videoRef = useRef<HTMLVideoElement>(null);
const isMobile = useIsMobile(); const isMobile = useIsMobile();
const home = (useCmsRoute()?.doc?.home || {}) as HomeCms; const home = (useCmsRoute()?.doc?.home || {}) as HomeCms;
const hero = home.hero || {}; const hero = home.hero || {};
@@ -118,8 +195,14 @@ const Home: React.FC = () => {
const application = home.application || {}; const application = home.application || {};
const form = home.form || {}; const form = home.form || {};
const videoModal = home.videoModal || {}; const videoModal = home.videoModal || {};
const applicationPhase = useApplicationPhase();
const cmsPreistraeger = useCmsCollection('preistraeger'); const cmsPreistraeger = useCmsCollection('preistraeger');
const selectedWinners = fallbackArray(winnersSection.featured, []); const selectedWinners = fallbackArray(winnersSection.featured, []);
const heroSecondaryCta = {
label: fallbackText(hero.secondaryCta?.label, homeContent.hero.secondaryCta.label),
url: fallbackText(hero.secondaryCta?.url, homeContent.hero.secondaryCta.url),
};
const showHeroSecondaryCta = !HIDE_MEMBERSHIP_HOME_HERO_CTA || !isMembershipCta(heroSecondaryCta);
const selectedWinnerDocs = selectedWinners const selectedWinnerDocs = selectedWinners
.map((selected) => { .map((selected) => {
const selectedRecord = selected as { id?: string | number } const selectedRecord = selected as { id?: string | number }
@@ -136,6 +219,16 @@ const Home: React.FC = () => {
const benefitItems = fallbackArray(benefits.items, homeContent.benefits.items); const benefitItems = fallbackArray(benefits.items, homeContent.benefits.items);
const applicationBenefits = fallbackArray(application.benefits, homeContent.application.benefits); const applicationBenefits = fallbackArray(application.benefits, homeContent.application.benefits);
const winnerFallbackImage = mediaUrl(winnersSection.fallbackImage, `/images/${homeContent.winners.fallbackImageFilename}`); const winnerFallbackImage = mediaUrl(winnersSection.fallbackImage, `/images/${homeContent.winners.fallbackImageFilename}`);
const videoTitle = fallbackText(videoModal.title, homeContent.videoModal.title);
const videoSrc = mediaUrl(videoModal.video, '');
useEffect(() => {
if (!showVideo || !videoSrc) return;
videoRef.current?.play().catch(() => {
// Browser autoplay rules can still require the native control click.
});
}, [showVideo, videoSrc]);
return ( return (
<div className="animate-fade-in"> <div className="animate-fade-in">
@@ -177,24 +270,27 @@ const Home: React.FC = () => {
{fallbackText(hero.primaryCta?.label, homeContent.hero.primaryCta.label)} <ChevronRight size={16} /> {fallbackText(hero.primaryCta?.label, homeContent.hero.primaryCta.label)} <ChevronRight size={16} />
</Link> </Link>
<div style={{ display: 'flex', flexDirection: isMobile ? 'column' : 'row', gap: 16, alignItems: isMobile ? 'stretch' : 'center' }}> <div style={{ display: 'flex', flexDirection: isMobile ? 'column' : 'row', gap: 16, alignItems: isMobile ? 'stretch' : 'center' }}>
<Link {showHeroSecondaryCta && (
to={fallbackText(hero.secondaryCta?.url, homeContent.hero.secondaryCta.url)} <Link
style={{ fontFamily:'"IBM Plex Sans", sans-serif', fontSize: 16, fontWeight:700, textTransform:'uppercase', letterSpacing:'0.12em', color:'rgba(255,255,255,0.85)', border:'1.5px solid rgba(255,255,255,0.35)', padding:'14px 32px', textDecoration:'none', display:'inline-flex', alignItems:'center', justifyContent: isMobile ? 'center' : 'flex-start', gap:10, transition:'border-color 0.15s, color 0.15s', background:'transparent', width: isMobile ? '100%' : 'auto' }} to={heroSecondaryCta.url}
onMouseEnter={e => { (e.currentTarget as HTMLElement).style.borderColor = '#EFBF04'; (e.currentTarget as HTMLElement).style.color = '#EFBF04'; }} style={{ fontFamily:'"IBM Plex Sans", sans-serif', fontSize: 16, fontWeight:700, textTransform:'uppercase', letterSpacing:'0.12em', color:'rgba(255,255,255,0.85)', border:'1.5px solid rgba(255,255,255,0.35)', padding:'14px 32px', textDecoration:'none', display:'inline-flex', alignItems:'center', justifyContent: isMobile ? 'center' : 'flex-start', gap:10, transition:'border-color 0.15s, color 0.15s', background:'transparent', width: isMobile ? '100%' : 'auto' }}
onMouseLeave={e => { (e.currentTarget as HTMLElement).style.borderColor = 'rgba(255,255,255,0.35)'; (e.currentTarget as HTMLElement).style.color = 'rgba(255,255,255,0.85)'; }} onMouseEnter={e => { (e.currentTarget as HTMLElement).style.borderColor = '#EFBF04'; (e.currentTarget as HTMLElement).style.color = '#EFBF04'; }}
> onMouseLeave={e => { (e.currentTarget as HTMLElement).style.borderColor = 'rgba(255,255,255,0.35)'; (e.currentTarget as HTMLElement).style.color = 'rgba(255,255,255,0.85)'; }}
{fallbackText(hero.secondaryCta?.label, homeContent.hero.secondaryCta.label)} >
</Link> {heroSecondaryCta.label}
<button </Link>
onClick={() => setShowVideo(true)} )}
className="flex items-center gap-4 text-white hover:text-accent transition-colors group" <button
style={{ justifyContent: isMobile ? 'center' : 'flex-start' }} type="button"
> onClick={() => setShowVideo(true)}
<div className="w-12 h-12 rounded-full border border-white/30 flex items-center justify-center group-hover:border-accent group-hover:bg-accent/10 transition-all"> className="flex items-center gap-4 text-white hover:text-accent transition-colors group"
<Play size={20} fill="currentColor" /> style={{ justifyContent: isMobile ? 'center' : 'flex-start' }}
</div> >
<span className="uppercase text-xs tracking-widest font-bold">{fallbackText(hero.videoLabel, homeContent.hero.videoLabel)}</span> <div className="w-12 h-12 rounded-full border border-white/30 flex items-center justify-center group-hover:border-accent group-hover:bg-accent/10 transition-all">
</button> <Play size={20} fill="currentColor" />
</div>
<span className="uppercase text-xs tracking-widest font-bold">{fallbackText(hero.videoLabel, homeContent.hero.videoLabel)}</span>
</button>
</div> </div>
</div> </div>
</div> </div>
@@ -221,7 +317,7 @@ const Home: React.FC = () => {
<MunichSkylineBg /> <MunichSkylineBg />
<div style={{ display: 'grid', gridTemplateColumns: isMobile ? '1fr' : '55% 45%', position: 'relative' }}> <div style={{ display: 'grid', gridTemplateColumns: isMobile ? '1fr' : '55% 45%', position: 'relative' }}>
{/* Left (desktop) / Bottom (mobile) – text block (light bg, dark text) */} {/* Left (desktop) / Bottom (mobile) – text block (light bg, dark text) */}
<div style={{ background: '#E4E2E3', padding: isMobile ? '32px 24px 44px' : '80px 72px', display: 'flex', flexDirection: 'column', justifyContent: 'center', order: isMobile ? 2 : 0 }}> <div style={{ background: '#fff', padding: isMobile ? '32px 24px 44px' : '80px 72px', display: 'flex', flexDirection: 'column', justifyContent: 'center', order: isMobile ? 2 : 0 }}>
{/* Eyebrow + heading – desktop only (on mobile these are overlaid on the image) */} {/* Eyebrow + heading – desktop only (on mobile these are overlaid on the image) */}
{!isMobile && ( {!isMobile && (
<> <>
@@ -272,7 +368,7 @@ const Home: React.FC = () => {
{/* Overlay: mobile = navy shadow bottom-left (for the heading); desktop = leftward fade into text bg */} {/* Overlay: mobile = navy shadow bottom-left (for the heading); desktop = leftward fade into text bg */}
<div style={{ position: 'absolute', inset: 0, background: isMobile <div style={{ position: 'absolute', inset: 0, background: isMobile
? 'linear-gradient(to top, rgba(3,9,58,0.92) 0%, rgba(3,9,58,0.55) 34%, rgba(3,9,58,0.12) 68%, transparent 100%), linear-gradient(to right, rgba(3,9,58,0.55) 0%, transparent 55%)' ? 'linear-gradient(to top, rgba(3,9,58,0.92) 0%, rgba(3,9,58,0.55) 34%, rgba(3,9,58,0.12) 68%, transparent 100%), linear-gradient(to right, rgba(3,9,58,0.55) 0%, transparent 55%)'
: 'linear-gradient(to left, transparent 40%, #E4E2E3 100%)' }} /> : 'linear-gradient(to left, transparent 40%, #fff 100%)' }} />
{/* Heading overlay – mobile only */} {/* Heading overlay – mobile only */}
{isMobile && ( {isMobile && (
<div style={{ position: 'absolute', left: 0, right: 0, bottom: 0, padding: '0 24px 26px', zIndex: 2 }}> <div style={{ position: 'absolute', left: 0, right: 0, bottom: 0, padding: '0 24px 26px', zIndex: 2 }}>
@@ -366,7 +462,7 @@ const Home: React.FC = () => {
</section> </section>
{/* Status Phase Slider */} {/* Status Phase Slider */}
<StatusSlider data={home.status} /> <StatusSlider data={applicationPhase} />
{/* Winners Grid – neue Preisträger-Übersicht */} {/* Winners Grid – neue Preisträger-Übersicht */}
<section style={{ background: '#111D55' }}> <section style={{ background: '#111D55' }}>
@@ -419,8 +515,8 @@ const Home: React.FC = () => {
<MunichSkylineBg /> <MunichSkylineBg />
<div style={{ display: 'grid', gridTemplateColumns: isMobile ? '1fr' : '55% 45%', minHeight: isMobile ? 'auto' : 560 }}> <div style={{ display: 'grid', gridTemplateColumns: isMobile ? '1fr' : '55% 45%', minHeight: isMobile ? 'auto' : 560 }}>
{/* Left – text on cream */} {/* Left – text on white */}
<div style={{ background: '#E4E2E3', padding: isMobile ? '32px 24px 44px' : '80px 72px', display: 'flex', flexDirection: 'column', justifyContent: 'center', order: isMobile ? 2 : 0 }}> <div style={{ background: '#fff', padding: isMobile ? '32px 24px 44px' : '80px 72px', display: 'flex', flexDirection: 'column', justifyContent: 'center', order: isMobile ? 2 : 0 }}>
{!isMobile && ( {!isMobile && (
<> <>
<span style={{ fontFamily: FF, fontSize: 10, color: '#4A8FC9', textTransform: 'uppercase', letterSpacing: '0.3em', fontWeight: 700, display: 'block', marginBottom: 16 }}>{fallbackText(benefits.eyebrow, homeContent.benefits.eyebrow)}</span> <span style={{ fontFamily: FF, fontSize: 10, color: '#4A8FC9', textTransform: 'uppercase', letterSpacing: '0.3em', fontWeight: 700, display: 'block', marginBottom: 16 }}>{fallbackText(benefits.eyebrow, homeContent.benefits.eyebrow)}</span>
@@ -436,7 +532,7 @@ const Home: React.FC = () => {
<div <div
key={i} key={i}
style={{ display: 'grid', gridTemplateColumns: '48px 1fr', gap: '0 20px', padding: '24px 0', borderBottom: '1px solid #D0D5DD', cursor: 'default', transition: 'background 0.15s' }} style={{ display: 'grid', gridTemplateColumns: '48px 1fr', gap: '0 20px', padding: '24px 0', borderBottom: '1px solid #D0D5DD', cursor: 'default', transition: 'background 0.15s' }}
onMouseEnter={e => (e.currentTarget.style.background = '#EAE7E6')} onMouseEnter={e => (e.currentTarget.style.background = 'rgba(17,29,85,0.04)')}
onMouseLeave={e => (e.currentTarget.style.background = 'transparent')} onMouseLeave={e => (e.currentTarget.style.background = 'transparent')}
> >
<span style={{ fontFamily: FF, fontSize: 11, fontWeight: 700, color: '#EFBF04', letterSpacing: '0.1em', paddingTop: 2 }}>{item.num}</span> <span style={{ fontFamily: FF, fontSize: 11, fontWeight: 700, color: '#EFBF04', letterSpacing: '0.1em', paddingTop: 2 }}>{item.num}</span>
@@ -458,7 +554,7 @@ const Home: React.FC = () => {
/> />
<div style={{ position: 'absolute', inset: 0, background: isMobile <div style={{ position: 'absolute', inset: 0, background: isMobile
? 'linear-gradient(to top, rgba(3,9,58,0.92) 0%, rgba(3,9,58,0.5) 36%, rgba(3,9,58,0.1) 70%, transparent 100%), linear-gradient(to right, rgba(3,9,58,0.5) 0%, transparent 55%)' ? 'linear-gradient(to top, rgba(3,9,58,0.92) 0%, rgba(3,9,58,0.5) 36%, rgba(3,9,58,0.1) 70%, transparent 100%), linear-gradient(to right, rgba(3,9,58,0.5) 0%, transparent 55%)'
: 'linear-gradient(to right, #E4E2E3 0%, transparent 30%), linear-gradient(to top, rgba(3,9,58,0.82) 0%, transparent 50%)' }} /> : 'linear-gradient(to right, #fff 0%, transparent 30%), linear-gradient(to top, rgba(3,9,58,0.82) 0%, transparent 50%)' }} />
{/* Heading overlay – mobile only (bottom-left) */} {/* Heading overlay – mobile only (bottom-left) */}
{isMobile && ( {isMobile && (
<div style={{ position: 'absolute', left: 0, right: 0, bottom: 0, padding: '0 24px 26px', zIndex: 2 }}> <div style={{ position: 'absolute', left: 0, right: 0, bottom: 0, padding: '0 24px 26px', zIndex: 2 }}>
@@ -480,9 +576,9 @@ const Home: React.FC = () => {
</section> </section>
{/* CTA Section – with embedded form */} {/* CTA Section – with embedded form */}
<section style={{ background: '#111D55', position: 'relative', overflow: 'hidden', height: isMobile ? 'auto' : 'calc(100vh - 60px)', display: 'flex', flexDirection: 'column' }} id="bewerben"> <section style={{ background: '#24366A', position: 'relative', overflow: 'hidden', height: isMobile ? 'auto' : 'calc(100vh - 60px)', display: 'flex', flexDirection: 'column' }} id="bewerben">
{/* Subtle radial glow behind left copy */} {/* Subtle radial glow behind left copy */}
<div style={{ position: 'absolute', left: -120, top: '50%', transform: 'translateY(-50%)', width: 600, height: 600, borderRadius: '50%', background: 'radial-gradient(circle, rgba(5,26,135,0.35) 0%, transparent 70%)', pointerEvents: 'none' }} /> <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 style={{ display: 'grid', gridTemplateColumns: isMobile ? '1fr' : '4fr 1px 8fr', position: 'relative', flex: isMobile ? 'none' : 1, minHeight: 0, overflow: 'hidden' }}>
@@ -570,18 +666,32 @@ const Home: React.FC = () => {
{showVideo && ( {showVideo && (
<div className="fixed inset-0 z-[200] bg-black flex items-center justify-center p-6 bg-opacity-95"> <div className="fixed inset-0 z-[200] bg-black flex items-center justify-center p-6 bg-opacity-95">
<button <button
type="button"
onClick={() => setShowVideo(false)} onClick={() => setShowVideo(false)}
className="absolute top-8 right-8 text-white/60 hover:text-white transition-colors" className="absolute top-8 right-8 text-white/60 hover:text-white transition-colors"
aria-label={fallbackText(videoModal.closeLabel, homeContent.videoModal.closeLabel)}
> >
<X size={40} /> <X size={40} />
</button> </button>
<div className="w-full max-w-6xl aspect-video bg-white/5 rounded-3xl overflow-hidden flex items-center justify-center relative"> <div className="w-full max-w-6xl aspect-video bg-white/5 rounded-3xl overflow-hidden flex items-center justify-center relative">
<div className="text-center"> {videoSrc ? (
<Play size={80} className="text-accent mx-auto mb-6 opacity-40" /> <video
<p className="text-white/40 uppercase tracking-widest text-sm">{fallbackText(videoModal.title, homeContent.videoModal.title)}</p> ref={videoRef}
<p className="text-white/60 mt-4 text-xs italic font-body">{fallbackText(videoModal.description, homeContent.videoModal.description)}</p> src={videoSrc}
<Button onClick={() => setShowVideo(false)} className="mt-8" variant="outline">{fallbackText(videoModal.closeLabel, homeContent.videoModal.closeLabel)}</Button> className="h-full w-full bg-black object-contain"
</div> controls
autoPlay
playsInline
aria-label={videoTitle}
/>
) : (
<div className="text-center">
<Play size={80} className="text-accent mx-auto mb-6 opacity-40" />
<p className="text-white/40 uppercase tracking-widest text-sm">{videoTitle}</p>
<p className="text-white/60 mt-4 text-xs italic font-body">{fallbackText(videoModal.description, homeContent.videoModal.description)}</p>
<Button type="button" onClick={() => setShowVideo(false)} className="mt-8" variant="outline">{fallbackText(videoModal.closeLabel, homeContent.videoModal.closeLabel)}</Button>
</div>
)}
</div> </div>
</div> </div>
)} )}
@@ -591,26 +701,28 @@ const Home: React.FC = () => {
// ─── StatusSlider ───────────────────────────────────────────────────────────── // ─── StatusSlider ─────────────────────────────────────────────────────────────
function StatusSlider({ data }: { data?: HomeCms }) { function StatusSlider({ data }: { data?: SpaApplicationPhaseData }) {
const isMobile = useIsMobile(); const isMobile = useIsMobile();
const phases = fallbackArray(data?.phases, homeContent.status.phases).map((phase, i) => { if (!data?.phases?.length) return null;
const phaseRecord = phase as StatusPhase
const fallbackPhase = homeContent.status.phases[i] const phases = data.phases.map((phase): RenderedStatusPhase => {
const fallbackCta = fallbackPhase?.cta?.label const cta = phase.cta?.label
? { label: fallbackPhase.cta.label, to: fallbackPhase.cta.url } ? { label: phase.cta.label, to: phase.cta.to || phase.cta.url || '/' }
: null : null
const cta = phaseRecord?.cta?.label const membershipCta = phase.membershipCta?.label
? { label: phaseRecord.cta.label, to: phaseRecord.cta.to || phaseRecord.cta.url || fallbackPhase?.cta?.url || '/' } ? { label: phase.membershipCta.label, to: phase.membershipCta.to || phase.membershipCta.url || '/mitglied-werden' }
: fallbackCta
const membershipCta = phaseRecord?.membershipCta?.label
? { label: phaseRecord.membershipCta.label, to: phaseRecord.membershipCta.to || phaseRecord.membershipCta.url || homeContent.hero.secondaryCta.url }
: undefined : undefined
return { ...fallbackPhase, ...phaseRecord, cta, membershipCta } return {
...phase,
accent: phase.accent || '#EFBF04',
bg: phase.bg || '#020A1E',
cta,
membershipCta,
}
}); });
const fallbackActivePhase = Number(homeContent.status.activePhase); const configuredActive = Number(data.activePhase);
const configuredActive = data?.activePhase !== undefined && data?.activePhase !== null ? Number(data.activePhase) : fallbackActivePhase; const active = Math.max(0, Math.min(phases.length - 1, Number.isFinite(configuredActive) ? configuredActive : 0));
const active = Math.max(0, Math.min(phases.length - 1, Number.isFinite(configuredActive) ? configuredActive : fallbackActivePhase));
const phase = phases[active]; const phase = phases[active];
return ( return (
@@ -625,9 +737,9 @@ function StatusSlider({ data }: { data?: HomeCms }) {
<StatusSlideCard <StatusSlideCard
phase={phase} phase={phase}
isMobile={isMobile} isMobile={isMobile}
noActionLabel={fallbackText(data?.noActionLabel, homeContent.status.noActionLabel)} noActionLabel={data.noActionLabel}
successApplicationsLabel={fallbackText(data?.successApplicationsLabel, homeContent.status.successApplicationsLabel)} successApplicationsLabel={data.successApplicationsLabel}
successWinnersLabel={fallbackText(data?.successWinnersLabel, homeContent.status.successWinnersLabel)} successWinnersLabel={data.successWinnersLabel}
/> />
</section> </section>
); );
@@ -642,7 +754,7 @@ function StatusSlideCard({
successApplicationsLabel, successApplicationsLabel,
successWinnersLabel, successWinnersLabel,
}: { }: {
phase: StatusPhase & HomeCms; phase: RenderedStatusPhase;
isMobile: boolean; isMobile: boolean;
noActionLabel: string; noActionLabel: string;
successApplicationsLabel: string; successApplicationsLabel: string;
@@ -722,10 +834,10 @@ function StatusSlideCard({
) : ( ) : (
/* Success stats */ /* Success stats */
<div style={{ textAlign: 'center' }}> <div style={{ textAlign: 'center' }}>
<div style={{ fontFamily: FF, fontSize: 68, fontWeight: 900, color: phase.accent, lineHeight: 1, opacity: 0.9 }}>{fallbackText(phase.successApplications, homeContent.status.phases[2]?.successApplications || '')}</div> <div style={{ fontFamily: FF, fontSize: 68, fontWeight: 900, color: phase.accent, lineHeight: 1, opacity: 0.9 }}>{fallbackText(phase.successApplications, '')}</div>
<div style={{ fontFamily: FF, fontSize: 8, fontWeight: 700, letterSpacing: '0.35em', textTransform: 'uppercase', color: 'rgba(255,255,255,0.50)', marginTop: 4, marginBottom: 20 }}>{successApplicationsLabel}</div> <div style={{ fontFamily: FF, fontSize: 8, fontWeight: 700, letterSpacing: '0.35em', textTransform: 'uppercase', color: 'rgba(255,255,255,0.50)', marginTop: 4, marginBottom: 20 }}>{successApplicationsLabel}</div>
<div style={{ width: 1, height: 28, background: 'rgba(255,255,255,0.08)', margin: '0 auto 20px' }} /> <div style={{ width: 1, height: 28, background: 'rgba(255,255,255,0.08)', margin: '0 auto 20px' }} />
<div style={{ fontFamily: FF, fontSize: 44, fontWeight: 900, color: '#fff', lineHeight: 1 }}>{fallbackText(phase.successWinners, homeContent.status.phases[2]?.successWinners || '')}</div> <div style={{ fontFamily: FF, fontSize: 44, fontWeight: 900, color: '#fff', lineHeight: 1 }}>{fallbackText(phase.successWinners, '')}</div>
<div style={{ fontFamily: FF, fontSize: 8, fontWeight: 700, letterSpacing: '0.35em', textTransform: 'uppercase', color: 'rgba(255,255,255,0.50)', marginTop: 4 }}>{successWinnersLabel}</div> <div style={{ fontFamily: FF, fontSize: 8, fontWeight: 700, letterSpacing: '0.35em', textTransform: 'uppercase', color: 'rgba(255,255,255,0.50)', marginTop: 4 }}>{successWinnersLabel}</div>
</div> </div>
)} )}
@@ -735,7 +847,7 @@ function StatusSlideCard({
<div style={{ textAlign: 'center' }}> <div style={{ textAlign: 'center' }}>
<div style={{ width: 1, height: 20, background: 'rgba(255,255,255,0.08)', margin: '0 auto 20px' }} /> <div style={{ width: 1, height: 20, background: 'rgba(255,255,255,0.08)', margin: '0 auto 20px' }} />
<Link <Link
to={fallbackText(phase.membershipCta?.to || phase.membershipCta?.url, homeContent.hero.secondaryCta.url)} to={phase.membershipCta?.to || '/mitglied-werden'}
onMouseDown={e => e.stopPropagation()} onMouseDown={e => e.stopPropagation()}
style={{ style={{
fontFamily: FF, fontFamily: FF,
@@ -760,7 +872,7 @@ function StatusSlideCard({
(e.currentTarget as HTMLElement).style.borderColor = `${phase.accent}55`; (e.currentTarget as HTMLElement).style.borderColor = `${phase.accent}55`;
}} }}
> >
{fallbackText(phase.membershipCta?.label, homeContent.hero.secondaryCta.label)} <ChevronRight size={13} /> {phase.membershipCta?.label} <ChevronRight size={13} />
</Link> </Link>
</div> </div>
)} )}

View File

@@ -1,11 +1,12 @@
import React, { useState, useEffect } from 'react'; import React, { useState, useEffect, useRef } from 'react';
import { Link } from '@/spa/router'; import { Link } from '@/spa/router';
import { ChevronRight, X, ArrowRight, Globe, Linkedin, Building2, MapPin, ExternalLink } from 'lucide-react'; import { ChevronRight, X, ArrowRight, Globe, Linkedin, Building2, MapPin, ExternalLink, Play } from 'lucide-react';
import SponsoringForm from '@/spa/components/forms/SponsoringForm'; import SponsoringForm from '@/spa/components/forms/SponsoringForm';
import MunichSkylineBg from '@/spa/components/ui/munich-skyline-bg'; import MunichSkylineBg from '@/spa/components/ui/munich-skyline-bg';
import { useIsMobile } from '@/spa/hooks/useIsMobile'; import { useIsMobile } from '@/spa/hooks/useIsMobile';
import { useCmsRoute } from '@/spa/cmsRoute'; import { useCmsCollection, useCmsRoute } from '@/spa/cmsRoute';
import { mediaAlt, mediaUrl } from '@/spa/cmsMediaField'; import { mediaAlt, mediaUrl } from '@/spa/cmsMediaField';
import { homeContent } from '@/spa/homeContent';
import { netzwerkContent } from '@/spa/netzwerkContent'; import { netzwerkContent } from '@/spa/netzwerkContent';
import Image from '@/spa/components/ui/UnoptimizedImage' import Image from '@/spa/components/ui/UnoptimizedImage'
@@ -14,6 +15,7 @@ const FB = '"Inter", sans-serif';
const NAVY = '#111D55'; const NAVY = '#111D55';
const GOLD = '#EFBF04'; const GOLD = '#EFBF04';
const CREAM = '#E4E2E3'; const CREAM = '#E4E2E3';
const HIDE_MEMBERSHIP_NETWORK_CTA = true;
const ROLE_COLOR: Record<string, string> = { const ROLE_COLOR: Record<string, string> = {
'Hauptsponsor': '#EFBF04', 'Hauptsponsor': '#EFBF04',
@@ -28,6 +30,18 @@ type NetzwerkCms = Partial<typeof netzwerkContent> & {
sponsoring?: Partial<typeof netzwerkContent.sponsoring> & { image?: unknown } 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 PatronPerson = (typeof netzwerkContent.patronage.people)[number] & { imageUpload?: unknown }
type JuryMember = (typeof netzwerkContent.jury.members)[number] & { imageUpload?: unknown } type JuryMember = (typeof netzwerkContent.jury.members)[number] & { imageUpload?: unknown }
type Partner = (typeof netzwerkContent.partners.items)[number] & { stableId?: string; tier: 1 | 2 | 3; heroImg?: string; linkedin?: string; logoSubtext?: string } type Partner = (typeof netzwerkContent.partners.items)[number] & { stableId?: string; tier: 1 | 2 | 3; heroImg?: string; linkedin?: string; logoSubtext?: string }
@@ -40,6 +54,11 @@ type PartnerModalCopy = typeof netzwerkContent.partners.modal
const fallbackText = (value: unknown, fallback: string) => typeof value === 'string' && value.length > 0 ? value : fallback; 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 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 managedImage = (upload: unknown, fallback: unknown) => mediaUrl(upload, fallbackText(fallback, ''))
const isMembershipCta = (cta?: { label?: string | null; url?: string | null } | null) => {
const normalizedLabel = cta?.label?.trim().toLowerCase();
return cta?.url === '/mitglied-werden' || normalizedLabel === 'mitglied werden' || normalizedLabel === 'vereinsmitglied werden';
}
function Lines({ text, highlight }: { text: string; highlight?: string }) { function Lines({ text, highlight }: { text: string; highlight?: string }) {
return ( return (
@@ -443,7 +462,12 @@ function PartnerModal({ partner, copy, onClose }: { partner: Partner; copy: Part
const Netzwerk: React.FC = () => { const Netzwerk: React.FC = () => {
const isMobile = useIsMobile(); const isMobile = useIsMobile();
const [selectedPartner, setSelectedPartner] = useState<Partner | null>(null); 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 cms = (useCmsRoute()?.doc?.netzwerk || {}) as NetzwerkCms;
const homePage = useCmsCollection('pages').find((page) => page.spaPath === '/' || page.slug === 'startseite' || page.slug === 'home');
const home = (homePage?.home || {}) as HomeVideoCms;
const homeVideoModal = home.videoModal || {};
const hero = { ...netzwerkContent.hero, ...(cms.hero || {}) }; const hero = { ...netzwerkContent.hero, ...(cms.hero || {}) };
const patronage = { ...netzwerkContent.patronage, ...(cms.patronage || {}) }; const patronage = { ...netzwerkContent.patronage, ...(cms.patronage || {}) };
const juryIntro = { ...netzwerkContent.juryIntro, ...(cms.juryIntro || {}) }; const juryIntro = { ...netzwerkContent.juryIntro, ...(cms.juryIntro || {}) };
@@ -465,6 +489,21 @@ const Netzwerk: React.FC = () => {
const mobileStats = fallbackArray<StatItem>(juryIntro.stats, netzwerkContent.juryIntro.stats); const mobileStats = fallbackArray<StatItem>(juryIntro.stats, netzwerkContent.juryIntro.stats);
const desktopStats = fallbackArray<StatItem>(juryIntro.desktopStats, netzwerkContent.juryIntro.desktopStats); const desktopStats = fallbackArray<StatItem>(juryIntro.desktopStats, netzwerkContent.juryIntro.desktopStats);
const sponsorBenefits = fallbackArray<BenefitItem>(sponsoring.benefits, netzwerkContent.sponsoring.benefits); 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 showMembershipFootnote =
!HIDE_MEMBERSHIP_NETWORK_CTA ||
!isMembershipCta({ label: sponsoring.footnoteLabel, url: sponsoring.footnoteUrl });
useEffect(() => {
if (!showGreetingVideo || !greetingVideoSrc) return;
greetingVideoRef.current?.play().catch(() => {
// Browser autoplay rules can still require the native control click.
});
}, [showGreetingVideo, greetingVideoSrc]);
return ( return (
<div className="animate-fade-in"> <div className="animate-fade-in">
@@ -532,6 +571,47 @@ const Netzwerk: React.FC = () => {
<div style={{ fontFamily: FF, fontSize: 11, color: 'rgba(255,255,255,0.35)', marginTop: 20, paddingLeft: 24 }}> <div style={{ fontFamily: FF, fontSize: 11, color: 'rgba(255,255,255,0.35)', marginTop: 20, paddingLeft: 24 }}>
{fallbackText(patronage.greetingAttribution, netzwerkContent.patronage.greetingAttribution)} {fallbackText(patronage.greetingAttribution, netzwerkContent.patronage.greetingAttribution)}
</div> </div>
{greetingVideoSrc && (
<div style={{ paddingLeft: 24, marginTop: 28 }}>
<button
type="button"
onClick={() => setShowGreetingVideo(true)}
aria-label={greetingVideoLabel}
style={{
display: 'inline-flex',
alignItems: 'center',
gap: 10,
border: `1px solid ${GOLD}73`,
background: 'rgba(239,191,4,0.1)',
color: '#fff',
cursor: 'pointer',
fontFamily: FF,
fontSize: 11,
fontWeight: 700,
letterSpacing: '0.12em',
lineHeight: 1,
padding: '13px 18px',
textTransform: 'uppercase',
transition: 'background 0.15s, border-color 0.15s, color 0.15s',
}}
onMouseEnter={e => {
const el = e.currentTarget as HTMLElement;
el.style.background = GOLD;
el.style.borderColor = GOLD;
el.style.color = NAVY;
}}
onMouseLeave={e => {
const el = e.currentTarget as HTMLElement;
el.style.background = 'rgba(239,191,4,0.1)';
el.style.borderColor = `${GOLD}73`;
el.style.color = '#fff';
}}
>
<Play size={15} fill="currentColor" />
{greetingVideoLabel}
</button>
</div>
)}
</div> </div>
</div> </div>
</div> </div>
@@ -757,37 +837,39 @@ const Netzwerk: React.FC = () => {
</div> </div>
</div> </div>
{/* Footnote */} {/* Footnote */}
<div style={{ position: 'relative', zIndex: 1, padding: isMobile ? '16px 20px 20px' : '14px 56px 18px', display: 'flex', flexWrap: 'wrap', justifyContent: 'center', alignItems: 'center', borderTop: '1px solid rgba(255,255,255,0.06)', flexShrink: 0 }}> {showMembershipFootnote && (
<span style={{ fontFamily: '"IBM Plex Sans", sans-serif', fontSize: 15, color: 'rgba(255,255,255,0.45)', marginRight: 10 }}>{fallbackText(sponsoring.footnotePrefix, netzwerkContent.sponsoring.footnotePrefix)}</span> <div style={{ position: 'relative', zIndex: 1, padding: isMobile ? '16px 20px 20px' : '14px 56px 18px', display: 'flex', flexWrap: 'wrap', justifyContent: 'center', alignItems: 'center', borderTop: '1px solid rgba(255,255,255,0.06)', flexShrink: 0 }}>
<Link <span style={{ fontFamily: '"IBM Plex Sans", sans-serif', fontSize: 15, color: 'rgba(255,255,255,0.45)', marginRight: 10 }}>{fallbackText(sponsoring.footnotePrefix, netzwerkContent.sponsoring.footnotePrefix)}</span>
to={fallbackText(sponsoring.footnoteUrl, netzwerkContent.sponsoring.footnoteUrl)} <Link
style={{ to={fallbackText(sponsoring.footnoteUrl, netzwerkContent.sponsoring.footnoteUrl)}
fontFamily: '"IBM Plex Sans", sans-serif', style={{
fontSize: 15, fontFamily: '"IBM Plex Sans", sans-serif',
fontWeight: 700, fontSize: 15,
letterSpacing: '0.12em', fontWeight: 700,
textTransform: 'uppercase', letterSpacing: '0.12em',
color: 'rgba(239,191,4,0.7)', textTransform: 'uppercase',
textDecoration: 'none', color: 'rgba(239,191,4,0.7)',
display: 'inline-flex', textDecoration: 'none',
alignItems: 'center', display: 'inline-flex',
gap: 6, alignItems: 'center',
borderBottom: '1px solid rgba(239,191,4,0.3)', gap: 6,
paddingBottom: 1, borderBottom: '1px solid rgba(239,191,4,0.3)',
transition: 'color 0.15s, borderColor 0.15s', paddingBottom: 1,
}} transition: 'color 0.15s, borderColor 0.15s',
onMouseEnter={e => { }}
(e.currentTarget as HTMLElement).style.color = '#EFBF04'; onMouseEnter={e => {
(e.currentTarget as HTMLElement).style.borderBottomColor = '#EFBF04'; (e.currentTarget as HTMLElement).style.color = '#EFBF04';
}} (e.currentTarget as HTMLElement).style.borderBottomColor = '#EFBF04';
onMouseLeave={e => { }}
(e.currentTarget as HTMLElement).style.color = 'rgba(239,191,4,0.7)'; onMouseLeave={e => {
(e.currentTarget as HTMLElement).style.borderBottomColor = 'rgba(239,191,4,0.3)'; (e.currentTarget as HTMLElement).style.color = 'rgba(239,191,4,0.7)';
}} (e.currentTarget as HTMLElement).style.borderBottomColor = 'rgba(239,191,4,0.3)';
> }}
{fallbackText(sponsoring.footnoteLabel, netzwerkContent.sponsoring.footnoteLabel)} <ChevronRight size={11} /> >
</Link> {fallbackText(sponsoring.footnoteLabel, netzwerkContent.sponsoring.footnoteLabel)} <ChevronRight size={11} />
</div> </Link>
</div>
)}
<div style={{ position: 'relative', zIndex: 1, height: 2, background: `linear-gradient(to right, ${GOLD}, rgba(239,191,4,0.3), transparent)`, flexShrink: 0 }} /> <div style={{ position: 'relative', zIndex: 1, height: 2, background: `linear-gradient(to right, ${GOLD}, rgba(239,191,4,0.3), transparent)`, flexShrink: 0 }} />
</section> </section>
@@ -797,6 +879,68 @@ const Netzwerk: React.FC = () => {
<PartnerModal partner={selectedPartner} copy={partnerModal} onClose={() => setSelectedPartner(null)} /> <PartnerModal partner={selectedPartner} copy={partnerModal} onClose={() => setSelectedPartner(null)} />
)} )}
{/* ── 9. GRUSSWORT VIDEO MODAL ────────────────────────────────────────── */}
{showGreetingVideo && greetingVideoSrc && (
<div
onClick={(e) => { if (e.target === e.currentTarget) setShowGreetingVideo(false); }}
style={{
position: 'fixed',
inset: 0,
zIndex: 520,
background: 'rgba(0,0,0,0.95)',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
padding: isMobile ? 16 : 24,
}}
>
<button
type="button"
onClick={() => setShowGreetingVideo(false)}
aria-label={greetingVideoCloseLabel}
style={{
position: 'absolute',
top: isMobile ? 18 : 32,
right: isMobile ? 18 : 32,
width: isMobile ? 42 : 52,
height: isMobile ? 42 : 52,
border: 'none',
borderRadius: '50%',
background: 'rgba(255,255,255,0.1)',
color: 'rgba(255,255,255,0.75)',
cursor: 'pointer',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
transition: 'background 0.15s, color 0.15s',
}}
onMouseEnter={e => {
const el = e.currentTarget as HTMLElement;
el.style.background = 'rgba(255,255,255,0.18)';
el.style.color = '#fff';
}}
onMouseLeave={e => {
const el = e.currentTarget as HTMLElement;
el.style.background = 'rgba(255,255,255,0.1)';
el.style.color = 'rgba(255,255,255,0.75)';
}}
>
<X size={isMobile ? 24 : 30} />
</button>
<div style={{ width: '100%', maxWidth: 1120, aspectRatio: '16/9', background: '#000' }}>
<video
ref={greetingVideoRef}
src={greetingVideoSrc}
style={{ width: '100%', height: '100%', objectFit: 'contain', display: 'block' }}
controls
autoPlay
playsInline
aria-label={greetingVideoTitle}
/>
</div>
</div>
)}
</div> </div>
); );
}; };

View File

@@ -1,5 +1,5 @@
import React, { useRef } from 'react'; import React, { useRef } from 'react';
import { MapPin, Users, TrendingUp, Building2, ArrowRight, CheckCircle2, UserCheck, Send, Star } from 'lucide-react'; import { MapPin, Users, TrendingUp, Building2, ArrowRight, CheckCircle2, UserCheck, Send, Star, Mail } from 'lucide-react';
import { Link } from '@/spa/router'; import { Link } from '@/spa/router';
import BewerbungsForm from '@/spa/components/forms/BewerbungsForm'; import BewerbungsForm from '@/spa/components/forms/BewerbungsForm';
import WegZurAuszeichnungSection from '@/spa/components/WegZurAuszeichnungSection'; import WegZurAuszeichnungSection from '@/spa/components/WegZurAuszeichnungSection';
@@ -18,10 +18,13 @@ const CREAM = '#E4E2E3';
type ParticipationCms = Partial<typeof participationContent> & { type ParticipationCms = Partial<typeof participationContent> & {
hero?: Partial<typeof participationContent.hero> & { backgroundImage?: unknown }; hero?: Partial<typeof participationContent.hero> & { backgroundImage?: unknown };
evaluation?: Partial<typeof participationContent.evaluation> & { image?: unknown }; awardsGrid?: Partial<typeof participationContent.awardsGrid> & { cards?: AwardCardCms[] };
applicationForm?: Partial<typeof participationContent.applicationForm> & { image?: unknown }; applicationForm?: Partial<typeof participationContent.applicationForm> & { image?: unknown };
}; };
type AwardCard = (typeof participationContent.awardsGrid.cards)[number];
type AwardCardCms = Partial<AwardCard> & { image?: unknown };
const ICONS = { const ICONS = {
building2: Building2, building2: Building2,
mapPin: MapPin, mapPin: MapPin,
@@ -38,6 +41,11 @@ const fallbackText = (value: unknown, fallback: string) =>
const fallbackArray = <T,>(value: unknown, fallback: T[]) => const fallbackArray = <T,>(value: unknown, fallback: T[]) =>
Array.isArray(value) && value.length > 0 ? (value as T[]) : fallback; Array.isArray(value) && value.length > 0 ? (value as T[]) : fallback;
const mergeAwardCards = (value: unknown, fallback: AwardCard[]): (AwardCard & { image?: unknown })[] => {
const cards = Array.isArray(value) && value.length > 0 ? (value as AwardCardCms[]) : fallback;
return cards.map((card, index) => ({ ...(fallback[index] || fallback[0]), ...card }));
};
function Lines({ text }: { text: string }) { 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('\n').map((line, i) => <React.Fragment key={`${line}-${i}`}>{i > 0 && <br />}{line}</React.Fragment>)}</>;
} }
@@ -49,14 +57,13 @@ const Participation: React.FC = () => {
const hero = { ...participationContent.hero, ...(cms.hero || {}) }; const hero = { ...participationContent.hero, ...(cms.hero || {}) };
const process = { ...participationContent.process, ...(cms.process || {}) }; const process = { ...participationContent.process, ...(cms.process || {}) };
const eligibility = { ...participationContent.eligibility, ...(cms.eligibility || {}) }; const eligibility = { ...participationContent.eligibility, ...(cms.eligibility || {}) };
const evaluation = { ...participationContent.evaluation, ...(cms.evaluation || {}) };
const applicationWays = { ...participationContent.applicationWays, ...(cms.applicationWays || {}) }; const applicationWays = { ...participationContent.applicationWays, ...(cms.applicationWays || {}) };
const datesBanner = { ...participationContent.datesBanner, ...(cms.datesBanner || {}) }; const datesBanner = { ...participationContent.datesBanner, ...(cms.datesBanner || {}) };
const awardsGrid = { ...participationContent.awardsGrid, ...(cms.awardsGrid || {}) };
const applicationForm = { ...participationContent.applicationForm, ...(cms.applicationForm || {}) }; const applicationForm = { ...participationContent.applicationForm, ...(cms.applicationForm || {}) };
const form = { ...participationContent.form, ...(cms.form || {}) }; const form = { ...participationContent.form, ...(cms.form || {}) };
const eligibilityCriteria = fallbackArray(eligibility.criteria, participationContent.eligibility.criteria); const eligibilityCriteria = fallbackArray(eligibility.criteria, participationContent.eligibility.criteria);
const eligibilityNotes = fallbackArray(eligibility.notes, participationContent.eligibility.notes); const eligibilityNotes = fallbackArray(eligibility.notes, participationContent.eligibility.notes);
const evaluationCriteria = fallbackArray(evaluation.criteria, participationContent.evaluation.criteria);
const mobileDates = fallbackArray(datesBanner.mobileItems, participationContent.datesBanner.mobileItems); const mobileDates = fallbackArray(datesBanner.mobileItems, participationContent.datesBanner.mobileItems);
const desktopDates = fallbackArray(datesBanner.desktopItems, participationContent.datesBanner.desktopItems); const desktopDates = fallbackArray(datesBanner.desktopItems, participationContent.datesBanner.desktopItems);
const applicationFacts = fallbackArray(applicationForm.facts, participationContent.applicationForm.facts); const applicationFacts = fallbackArray(applicationForm.facts, participationContent.applicationForm.facts);
@@ -174,75 +181,6 @@ const Participation: React.FC = () => {
</div> </div>
</section> </section>
{/* ── CRITERIA / BEWERTUNG ─────────────────────────────────────────────── */}
<section id="kriterien" style={{ background: NAVY, overflow: 'hidden', position: 'relative' }}>
{/* TOP: editorial split – image left (45%) / header right (55%) */}
<div style={{ display: 'grid', gridTemplateColumns: isMobile ? '1fr' : '45% 55%', minHeight: isMobile ? 'auto' : 480 }}>
{/* Left – full-bleed image with gradient blend into navy on the right (+ heading on mobile) */}
<div style={{ position: 'relative', overflow: 'hidden', minHeight: isMobile ? 280 : undefined }}>
<Image unoptimized
src={mediaUrl(evaluation.image, `/images/${participationContent.evaluation.imageFilename}`)}
alt={mediaAlt(evaluation.image, fallbackText(evaluation.imageAlt, participationContent.evaluation.imageAlt))}
style={{ position: 'absolute', inset: 0, width: '100%', height: '100%', objectFit: 'cover', objectPosition: 'center', display: 'block' }}
/>
{/* Dark overlay for depth */}
<div style={{ position: 'absolute', inset: 0, background: 'linear-gradient(to bottom, rgba(3,9,58,0.35) 0%, rgba(3,9,58,0.15) 50%, rgba(3,9,58,0.35) 100%)' }} />
{/* Right-side blend into navy (desktop) */}
{!isMobile && <div style={{ position: 'absolute', inset: 0, background: 'linear-gradient(to right, transparent 50%, rgba(3,9,58,0.7) 80%, #111D55 100%)' }} />}
{/* Mobile heading shadow (bottom-left) */}
{isMobile && <div style={{ position: 'absolute', inset: 0, background: '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%)' }} />}
{/* Bottom blend for continuity with grid below */}
<div style={{ position: 'absolute', bottom: 0, left: 0, right: 0, height: 120, background: 'linear-gradient(to bottom, transparent, #111D55)' }} />
{/* Heading overlay – mobile only */}
{isMobile && (
<div style={{ position: 'absolute', left: 0, right: 0, bottom: 0, padding: '0 24px 24px', zIndex: 2 }}>
<span style={{ fontFamily: FF, fontSize: 10, color: '#EFBF04', textTransform: 'uppercase', letterSpacing: '0.32em', fontWeight: 700, display: 'block', marginBottom: 12 }}>{fallbackText(evaluation.eyebrow, participationContent.evaluation.eyebrow)}</span>
<h2 style={{ fontFamily: FF, fontSize: 'clamp(2rem, 9vw, 2.8rem)', fontWeight: 900, color: '#fff', textTransform: 'uppercase', letterSpacing: '-0.03em', lineHeight: 0.97, margin: 0, textShadow: '0 2px 18px rgba(3,9,58,0.6)' }}>
<Lines text={fallbackText(evaluation.heading, participationContent.evaluation.heading)} />
</h2>
</div>
)}
</div>
{/* Right – overline + headline + intro */}
<div style={{ padding: isMobile ? '32px 24px 40px' : '80px 80px 72px 64px', display: 'flex', flexDirection: 'column', justifyContent: 'center', borderBottom: '1px solid rgba(255,255,255,0.07)' }}>
{!isMobile && (
<>
<span style={{ fontFamily: FF, fontSize: 10, color: '#EFBF04', textTransform: 'uppercase', letterSpacing: '0.32em', fontWeight: 700, display: 'block', marginBottom: 20 }}>{fallbackText(evaluation.eyebrow, participationContent.evaluation.eyebrow)}</span>
<h2 style={{ fontFamily: FF, fontSize: 'clamp(2.4rem, 4vw, 3.6rem)', fontWeight: 900, color: '#fff', textTransform: 'uppercase', letterSpacing: '-0.03em', lineHeight: 0.97, margin: '0 0 28px' }}>
<Lines text={fallbackText(evaluation.heading, participationContent.evaluation.heading)} />
</h2>
</>
)}
<div style={{ width: 40, height: 2, background: GOLD, marginBottom: 28 }} />
<p style={{ fontFamily: FB, fontSize: 19, color: 'rgba(255,255,255,0.68)', lineHeight: 1.8, margin: 0, maxWidth: 420 }}>
<Lines text={fallbackText(evaluation.body, participationContent.evaluation.body)} />
</p>
<a
href={fallbackText(evaluation.link?.url, participationContent.evaluation.link.url)}
target="_blank"
rel="noopener noreferrer"
style={{ fontFamily: FF, fontSize: 14, fontWeight: 700, letterSpacing: '0.1em', textTransform: 'uppercase', color: GOLD, textDecoration: 'none', display: 'inline-flex', alignItems: 'center', gap: 8, marginTop: 28, borderBottom: `1px solid rgba(239,191,4,0.4)`, paddingBottom: 2, alignSelf: 'flex-start' }}
onMouseEnter={e => ((e.currentTarget as HTMLElement).style.opacity = '0.8')}
onMouseLeave={e => ((e.currentTarget as HTMLElement).style.opacity = '1')}
>
{fallbackText(evaluation.link?.label, participationContent.evaluation.link.label)} <ArrowRight size={13} />
</a>
</div>
</div>
{/* BOTTOM: full-width 4-column criteria grid */}
<div style={{ display: 'grid', gridTemplateColumns: isMobile ? '1fr' : 'repeat(4, 1fr)', borderTop: '1px solid rgba(255,255,255,0.07)', position: 'relative' }}>
{evaluationCriteria.map((crit, i) => (
<CriteriaCell key={i} crit={crit} idx={i} />
))}
</div>
<div style={{ height: 2, background: `linear-gradient(to right, ${GOLD}, rgba(239,191,4,0.2), transparent)` }} />
</section>
{/* ── BEWERBUNGSWEGE ───────────────────────────────────────────────────── */} {/* ── BEWERBUNGSWEGE ───────────────────────────────────────────────────── */}
<BewerbungswegeSection content={applicationWays} /> <BewerbungswegeSection content={applicationWays} />
@@ -282,6 +220,9 @@ const Participation: React.FC = () => {
)} )}
</section> </section>
{/* ── AWARDS GRID ─────────────────────────────────────────────────────── */}
<AwardsGridSection content={awardsGrid} />
{/* ── FORM SECTION ─────────────────────────────────────────────────────── */} {/* ── FORM SECTION ─────────────────────────────────────────────────────── */}
<section style={{ background: NAVY, position: 'relative', overflow: 'hidden', height: isMobile ? 'auto' : 'calc(100vh - 60px)', display: 'flex', flexDirection: 'column' }} id="bewerben"> <section style={{ background: NAVY, position: 'relative', overflow: 'hidden', height: isMobile ? 'auto' : 'calc(100vh - 60px)', display: 'flex', flexDirection: 'column' }} id="bewerben">
<div style={{ display: 'grid', gridTemplateColumns: isMobile ? '1fr' : '4fr 1px 8fr', flex: 1, minHeight: 0, overflow: isMobile ? 'visible' : 'hidden' }}> <div style={{ display: 'grid', gridTemplateColumns: isMobile ? '1fr' : '4fr 1px 8fr', flex: 1, minHeight: 0, overflow: isMobile ? 'visible' : 'hidden' }}>
@@ -400,27 +341,6 @@ function QualRow({ item, Icon, last }: { item: { num: string; title: string; bod
); );
} }
// ── CriteriaCell ──────────────────────────────────────────────────────────────
function CriteriaCell({ crit, idx }: { crit: { title: string; desc: string }; idx: number }) {
const [hovered, setHovered] = React.useState(false);
const isMobileCell = useIsMobile();
return (
<div
style={{ padding: isMobileCell ? '32px 24px' : '56px 40px', borderLeft: !isMobileCell && idx > 0 ? '1px solid rgba(255,255,255,0.07)' : 'none', borderTop: isMobileCell && idx > 0 ? '1px solid rgba(255,255,255,0.07)' : 'none', display: 'flex', flexDirection: 'column', gap: 0, cursor: 'default', transition: 'background 0.2s', background: hovered ? 'rgba(255,255,255,0.03)' : 'transparent' }}
onMouseEnter={() => setHovered(true)}
onMouseLeave={() => setHovered(false)}
>
<div style={{ display: 'flex', alignItems: 'flex-start', justifyContent: 'space-between', marginBottom: 24 }}>
<span style={{ fontFamily: FF, fontSize: 10, fontWeight: 700, color: 'rgba(255,255,255,0.50)', letterSpacing: '0.15em', textTransform: 'uppercase' }}>0{idx + 1}</span>
</div>
<div style={{ width: hovered ? '100%' : 28, height: 1, background: hovered ? 'rgba(239,191,4,0.6)' : 'rgba(239,191,4,0.3)', marginBottom: 20, transition: 'width 0.5s ease' }} />
<h4 style={{ fontFamily: FF, fontSize: 16, fontWeight: 700, color: '#fff', textTransform: 'uppercase', letterSpacing: '0.08em', lineHeight: 1.3, marginBottom: 14 }}>{crit.title}</h4>
<p style={{ fontFamily: FB, fontSize: 18, color: 'rgba(255,255,255,0.65)', lineHeight: 1.7, flex: 1 }}>{crit.desc}</p>
</div>
);
}
// ── BewerbungswegeSection ───────────────────────────────────────────────────── // ── BewerbungswegeSection ─────────────────────────────────────────────────────
type ApplicationWaysContent = Partial<typeof participationContent.applicationWays>; type ApplicationWaysContent = Partial<typeof participationContent.applicationWays>;
@@ -551,4 +471,131 @@ function BewerbungswegeSection({ content }: { content?: ApplicationWaysContent }
); );
} }
type AwardsGridContent = Partial<typeof participationContent.awardsGrid>;
function AwardsGridSection({ content }: { content?: AwardsGridContent }) {
const isMobile = useIsMobile();
const section = { ...participationContent.awardsGrid, ...(content || {}) };
const cards = mergeAwardCards(section.cards, participationContent.awardsGrid.cards);
return (
<section id="auszeichnungen" style={{ background: '#F7F7F5', position: 'relative', overflow: 'hidden', isolation: 'isolate' }}>
<MunichSkylineBg />
<div style={{ position: 'relative', zIndex: 1, padding: isMobile ? '48px 24px 32px' : '80px 80px 56px', borderBottom: '1px solid rgba(3,9,58,0.1)' }}>
<span style={{ fontFamily: FF, fontSize: 10, color: '#4A8FC9', textTransform: 'uppercase', letterSpacing: '0.32em', fontWeight: 700, display: 'block', marginBottom: 16 }}>
{fallbackText(section.eyebrow, participationContent.awardsGrid.eyebrow)}
</span>
<div style={{ display: 'grid', gridTemplateColumns: isMobile ? '1fr' : 'minmax(0, 0.9fr) minmax(0, 1fr)', gap: isMobile ? 16 : 56, alignItems: 'end' }}>
<h2 style={{ fontFamily: FF, fontSize: 'clamp(2rem, 3.5vw, 3rem)', fontWeight: 900, color: '#101828', textTransform: 'uppercase', letterSpacing: '-0.025em', lineHeight: 1.03, margin: 0, overflowWrap: 'anywhere' }}>
<Lines text={fallbackText(section.heading, participationContent.awardsGrid.heading)} />
</h2>
<p style={{ fontFamily: FB, fontSize: 18, color: 'rgba(16,24,40,0.55)', lineHeight: 1.8, margin: 0 }}>
{fallbackText(section.description, participationContent.awardsGrid.description)}
</p>
</div>
</div>
<div style={{ position: 'relative', zIndex: 1, display: 'grid', gridTemplateColumns: isMobile ? '1fr' : 'repeat(3, minmax(0, 1fr))', borderBottom: '1px solid rgba(3,9,58,0.08)' }}>
{cards.map((card, index) => {
const imageFilename = fallbackText(card.imageFilename, participationContent.awardsGrid.cards[index]?.imageFilename || participationContent.awardsGrid.cards[0].imageFilename);
const isContainedImage = imageFilename.endsWith('.png') || imageFilename.includes('roland-berger');
const isLast = index === cards.length - 1;
return (
<article
key={`${fallbackText(card.title, 'award')}-${index}`}
style={{
display: 'flex',
flexDirection: 'column',
minHeight: isMobile ? 'auto' : 620,
borderRight: !isMobile && !isLast ? '1px solid rgba(3,9,58,0.1)' : 'none',
borderBottom: isMobile && !isLast ? '1px solid rgba(3,9,58,0.1)' : 'none',
background: index === 1 ? NAVY : 'rgba(255,255,255,0.72)',
}}
>
<div style={{ height: isMobile ? 220 : 260, background: imageFilename.includes('roland-berger') ? '#DDE7E2' : NAVY, display: 'flex', alignItems: 'center', justifyContent: 'center', overflow: 'hidden' }}>
<Image unoptimized
src={mediaUrl(card.image, `/images/${imageFilename}`)}
alt={mediaAlt(card.image, fallbackText(card.imageAlt, fallbackText(card.title, 'Auszeichnung')))}
style={{
width: '100%',
height: '100%',
objectFit: isContainedImage ? 'contain' : 'cover',
objectPosition: index === 1 ? 'center top' : 'center',
padding: imageFilename.endsWith('.png') ? 22 : 0,
boxSizing: 'border-box',
display: 'block',
}}
/>
</div>
<div style={{ padding: isMobile ? '28px 24px 32px' : '36px 36px 40px', display: 'flex', flexDirection: 'column', flex: 1 }}>
<span style={{ fontFamily: FF, fontSize: 9, fontWeight: 700, letterSpacing: '0.26em', textTransform: 'uppercase', color: index === 1 ? 'rgba(239,191,4,0.8)' : '#4A8FC9', marginBottom: 12 }}>
{fallbackText(card.label, '')}
</span>
<h3 style={{ fontFamily: FF, fontSize: 'clamp(1.25rem, 2vw, 1.7rem)', fontWeight: 900, color: index === 1 ? '#fff' : '#101828', textTransform: 'uppercase', letterSpacing: '-0.02em', lineHeight: 1.08, margin: '0 0 16px', overflowWrap: 'anywhere' }}>
<Lines text={fallbackText(card.title, '')} />
</h3>
<div style={{ width: 36, height: 2, background: GOLD, marginBottom: 22 }} />
<p style={{ fontFamily: FB, fontSize: 18, color: index === 1 ? 'rgba(255,255,255,0.62)' : 'rgba(16,24,40,0.55)', lineHeight: 1.7, margin: 0, flex: 1 }}>
{fallbackText(card.description, '')}
</p>
<div style={{ display: 'flex', flexDirection: isMobile ? 'column' : 'row', gap: 10, marginTop: 32 }}>
<Link
to={fallbackText(card.articleCta?.url, '#bewerben')}
style={{
minHeight: 46,
padding: '0 18px',
display: 'inline-flex',
alignItems: 'center',
justifyContent: 'center',
gap: 8,
background: GOLD,
color: '#101828',
fontFamily: FF,
fontSize: 13,
fontWeight: 700,
textTransform: 'uppercase',
letterSpacing: '0.08em',
textDecoration: 'none',
whiteSpace: 'normal',
textAlign: 'center',
}}
>
{fallbackText(card.articleCta?.label, 'Artikel lesen')} <ArrowRight size={13} />
</Link>
<a
href={fallbackText(card.mailtoCta?.url, `mailto:${participationContent.form.eligibility.ineligibleContactEmail}`)}
style={{
minHeight: 46,
padding: '0 18px',
display: 'inline-flex',
alignItems: 'center',
justifyContent: 'center',
gap: 8,
color: index === 1 ? '#fff' : NAVY,
border: `1px solid ${index === 1 ? 'rgba(255,255,255,0.22)' : 'rgba(3,9,58,0.18)'}`,
fontFamily: FF,
fontSize: 13,
fontWeight: 700,
textTransform: 'uppercase',
letterSpacing: '0.08em',
textDecoration: 'none',
whiteSpace: 'normal',
textAlign: 'center',
}}
>
<Mail size={13} /> {fallbackText(card.mailtoCta?.label, 'Kontakt aufnehmen')}
</a>
</div>
</div>
</article>
);
})}
</div>
</section>
);
}
export default Participation; export default Participation;

View File

@@ -1,6 +1,6 @@
import React, { useState, useMemo } from 'react'; import React, { useState, useMemo } from 'react';
import { Link } from '@/spa/router'; import { Link } from '@/spa/router';
import { Trophy, Search, ChevronDown, ChevronRight, X, ArrowRight } from 'lucide-react'; import { Trophy, ChevronRight, ArrowRight } from 'lucide-react';
import { WINNERS, type Winner } from '@/spa/data/winners'; import { WINNERS, type Winner } from '@/spa/data/winners';
import { useCmsCollection, useCmsRoute, type CmsRouteDoc } from '@/spa/cmsRoute'; import { useCmsCollection, useCmsRoute, type CmsRouteDoc } from '@/spa/cmsRoute';
import { docImageUrl, mediaAlt, mediaUrl } from '@/spa/cmsMediaField'; import { docImageUrl, mediaAlt, mediaUrl } from '@/spa/cmsMediaField';
@@ -47,14 +47,10 @@ const normalizeWinner = (
export default function Preistraeger() { export default function Preistraeger() {
const isMobile = useIsMobile(); const isMobile = useIsMobile();
const [filterCategory, setFilterCategory] = useState('');
const [search, setSearch] = useState('');
const [mediaOnly, setMediaOnly] = useState(false);
const cms = (useCmsRoute()?.doc?.preistraegerIndex || {}) as PreistraegerIndexCms; const cms = (useCmsRoute()?.doc?.preistraegerIndex || {}) as PreistraegerIndexCms;
const cmsPreistraeger = useCmsCollection('preistraeger'); const cmsPreistraeger = useCmsCollection('preistraeger');
const hero = { ...preistraegerIndexContent.hero, ...(cms.hero || {}) }; const hero = { ...preistraegerIndexContent.hero, ...(cms.hero || {}) };
const breadcrumb = { ...preistraegerIndexContent.breadcrumb, ...(cms.breadcrumb || {}) }; const breadcrumb = { ...preistraegerIndexContent.breadcrumb, ...(cms.breadcrumb || {}) };
const filters = { ...preistraegerIndexContent.filters, ...(cms.filters || {}) };
const count = { ...preistraegerIndexContent.count, ...(cms.count || {}) }; const count = { ...preistraegerIndexContent.count, ...(cms.count || {}) };
const empty = { ...preistraegerIndexContent.empty, ...(cms.empty || {}) }; const empty = { ...preistraegerIndexContent.empty, ...(cms.empty || {}) };
const card = useMemo(() => ({ ...preistraegerIndexContent.card, ...(cms.card || {}) }), [cms.card]); const card = useMemo(() => ({ ...preistraegerIndexContent.card, ...(cms.card || {}) }), [cms.card]);
@@ -69,20 +65,10 @@ export default function Preistraeger() {
return WINNERS; return WINNERS;
}, [card, cardFallbackImage, cmsPreistraeger]); }, [card, cardFallbackImage, cmsPreistraeger]);
const years = useMemo(() => [...new Set(winners.map((winner) => winner.year))].sort((a, b) => b - a), [winners]); const years = useMemo(() => [...new Set(winners.map((winner) => winner.year))].sort((a, b) => b - a), [winners]);
const categories = useMemo(() => [...new Set(winners.map((winner) => winner.category))].sort((a, b) => a.localeCompare(b, 'de')), [winners]);
const [activeYear, setActiveYear] = useState<number | undefined>(undefined); const [activeYear, setActiveYear] = useState<number | undefined>(undefined);
const selectedYear = activeYear ?? years[0] ?? new Date().getFullYear(); const selectedYear = activeYear ?? years[0] ?? new Date().getFullYear();
const filtered = useMemo(() => winners.filter(w => { const filtered = useMemo(() => winners.filter(w => w.year === selectedYear), [selectedYear, winners]);
if (w.year !== selectedYear) return false;
if (filterCategory && w.category !== filterCategory) return false;
if (search && !w.name.toLowerCase().includes(search.toLowerCase())) return false;
if (mediaOnly && !w.hasMedia) return false;
return true;
}), [selectedYear, filterCategory, search, mediaOnly, winners]);
const reset = () => { setFilterCategory(''); setSearch(''); setMediaOnly(false); };
const hasFilter = !!(filterCategory || search || mediaOnly);
return ( return (
<div style={{ background: '#fff', minHeight: '100vh' }}> <div style={{ background: '#fff', minHeight: '100vh' }}>
@@ -102,16 +88,16 @@ export default function Preistraeger() {
<span style={{ fontFamily: FF, fontSize: 16, color: GOLD, fontWeight: 600, letterSpacing: '0.04em' }}>{fallbackText(breadcrumb.label, preistraegerIndexContent.breadcrumb.label)}</span> <span style={{ fontFamily: FF, fontSize: 16, color: GOLD, fontWeight: 600, letterSpacing: '0.04em' }}>{fallbackText(breadcrumb.label, preistraegerIndexContent.breadcrumb.label)}</span>
</div> </div>
{/* Filter Section */} {/* Year Filter Section */}
<div style={{ background: '#fff', padding: isMobile ? '32px 24px 24px' : '48px 80px 32px' }}> <div style={{ background: '#fff', padding: isMobile ? '32px 24px 20px' : '48px 80px 24px' }}>
{/* #93: Headline entfernt – Seitenname steht bereits oben im Breadcrumb. */} {/* #93: Headline entfernt – Seitenname steht bereits oben im Breadcrumb. */}
{/* Year Tabs */} {/* Year Tabs */}
<div style={{ display: 'flex', flexWrap: isMobile ? 'nowrap' : 'wrap', gap: 0, marginBottom: 32, borderBottom: `2px solid ${BORDER}`, overflowX: isMobile ? 'auto' : 'visible', WebkitOverflowScrolling: 'touch', maxWidth: '100%' }}> <div style={{ display: 'flex', flexWrap: isMobile ? 'nowrap' : 'wrap', gap: 0, borderBottom: `2px solid ${BORDER}`, overflowX: isMobile ? 'auto' : 'visible', WebkitOverflowScrolling: 'touch', maxWidth: '100%' }}>
{years.map(year => ( {years.map(year => (
<button <button
key={year} key={year}
onClick={() => { setActiveYear(year); reset(); }} onClick={() => setActiveYear(year)}
style={{ style={{
fontFamily: FF, fontSize: 16, fontWeight: 700, fontFamily: FF, fontSize: 16, fontWeight: 700,
padding: isMobile ? '12px 20px' : '12px 32px', padding: isMobile ? '12px 20px' : '12px 32px',
@@ -128,54 +114,6 @@ export default function Preistraeger() {
</button> </button>
))} ))}
</div> </div>
{/* Filter Row */}
<div style={{ display: 'flex', flexDirection: isMobile ? 'column' : 'row', gap: 16, alignItems: 'stretch', marginBottom: 20, flexWrap: 'wrap' }}>
<SelectControl
value={filterCategory}
onChange={setFilterCategory}
placeholder={fallbackText(filters.categoryPlaceholder, preistraegerIndexContent.filters.categoryPlaceholder)}
options={categories.map(c => ({ label: c, value: c }))}
width={isMobile ? undefined : 280}
/>
<SearchControl value={search} onChange={setSearch} placeholder={fallbackText(filters.searchPlaceholder, preistraegerIndexContent.filters.searchPlaceholder)} />
</div>
{/* Toggle row */}
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', flexWrap: 'wrap', gap: 12 }}>
<div style={{ display: 'flex', flexDirection: 'column', gap: 6 }}>
<span style={{ fontFamily: FF, fontSize: 15, fontWeight: 700, color: '#101828' }}>
{fallbackText(filters.storyHeading, preistraegerIndexContent.filters.storyHeading)}
</span>
<div style={{ display: 'flex', alignItems: 'center', gap: 8 }}>
<span style={{ fontSize: 15 }}>{fallbackText(filters.mediaIcon, preistraegerIndexContent.filters.mediaIcon)}</span>
<span style={{ fontFamily: FF, fontSize: 15, color: GRAY }}>{fallbackText(filters.mediaLabel, preistraegerIndexContent.filters.mediaLabel)}</span>
<button
onClick={() => setMediaOnly(v => !v)}
style={{
width: 40, height: 22, borderRadius: 11, border: 'none', cursor: 'pointer',
background: mediaOnly ? NAVY : BORDER,
position: 'relative', transition: 'background 0.2s',
}}
>
<span style={{
position: 'absolute', top: 3, left: mediaOnly ? 20 : 3, width: 16, height: 16,
borderRadius: '50%', background: '#fff', transition: 'left 0.2s',
}} />
</button>
</div>
</div>
{hasFilter && (
<button
onClick={reset}
style={{ fontFamily: FF, fontSize: 16, color: GRAY, background: 'none', border: 'none', cursor: 'pointer', display: 'flex', alignItems: 'center', gap: 6 }}
onMouseEnter={e => (e.currentTarget.style.color = GOLD)}
onMouseLeave={e => (e.currentTarget.style.color = GRAY)}
>
<X size={14} /> {fallbackText(filters.resetLabel, preistraegerIndexContent.filters.resetLabel)}
</button>
)}
</div>
</div> </div>
{/* Count bar */} {/* Count bar */}
@@ -319,61 +257,3 @@ function WinnerCard({ winner, hoverLabel }: { winner: Winner; hoverLabel: string
</Link> </Link>
); );
} }
// ── SelectControl ────────────────────────────────────────────────────────────
function SelectControl({ value, onChange, placeholder, options, width }: {
value: string; onChange: (v: string) => void;
placeholder: string; options: { label: string; value: string }[];
width?: number;
}) {
const active = !!value;
return (
<div style={{ position: 'relative', width: width ?? '100%', flexShrink: width ? 0 : 1 }}>
<select
value={value}
onChange={e => onChange(e.target.value)}
style={{
width: '100%', height: 44, padding: '0 36px 0 14px',
fontFamily: FF, fontSize: 16,
border: `1px solid ${active ? NAVY : BORDER}`,
color: active ? NAVY : GRAY,
background: '#fff', borderRadius: 0,
appearance: 'none', cursor: 'pointer', outline: 'none',
transition: 'border-color 0.15s',
}}
onFocus={e => (e.currentTarget.style.borderColor = GOLD)}
onBlur={e => (e.currentTarget.style.borderColor = active ? NAVY : BORDER)}
>
<option value="">{placeholder}</option>
{options.map(o => <option key={o.value} value={o.value}>{o.label}</option>)}
</select>
<ChevronDown size={16} style={{ position: 'absolute', right: 12, top: '50%', transform: 'translateY(-50%)', pointerEvents: 'none', color: '#101828' }} />
</div>
);
}
// ── SearchControl ────────────────────────────────────────────────────────────
function SearchControl({ value, onChange, placeholder }: { value: string; onChange: (v: string) => void; placeholder: string }) {
return (
<div style={{ position: 'relative', flex: 1, minWidth: 200 }}>
<input
type="text"
value={value}
onChange={e => onChange(e.target.value)}
placeholder={placeholder}
style={{
width: '100%', height: 44, padding: '0 40px 0 14px',
fontFamily: FF, fontSize: 16,
border: `1px solid ${BORDER}`, borderRadius: 0,
background: '#fff', outline: 'none', boxSizing: 'border-box',
transition: 'border-color 0.15s', color: '#101828',
}}
onFocus={e => (e.currentTarget.style.borderColor = GOLD)}
onBlur={e => (e.currentTarget.style.borderColor = BORDER)}
/>
<Search size={16} style={{ position: 'absolute', right: 14, top: '50%', transform: 'translateY(-50%)', pointerEvents: 'none', color: GRAY }} />
</div>
);
}

View File

@@ -524,6 +524,7 @@ const Press: React.FC = () => {
{/* Left col */} {/* Left col */}
<div <div
style={{ style={{
background: '#fff',
padding: isMobile ? '48px 24px' : '88px 80px', padding: isMobile ? '48px 24px' : '88px 80px',
display: 'flex', display: 'flex',
flexDirection: 'column', flexDirection: 'column',

View File

@@ -1,3 +1,5 @@
import { participationAwardsGrid } from './awardArticles'
export const participationContent = { export const participationContent = {
hero: { hero: {
backgroundImageFilename: 'preisuebergabe.jpg', backgroundImageFilename: 'preisuebergabe.jpg',
@@ -74,24 +76,6 @@ export const participationContent = {
nudgeText: 'Alle 4 Punkte erfüllt? Dann jetzt kostenlos bewerben.', nudgeText: 'Alle 4 Punkte erfüllt? Dann jetzt kostenlos bewerben.',
nudgeCta: { label: 'Zum Formular', url: '#bewerben' }, nudgeCta: { label: 'Zum Formular', url: '#bewerben' },
}, },
evaluation: {
imageFilename: 'buehne-gewinner.jpg',
imageAlt: 'Jury-Bewertung BMP',
eyebrow: 'Bewertung',
heading: 'UNSERE\nKRITERIEN.',
body:
'Vier zentrale Säulen, bewertet durch eine vollständig unabhängige Expertenjury aus Wirtschaft, Wissenschaft und Gesellschaft.\n\nDie Kriterien werden nicht gleich gewichtet.',
link: {
label: 'Kriterien & Bewertung',
url: 'https://www.der-bayerische-mittelstandspreis.de/der-preis/kriterien/',
},
criteria: [
{ title: 'Innovations­kraft', desc: 'Zukunftsweisende Produkte, Dienstleistungen oder interne Prozessinnovationen.' },
{ title: 'Nachhaltigkeit', desc: 'Ökologische Verantwortung und Ressourcen-Effizienz im operativen Kern.' },
{ title: 'Unternehmens­kultur', desc: 'Mitarbeiterbindung, Aus- und Weiterbildung sowie wertebasierte Führung.' },
{ title: 'Regionale Wurzeln', desc: 'Engagement am Standort Bayern und Beitrag zur regionalen Wertschöpfung.' },
],
},
applicationWays: { applicationWays: {
eyebrow: 'Bewerbung 2026', eyebrow: 'Bewerbung 2026',
heading: 'HIER KÖNNEN SIE SICH\nBEWERBEN ODER EIN\nUNTERNEHMEN VORSCHLAGEN.', heading: 'HIER KÖNNEN SIE SICH\nBEWERBEN ODER EIN\nUNTERNEHMEN VORSCHLAGEN.',
@@ -165,6 +149,7 @@ export const participationContent = {
{ date: 'Oktober 2026', label: 'Gala-Verleihung' }, { date: 'Oktober 2026', label: 'Gala-Verleihung' },
], ],
}, },
awardsGrid: participationAwardsGrid,
applicationForm: { applicationForm: {
imageFilename: 'preistraeger-jubel.jpg', imageFilename: 'preistraeger-jubel.jpg',
imageAlt: 'Preisverleihung 2025', imageAlt: 'Preisverleihung 2025',

View File

@@ -6,14 +6,6 @@ export const preistraegerIndexContent = {
breadcrumb: { breadcrumb: {
label: 'Preisträger:innen', label: 'Preisträger:innen',
}, },
filters: {
categoryPlaceholder: 'Preiskategorie …',
searchPlaceholder: 'Stichwortsuche …',
storyHeading: 'Diese Erfolgsgeschichten müssen erzählt werden.',
mediaIcon: '💬',
mediaLabel: 'Mit Medienbeiträgen (Videos & Storys)',
resetLabel: 'Filter zurücksetzen',
},
count: { count: {
label: 'Preisträger:innen', label: 'Preisträger:innen',
}, },

File diff suppressed because one or more lines are too long