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
DATABASE_URL=mongodb://127.0.0.1/your-database-name
# SQLite database connection string
DATABASE_URL=file:./content.db
# Or use a PG connection string
#DATABASE_URL=postgresql://127.0.0.1:5432/your-database-name
# VPS production example:
# DATABASE_URL=file:/var/www/bmp-website/shared/content.db
# Used to encrypt JWT tokens
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.
### 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
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:
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.

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: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: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: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",

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: 'dialogLabel', type: 'text', defaultValue: defaultHeaderData.dialogLabel, 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: [
{ label: 'Primary gold button', value: 'primary' },
{ 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: [
{
name: 'socialLinks',
@@ -122,13 +120,6 @@ export const Header: GlobalConfig = {
fields: socialFields,
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 NextApp from '@/spa/NextApp'
import { normalizeApplicationPhaseData, type SpaApplicationPhaseData } from '@/spa/applicationPhase'
import { normalizeFooterData, normalizeHeaderData } from '@/spa/cmsNavigation'
import type { CmsRouteDoc } from '@/spa/cmsRoute'
import { normalizeSiteSettings, type SpaSiteSettingsData } from '@/spa/siteSettings'
@@ -69,16 +70,34 @@ async function findList(payload: Awaited<ReturnType<typeof getPayload>>, collect
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) {
const { slug = [] } = await params
const pathname = `/${slug.join('/')}`.replace(/\/$/, '') || '/'
const payload = await getPayload({ config: configPromise })
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: 'footer', 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),
findHomePage(payload, draft).catch(() => undefined),
findList(payload, 'preistraeger', draft).catch(() => []),
findList(payload, 'events', draft).catch(() => []),
findList(payload, 'posts', draft).catch(() => []),
@@ -92,10 +111,12 @@ export default async function Page({ params }: Args) {
header={normalizeHeaderData(header)}
footer={normalizeFooterData(footer)}
siteSettings={normalizeSiteSettings(siteSettings as Partial<SpaSiteSettingsData> | undefined)}
applicationPhase={normalizeApplicationPhaseData(applicationPhase as Partial<SpaApplicationPhaseData> | undefined)}
cmsRoute={{
collection: route.collection,
doc: route.doc as unknown as CmsRouteDoc,
lists: {
pages: homePage ? [homePage as unknown as CmsRouteDoc] : [],
preistraeger: preistraeger as unknown as CmsRouteDoc[],
events: events as unknown as CmsRouteDoc[],
posts: posts as unknown as CmsRouteDoc[],

View File

@@ -103,7 +103,7 @@ export const homeFields: Field[] = [
name: 'quickCheck',
label: '02 · Schnell-Check eligibility section',
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: [
text('eyebrow', 'Eyebrow', homeContent.quickCheck.eyebrow),
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: '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',
label: '05 · Preisträger card grid',
label: '04 · Preisträger card grid',
type: 'group',
admin: sectionAdmin('Blue card grid with featured winner relationships.'),
fields: [
@@ -225,7 +179,7 @@ export const homeFields: Field[] = [
},
{
name: 'testimonials',
label: '06 · Testimonials carousel',
label: '05 · Testimonials carousel',
type: 'group',
admin: sectionAdmin('Portrait carousel and quote copy.'),
fields: [
@@ -247,9 +201,9 @@ export const homeFields: Field[] = [
},
{
name: 'benefits',
label: '07 · Participation benefits',
label: '06 · Participation benefits',
type: 'group',
admin: sectionAdmin('Cream left panel and networking image.'),
admin: sectionAdmin('White left panel and networking image.'),
fields: [
text('eyebrow', 'Eyebrow', homeContent.benefits.eyebrow),
textarea('heading', 'Heading', homeContent.benefits.heading),
@@ -263,7 +217,7 @@ export const homeFields: Field[] = [
},
{
name: 'application',
label: '08 · Application form section',
label: '07 · Application form section',
type: 'group',
admin: sectionAdmin('Final application pitch, award image, embedded form heading, and footnote.'),
fields: [
@@ -282,7 +236,7 @@ export const homeFields: Field[] = [
},
{
name: 'form',
label: '09 · Embedded application form copy and logic labels',
label: '08 · Embedded application form copy and logic labels',
type: 'group',
admin: sectionAdmin('Text, options, and eligibility thresholds used inside the home page multi-step application form.'),
fields: [
@@ -398,10 +352,28 @@ export const homeFields: Field[] = [
},
{
name: 'videoModal',
label: '10 · Video modal placeholder',
label: '10 · Video modal',
type: 'group',
admin: sectionAdmin('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)],
admin: sectionAdmin('Video and fallback copy shown when the hero video button is clicked.'),
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',
fields: linkGroup(labelDefault, urlDefault),
})
const awardCardDefaults = participationContent.awardsGrid.cards.map(({ imageFilename: _imageFilename, ...card }) => card)
export const participationFields: Field[] = [
{
@@ -153,31 +154,9 @@ export const participationFields: Field[] = [
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',
label: '05 · Application paths',
label: '04 · Application paths',
type: 'group',
admin: sectionAdmin('Cream section explaining proposal, self-application, and special award paths.'),
fields: [
@@ -236,7 +215,7 @@ export const participationFields: Field[] = [
},
{
name: 'datesBanner',
label: '06 · Dates banner',
label: '05 · Dates banner',
type: 'group',
admin: sectionAdmin('Gold important-dates banner. Mobile and desktop labels differ slightly in the current design.'),
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',
label: '07 · Application form wrapper',

View File

@@ -50,7 +50,7 @@ export const preistraegerIndexFields: Field[] = [
name: 'hero',
label: '01 · Hero image',
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: [
uploadField('image', 'Hero image'),
text('imageUrl', 'Fallback image URL', preistraegerIndexContent.hero.imageUrl),
@@ -64,37 +64,23 @@ export const preistraegerIndexFields: Field[] = [
admin: sectionAdmin('Small page label below the image.'),
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',
label: '04 · Count bar',
label: '03 · Count bar',
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)],
},
{
name: 'empty',
label: '05 · Empty state',
label: '04 · Empty state',
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)],
},
{
name: 'card',
label: '06 · Winner cards',
label: '05 · Winner cards',
type: 'group',
admin: sectionAdmin('Hover label shown on winner cards.'),
fields: [
@@ -116,7 +102,7 @@ export const preistraegerIndexFields: Field[] = [
},
{
name: 'cta',
label: '07 · Bottom CTA',
label: '06 · Bottom CTA',
type: 'group',
admin: sectionAdmin('Bottom application and membership CTA.'),
fields: [

View File

@@ -9,6 +9,7 @@ import { imageHero1 } from './image-hero-1'
import { post1 } from './post-1'
import { post2 } from './post-2'
import { post3 } from './post-3'
import { defaultApplicationPhaseData } from '@/spa/applicationPhase'
import { defaultFooterData, defaultHeaderData } from '@/spa/cmsNavigation'
import { defaultSiteSettings } from '@/spa/siteSettings'
@@ -22,7 +23,7 @@ const collections: CollectionSlug[] = [
'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']
@@ -233,6 +234,10 @@ export const seed = async ({
slug: 'site-settings',
data: defaultSiteSettings as never,
}),
payload.updateGlobal({
slug: 'application-phase',
data: defaultApplicationPhaseData as never,
}),
])
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;
footer: Footer;
'site-settings': SiteSetting;
'application-phase': ApplicationPhase;
};
globalsSelect: {
header: HeaderSelect<false> | HeaderSelect<true>;
footer: FooterSelect<false> | FooterSelect<true>;
'site-settings': SiteSettingsSelect<false> | SiteSettingsSelect<true>;
'application-phase': ApplicationPhaseSelect<false> | ApplicationPhaseSelect<true>;
};
locale: null;
widgets: {
@@ -243,7 +245,7 @@ export interface Page {
| 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?: {
eyebrow?: string | null;
@@ -292,43 +294,6 @@ export interface Page {
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.
*/
@@ -375,7 +340,7 @@ export interface Page {
| null;
};
/**
* Cream left panel and networking image.
* White left panel and networking image.
*/
benefits?: {
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?: {
/**
* Choose a video from the media collection. If empty, the frontend shows the fallback copy below.
*/
video?: (number | null) | Media;
title?: string | null;
description?: string | null;
closeLabel?: string | null;
@@ -843,30 +812,6 @@ export interface Page {
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.
*/
@@ -948,6 +893,32 @@ export interface Page {
}[]
| 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.
*/
@@ -1427,7 +1398,7 @@ export interface Page {
*/
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?: {
image?: (number | null) | Media;
@@ -1441,24 +1412,13 @@ export interface Page {
label?: string | null;
};
/**
* Filter placeholders, toggle label, and reset label.
*/
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.
* Label after the selected-year result count.
*/
count?: {
label?: string | null;
};
/**
* Message when filters return no winners.
* Message when the selected year returns no winners.
*/
empty?: {
message?: string | null;
@@ -3396,43 +3356,6 @@ export interface PagesSelect<T extends boolean = true> {
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?:
| T
| {
@@ -3652,6 +3575,7 @@ export interface PagesSelect<T extends boolean = true> {
videoModal?:
| T
| {
video?: T;
title?: T;
description?: T;
closeLabel?: T;
@@ -3851,28 +3775,6 @@ export interface PagesSelect<T extends boolean = true> {
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?:
| T
| {
@@ -3937,6 +3839,35 @@ export interface PagesSelect<T extends boolean = true> {
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?:
| T
| {
@@ -4358,16 +4289,6 @@ export interface PagesSelect<T extends boolean = true> {
| {
label?: T;
};
filters?:
| T
| {
categoryPlaceholder?: T;
searchPlaceholder?: T;
storyHeading?: T;
mediaIcon?: T;
mediaLabel?: T;
resetLabel?: T;
};
count?:
| T
| {
@@ -5802,8 +5723,6 @@ export interface Header {
closeLabel: string;
dialogLabel: string;
overviewLabel: string;
loadingBrand: string;
loadingLabel: string;
navItems?:
| {
/**
@@ -5834,7 +5753,7 @@ export interface Header {
| {
label: string;
path: string;
variant?: ('primary' | 'secondary' | 'upload') | null;
variant?: ('primary' | 'secondary' | 'login') | null;
id?: string | null;
}[]
| null;
@@ -5846,12 +5765,6 @@ export interface Header {
id?: string | null;
}[]
| null;
languages?:
| {
label: string;
id?: string | null;
}[]
| null;
updatedAt?: string | null;
createdAt?: string | null;
}
@@ -5927,6 +5840,47 @@ export interface SiteSetting {
updatedAt?: 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
* via the `definition` "header_select".
@@ -5942,8 +5896,6 @@ export interface HeaderSelect<T extends boolean = true> {
closeLabel?: T;
dialogLabel?: T;
overviewLabel?: T;
loadingBrand?: T;
loadingLabel?: T;
navItems?:
| T
| {
@@ -5983,12 +5935,6 @@ export interface HeaderSelect<T extends boolean = true> {
icon?: T;
id?: T;
};
languages?:
| T
| {
label?: T;
id?: T;
};
updatedAt?: T;
createdAt?: T;
globalType?: T;
@@ -6061,6 +6007,48 @@ export interface SiteSettingsSelect<T extends boolean = true> {
createdAt?: 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
* via the `definition` "collections_widget".

View File

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

View File

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

View File

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

View File

@@ -14,7 +14,7 @@ const orderedScripts = [
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',
},
{
@@ -65,6 +65,10 @@ const orderedScripts = [
label: 'Preload participation page',
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',
path: 'src/scripts/preload-preistraeger-index-page-cms.ts',

View File

@@ -1,10 +1,11 @@
'use client'
import React, { useEffect, useState } from 'react'
import React from 'react'
import Layout from '@/spa/components/layout/Layout'
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 type { SpaSiteSettingsData } from '@/spa/siteSettings'
import About from '@/spa/pages/About'
@@ -60,42 +61,20 @@ export default function NextApp({
footer,
cmsRoute,
siteSettings,
applicationPhase,
}: {
pathname: string
header?: SpaHeaderData
footer?: SpaFooterData
cmsRoute?: CmsRouteData
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))
return (
<>
<CmsRouteProvider value={cmsRoute ? { ...cmsRoute, siteSettings } : undefined}>
<CmsRouteProvider value={cmsRoute ? { ...cmsRoute, siteSettings, applicationPhase } : undefined}>
<ScrollToTop />
<Layout header={header} footer={footer}>
{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',
},
categoryColors: {
Auszeichnung: '#111D55',
Innovation: '#2563EB',
Engagement: '#16A34A',
Wirtschaft: '#9333EA',

View File

@@ -3,7 +3,7 @@ import type { Footer, Header, Media } from '@/payload-types'
export type SpaIconName = 'instagram' | 'linkedin' | 'facebook'
export type SpaLink = { label: string; path: string }
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 SpaHeaderNavItem = SpaLink & {
@@ -23,13 +23,10 @@ export type SpaHeaderData = {
closeLabel: string
dialogLabel: string
overviewLabel: string
loadingBrand: string
loadingLabel: string
navItems: SpaHeaderNavItem[]
secondaryLinks: SpaLink[]
socialLinks: SpaSocialLink[]
ctas: SpaCtaLink[]
languages: string[]
}
export type SpaFooterLinkColumn = { label: string; links: SpaLink[] }
@@ -69,9 +66,6 @@ export const defaultHeaderData: SpaHeaderData = {
closeLabel: 'Menü schließen',
dialogLabel: 'Navigation',
overviewLabel: 'Überblick',
loadingBrand: 'BMP',
loadingLabel: 'Exzellenz wird geladen...',
languages: ['DE', 'EN'],
socialLinks: defaultSocialLinks,
navItems: [
{
@@ -85,7 +79,6 @@ export const defaultHeaderData: SpaHeaderData = {
key: 'teilnahme', label: 'Ihr Weg zum Preis', path: '/teilnahme', subLabel: 'IHR WEG ZUM PREIS',
sub: [
{ label: 'Teilnahmevoraussetzungen', path: '/teilnahme#voraussetzungen' },
{ label: 'Kriterien & Bewertung', path: '/teilnahme#kriterien' },
{ label: 'Fristen & Termine', path: '/teilnahme#fristen' },
{ label: 'Bewerbungsformular', path: '/teilnahme#bewerben' },
],
@@ -124,7 +117,7 @@ export const defaultHeaderData: SpaHeaderData = {
ctas: [
{ label: 'Jetzt bewerben', path: '/teilnahme#bewerben', variant: 'primary' },
{ 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
}
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 = {
alt?: string
@@ -181,13 +192,15 @@ type FooterPartnerLogo = {
}
export function normalizeHeaderData(header?: Partial<Header>): SpaHeaderData {
const navItems = header?.navItems?.length
? (header.navItems as unknown as SpaHeaderNavItem[])
: defaultHeaderData.navItems
return {
...defaultHeaderData,
...header,
logo: mediaToSpa(header?.logo, defaultHeaderData.logo),
navItems: header?.navItems?.length
? (header.navItems as unknown as SpaHeaderNavItem[])
: defaultHeaderData.navItems,
navItems: withoutParticipationCriteriaLink(navItems),
secondaryLinks: header?.secondaryLinks?.length
? (header.secondaryLinks as unknown as SpaLink[])
: defaultHeaderData.secondaryLinks,
@@ -195,11 +208,6 @@ export function normalizeHeaderData(header?: Partial<Header>): SpaHeaderData {
? (header.socialLinks as unknown as SpaSocialLink[])
: defaultHeaderData.socialLinks,
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 type { SpaApplicationPhaseData } from '@/spa/applicationPhase'
import type { SpaSiteSettingsData } from '@/spa/siteSettings'
export type CmsRouteCollection = 'pages' | 'posts' | 'events' | 'preistraeger'
@@ -24,6 +25,7 @@ export type CmsRouteData = {
doc: CmsRouteDoc
lists?: Partial<Record<CmsRouteCollection, CmsRouteDoc[]>>
siteSettings?: SpaSiteSettingsData
applicationPhase?: SpaApplicationPhaseData
}
const CmsRouteContext = createContext<CmsRouteData | undefined>(undefined)
@@ -39,3 +41,7 @@ export function useCmsRoute() {
export function useCmsCollection(collection: CmsRouteCollection) {
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 { Link, useLocation } from '@/spa/router';
import { ArrowRight, Upload } from 'lucide-react';
import { ArrowRight } from 'lucide-react';
import LoadingScreen from '@/spa/components/ui/LoadingScreen';
import { useIsMobile } from '@/spa/hooks/useIsMobile';
import {
@@ -36,6 +36,10 @@ const SEP = 'rgba(239,191,4,0.2)';
const LINE = 'rgba(239,191,4,0.35)';
const FF_DISPLAY = '"IBM Plex Sans", 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 ─────────────────────────────────── */
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 NAV = header.navItems;
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 footerLogo = footer.logo?.url || defaultFooterData.logo?.url || '';
const headerCtas = header.ctas;
const footerCtas = footer.ctas;
const fallbackHeaderCtas = defaultHeaderData.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 [activeKey, setActive] = useState<string | null>(null); // desktop hover subnav
const [mobileOpen, setMobileOpen] = useState(false); // mobile sidebar overlay
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 [showLoader, setShowLoader] = useState(() => {
if (typeof window === 'undefined') return false;
sessionStorage.removeItem('bmp-intro-seen');
return true;
});
const [showLoader, setShowLoader] = useState(true);
const [formVisible, setFormVisible] = useState(false);
const location = useLocation();
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>
</div>
{/* RIGHT – social + lang */}
<div style={{ flex:1, display:'flex', alignItems:'center', justifyContent:'flex-end', gap:20, padding:'0 28px', height:'100%' }}>
{/* RIGHT – social */}
<div style={{ flex:1, display:'flex', alignItems:'center', justifyContent:'flex-end', padding:'0 28px', height:'100%' }}>
<div style={{ display:'flex', gap:14, alignItems:'center' }}>
{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>
))}
{HEADER_SOCIAL.map(s => <SocialLink key={s.label} {...s} />)}
</div>
</div>
@@ -326,7 +321,7 @@ const Layout: React.FC<LayoutProps> = ({ children, header = defaultHeaderData, f
transition:'opacity .2s ease, transform .2s ease',
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}
</div>
<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={{ 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 }}>
{SOCIAL.map(s => <SocialLink key={s.label} {...s} />)}
{HEADER_SOCIAL.map(s => <SocialLink key={s.label} {...s} />)}
</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)'; }}
onMouseLeave={e=>{ e.currentTarget.style.background='#EFBF04'; e.currentTarget.style.boxShadow='none'; }}
>{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' }} />
<Link to={headerCtas[2]?.path || fallbackHeaderCtas[2].path} onClick={() => setOpen(false)}
style={{
display:'flex', alignItems:'center', gap:10,
background:'rgba(255,255,255,0.04)',
color:'rgba(255,255,255,0.45)',
background:'transparent',
color:MUTED,
fontSize:11, fontWeight:700,
textTransform:'uppercase', letterSpacing:'.14em',
padding:'10px 18px',
textDecoration:'none',
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',
}}
onMouseEnter={e=>{
e.currentTarget.style.color='#fff';
e.currentTarget.style.borderColor='rgba(255,255,255,0.4)';
e.currentTarget.style.background='rgba(255,255,255,0.07)';
e.currentTarget.style.color=GOLD;
e.currentTarget.style.borderColor='rgba(239,191,4,0.7)';
e.currentTarget.style.background='rgba(239,191,4,0.05)';
}}
onMouseLeave={e=>{
e.currentTarget.style.color='rgba(255,255,255,0.45)';
e.currentTarget.style.borderColor='rgba(255,255,255,0.15)';
e.currentTarget.style.background='rgba(255,255,255,0.04)';
e.currentTarget.style.color=MUTED;
e.currentTarget.style.borderColor='rgba(239,191,4,0.28)';
e.currentTarget.style.background='transparent';
}}
>
<Upload size={12} strokeWidth={2} /> {headerCtas[2]?.label || fallbackHeaderCtas[2].label}
{headerCtas[2]?.label || fallbackHeaderCtas[2].label}
</Link>
</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)}
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>
<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)}
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)' }}
><Upload size={13} strokeWidth={2} /> {headerCtas[2]?.label || fallbackHeaderCtas[2].label}</Link>
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)' }}
>{headerCtas[2]?.label || fallbackHeaderCtas[2].label}</Link>
</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 }}>
{/* Lang + social row */}
<div style={{ display:'flex', alignItems:'center', justifyContent:'space-between' }}>
<div style={{ display:'flex', alignItems:'center', gap:10 }}>
{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>
{/* Social row */}
<div style={{ display:'flex', gap:18 }}>
{SOCIAL.map(s => <SocialLink key={s.label} {...s} />)}
</div>
{HEADER_SOCIAL.map(s => <SocialLink key={s.label} {...s} />)}
</div>
{/* Secondary links */}
<div style={{ display:'flex', flexWrap:'wrap', gap:'0 20px' }}>
@@ -770,6 +733,7 @@ const Layout: React.FC<LayoutProps> = ({ children, header = defaultHeaderData, f
}}
>{footerCtas[0]?.label || fallbackFooterCtas[0].label}</Link>
{showFooterMembershipCta && (
<Link
to={footerCtas[1]?.path || fallbackFooterCtas[1].path}
style={{
@@ -795,6 +759,7 @@ const Layout: React.FC<LayoutProps> = ({ children, header = defaultHeaderData, f
e.currentTarget.style.color = MUTED;
}}
>{footerCtas[1]?.label || fallbackFooterCtas[1].label}</Link>
)}
<Link
to={footerCtas[2]?.path || fallbackFooterCtas[2].path}
@@ -937,7 +902,7 @@ const Layout: React.FC<LayoutProps> = ({ children, header = defaultHeaderData, f
{footer.copyright}
</p>
<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>
</footer>

View File

@@ -9,7 +9,7 @@ import { Link } from '@/spa/router'
const ANTHRA = '#1A2030'
const NAVY = '#111D55'
const GOLD = '#EFBF04'
const CREAM = '#EFE5E3'
const PAGE_BG = '#fff'
const FF = '"IBM Plex Sans", sans-serif'
const FB = '"Inter", sans-serif'
@@ -51,7 +51,7 @@ export default function LegalPage({ content, cmsKey }: { content: LegalPageConte
}))
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' }}>
<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

View File

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

View File

@@ -16,6 +16,7 @@ const FB = '"Inter", sans-serif'
const NAVY = '#111D55'
const GOLD = '#EFBF04'
const CREAM = '#E4E2E3'
const HIDE_MEMBERSHIP_ABOUT_CTA = true
type AboutCms = Partial<typeof aboutContent> & {
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 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 }) {
return (
<>
@@ -117,6 +124,7 @@ const About: React.FC = () => {
const goals = { ...aboutContent.goals, ...(cms.goals || {}) }
const values = { ...aboutContent.values, ...(cms.values || {}) }
const cta = { ...aboutContent.cta, ...(cms.cta || {}) }
const showSecondaryCta = !HIDE_MEMBERSHIP_ABOUT_CTA || !isMembershipCta(cta.secondaryCta)
const highlightFallbackImage = mediaUrl(
highlights.fallbackImage,
`/images/${aboutContent.highlights.fallbackImageFilename}`,
@@ -359,7 +367,7 @@ const About: React.FC = () => {
</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>
<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)} />
@@ -423,6 +431,7 @@ const About: React.FC = () => {
>
{fallbackText(cta.primaryCta?.label, aboutContent.cta.primaryCta.label)} <ChevronRight size={16} />
</Link>
{showSecondaryCta && (
<div style={{ marginTop: 16, display: 'flex', justifyContent: 'center' }}>
<span style={{ fontFamily: FF, fontSize: 11, color: 'rgba(255,255,255,0.3)', marginRight: 8 }}>{fallbackText(cta.secondaryPrefix, aboutContent.cta.secondaryPrefix)}</span>
<Link
@@ -434,6 +443,7 @@ const About: React.FC = () => {
{fallbackText(cta.secondaryCta?.label, aboutContent.cta.secondaryCta.label)} <ChevronRight size={10} />
</Link>
</div>
)}
</div>
</section>
</div>

View File

@@ -11,7 +11,7 @@ import Image from '@/spa/components/ui/UnoptimizedImage'
const NAVY = '#111D55';
const GOLD = '#EFBF04';
const CREAM = '#EFE5E3';
const PAGE_BG = '#fff';
const INK = '#3A3A3A';
const FF = '"IBM Plex Sans", sans-serif';
const FB = '"Inter", sans-serif';
@@ -81,10 +81,21 @@ export default function BlogDetail() {
const metaCopy = detail.metaCopy || blogDetailContent.meta;
const sidebarCta = detail.sidebarCta || blogDetailContent.sidebarCta;
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) {
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>
<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)}
@@ -177,7 +188,7 @@ export default function BlogDetail() {
</section>
{/* ── 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 }}>
{/* 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 }}>
{fallbackText(sidebarCta.text, blogDetailContent.sidebarCta.text)}
</p>
{sidebarCtaIsExternal ? (
<a
href={sidebarCtaUrl}
style={sidebarCtaStyle}
onMouseEnter={e => (e.currentTarget.style.background = GOLD)}
onMouseLeave={e => (e.currentTarget.style.background = NAVY)}
>
{fallbackText(sidebarCta.label, blogDetailContent.sidebarCta.label)} <ArrowRight size={12} />
</a>
) : (
<Link
to={fallbackText(sidebarCta.url, blogDetailContent.sidebarCta.url)}
style={{
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',
}}
to={sidebarCtaUrl}
style={sidebarCtaStyle}
onMouseEnter={e => (e.currentTarget.style.background = GOLD)}
onMouseLeave={e => (e.currentTarget.style.background = NAVY)}
>
{fallbackText(sidebarCta.label, blogDetailContent.sidebarCta.label)} <ArrowRight size={12} />
</Link>
)}
</div>
</div>
</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 { Play, ChevronRight, X, Star } from 'lucide-react';
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 { useIsMobile } from '@/spa/hooks/useIsMobile';
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 { homeContent } from '@/spa/homeContent'
const FF = '"IBM Plex Sans", sans-serif';
type CmsRecord = Record<string, unknown>;
type HomeCms = Record<string, any>;
type StatusPhase = HomeCms & {
cta?: { label?: string; to?: string; url?: string } | null;
membershipCta?: { label?: string; to?: string; url?: string } | null;
type CmsLink = { label?: string | null; to?: string | null; url?: string | null };
type HomeHeroCms = {
backgroundImage?: unknown;
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 & {
id?: string | number;
@@ -41,6 +110,13 @@ const fallbackText = (value: unknown, fallback: string) =>
const fallbackArray = <T,>(value: unknown, fallback: readonly T[]) =>
Array.isArray(value) && value.length > 0 ? (value as T[]) : fallback;
const 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 }) {
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 [showVideo, setShowVideo] = useState(false);
const videoRef = useRef<HTMLVideoElement>(null);
const isMobile = useIsMobile();
const home = (useCmsRoute()?.doc?.home || {}) as HomeCms;
const hero = home.hero || {};
@@ -118,8 +195,14 @@ const Home: React.FC = () => {
const application = home.application || {};
const form = home.form || {};
const videoModal = home.videoModal || {};
const applicationPhase = useApplicationPhase();
const cmsPreistraeger = useCmsCollection('preistraeger');
const selectedWinners = fallbackArray(winnersSection.featured, []);
const heroSecondaryCta = {
label: fallbackText(hero.secondaryCta?.label, homeContent.hero.secondaryCta.label),
url: fallbackText(hero.secondaryCta?.url, homeContent.hero.secondaryCta.url),
};
const showHeroSecondaryCta = !HIDE_MEMBERSHIP_HOME_HERO_CTA || !isMembershipCta(heroSecondaryCta);
const selectedWinnerDocs = selectedWinners
.map((selected) => {
const selectedRecord = selected as { id?: string | number }
@@ -136,6 +219,16 @@ const Home: React.FC = () => {
const benefitItems = fallbackArray(benefits.items, homeContent.benefits.items);
const applicationBenefits = fallbackArray(application.benefits, homeContent.application.benefits);
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 (
<div className="animate-fade-in">
@@ -177,15 +270,18 @@ const Home: React.FC = () => {
{fallbackText(hero.primaryCta?.label, homeContent.hero.primaryCta.label)} <ChevronRight size={16} />
</Link>
<div style={{ display: 'flex', flexDirection: isMobile ? 'column' : 'row', gap: 16, alignItems: isMobile ? 'stretch' : 'center' }}>
{showHeroSecondaryCta && (
<Link
to={fallbackText(hero.secondaryCta?.url, homeContent.hero.secondaryCta.url)}
to={heroSecondaryCta.url}
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' }}
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)}
{heroSecondaryCta.label}
</Link>
)}
<button
type="button"
onClick={() => setShowVideo(true)}
className="flex items-center gap-4 text-white hover:text-accent transition-colors group"
style={{ justifyContent: isMobile ? 'center' : 'flex-start' }}
@@ -221,7 +317,7 @@ const Home: React.FC = () => {
<MunichSkylineBg />
<div style={{ display: 'grid', gridTemplateColumns: isMobile ? '1fr' : '55% 45%', position: 'relative' }}>
{/* 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) */}
{!isMobile && (
<>
@@ -272,7 +368,7 @@ const Home: React.FC = () => {
{/* Overlay: mobile = navy shadow bottom-left (for the heading); desktop = leftward fade into text bg */}
<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 left, transparent 40%, #E4E2E3 100%)' }} />
: 'linear-gradient(to left, transparent 40%, #fff 100%)' }} />
{/* Heading overlay – mobile only */}
{isMobile && (
<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>
{/* Status Phase Slider */}
<StatusSlider data={home.status} />
<StatusSlider data={applicationPhase} />
{/* Winners Grid – neue Preisträger-Übersicht */}
<section style={{ background: '#111D55' }}>
@@ -419,8 +515,8 @@ const Home: React.FC = () => {
<MunichSkylineBg />
<div style={{ display: 'grid', gridTemplateColumns: isMobile ? '1fr' : '55% 45%', minHeight: isMobile ? 'auto' : 560 }}>
{/* Left – text on cream */}
<div style={{ background: '#E4E2E3', padding: isMobile ? '32px 24px 44px' : '80px 72px', display: 'flex', flexDirection: 'column', justifyContent: 'center', order: isMobile ? 2 : 0 }}>
{/* Left – text on white */}
<div style={{ background: '#fff', padding: isMobile ? '32px 24px 44px' : '80px 72px', display: 'flex', flexDirection: 'column', justifyContent: 'center', order: isMobile ? 2 : 0 }}>
{!isMobile && (
<>
<span style={{ fontFamily: FF, fontSize: 10, color: '#4A8FC9', textTransform: 'uppercase', letterSpacing: '0.3em', fontWeight: 700, display: 'block', marginBottom: 16 }}>{fallbackText(benefits.eyebrow, homeContent.benefits.eyebrow)}</span>
@@ -436,7 +532,7 @@ const Home: React.FC = () => {
<div
key={i}
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')}
>
<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
? '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) */}
{isMobile && (
<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>
{/* 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 */}
<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' }}>
@@ -570,18 +666,32 @@ const Home: React.FC = () => {
{showVideo && (
<div className="fixed inset-0 z-[200] bg-black flex items-center justify-center p-6 bg-opacity-95">
<button
type="button"
onClick={() => setShowVideo(false)}
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} />
</button>
<div className="w-full max-w-6xl aspect-video bg-white/5 rounded-3xl overflow-hidden flex items-center justify-center relative">
{videoSrc ? (
<video
ref={videoRef}
src={videoSrc}
className="h-full w-full bg-black object-contain"
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">{fallbackText(videoModal.title, homeContent.videoModal.title)}</p>
<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 onClick={() => setShowVideo(false)} className="mt-8" variant="outline">{fallbackText(videoModal.closeLabel, homeContent.videoModal.closeLabel)}</Button>
<Button type="button" onClick={() => setShowVideo(false)} className="mt-8" variant="outline">{fallbackText(videoModal.closeLabel, homeContent.videoModal.closeLabel)}</Button>
</div>
)}
</div>
</div>
)}
@@ -591,26 +701,28 @@ const Home: React.FC = () => {
// ─── StatusSlider ─────────────────────────────────────────────────────────────
function StatusSlider({ data }: { data?: HomeCms }) {
function StatusSlider({ data }: { data?: SpaApplicationPhaseData }) {
const isMobile = useIsMobile();
const phases = fallbackArray(data?.phases, homeContent.status.phases).map((phase, i) => {
const phaseRecord = phase as StatusPhase
const fallbackPhase = homeContent.status.phases[i]
const fallbackCta = fallbackPhase?.cta?.label
? { label: fallbackPhase.cta.label, to: fallbackPhase.cta.url }
if (!data?.phases?.length) return null;
const phases = data.phases.map((phase): RenderedStatusPhase => {
const cta = phase.cta?.label
? { label: phase.cta.label, to: phase.cta.to || phase.cta.url || '/' }
: null
const cta = phaseRecord?.cta?.label
? { label: phaseRecord.cta.label, to: phaseRecord.cta.to || phaseRecord.cta.url || fallbackPhase?.cta?.url || '/' }
: fallbackCta
const membershipCta = phaseRecord?.membershipCta?.label
? { label: phaseRecord.membershipCta.label, to: phaseRecord.membershipCta.to || phaseRecord.membershipCta.url || homeContent.hero.secondaryCta.url }
const membershipCta = phase.membershipCta?.label
? { label: phase.membershipCta.label, to: phase.membershipCta.to || phase.membershipCta.url || '/mitglied-werden' }
: 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 = data?.activePhase !== undefined && data?.activePhase !== null ? Number(data.activePhase) : fallbackActivePhase;
const active = Math.max(0, Math.min(phases.length - 1, Number.isFinite(configuredActive) ? configuredActive : fallbackActivePhase));
const configuredActive = Number(data.activePhase);
const active = Math.max(0, Math.min(phases.length - 1, Number.isFinite(configuredActive) ? configuredActive : 0));
const phase = phases[active];
return (
@@ -625,9 +737,9 @@ function StatusSlider({ data }: { data?: HomeCms }) {
<StatusSlideCard
phase={phase}
isMobile={isMobile}
noActionLabel={fallbackText(data?.noActionLabel, homeContent.status.noActionLabel)}
successApplicationsLabel={fallbackText(data?.successApplicationsLabel, homeContent.status.successApplicationsLabel)}
successWinnersLabel={fallbackText(data?.successWinnersLabel, homeContent.status.successWinnersLabel)}
noActionLabel={data.noActionLabel}
successApplicationsLabel={data.successApplicationsLabel}
successWinnersLabel={data.successWinnersLabel}
/>
</section>
);
@@ -642,7 +754,7 @@ function StatusSlideCard({
successApplicationsLabel,
successWinnersLabel,
}: {
phase: StatusPhase & HomeCms;
phase: RenderedStatusPhase;
isMobile: boolean;
noActionLabel: string;
successApplicationsLabel: string;
@@ -722,10 +834,10 @@ function StatusSlideCard({
) : (
/* Success stats */
<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={{ 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>
)}
@@ -735,7 +847,7 @@ function StatusSlideCard({
<div style={{ textAlign: 'center' }}>
<div style={{ width: 1, height: 20, background: 'rgba(255,255,255,0.08)', margin: '0 auto 20px' }} />
<Link
to={fallbackText(phase.membershipCta?.to || phase.membershipCta?.url, homeContent.hero.secondaryCta.url)}
to={phase.membershipCta?.to || '/mitglied-werden'}
onMouseDown={e => e.stopPropagation()}
style={{
fontFamily: FF,
@@ -760,7 +872,7 @@ function StatusSlideCard({
(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>
</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 { 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 MunichSkylineBg from '@/spa/components/ui/munich-skyline-bg';
import { useIsMobile } from '@/spa/hooks/useIsMobile';
import { useCmsRoute } from '@/spa/cmsRoute';
import { useCmsCollection, useCmsRoute } from '@/spa/cmsRoute';
import { mediaAlt, mediaUrl } from '@/spa/cmsMediaField';
import { homeContent } from '@/spa/homeContent';
import { netzwerkContent } from '@/spa/netzwerkContent';
import Image from '@/spa/components/ui/UnoptimizedImage'
@@ -14,6 +15,7 @@ const FB = '"Inter", sans-serif';
const NAVY = '#111D55';
const GOLD = '#EFBF04';
const CREAM = '#E4E2E3';
const HIDE_MEMBERSHIP_NETWORK_CTA = true;
const ROLE_COLOR: Record<string, string> = {
'Hauptsponsor': '#EFBF04',
@@ -28,6 +30,18 @@ type NetzwerkCms = Partial<typeof netzwerkContent> & {
sponsoring?: Partial<typeof netzwerkContent.sponsoring> & { image?: unknown }
}
type HomeVideoCms = {
hero?: {
videoLabel?: string | null
}
videoModal?: {
video?: unknown
title?: string | null
description?: string | null
closeLabel?: string | null
}
}
type PatronPerson = (typeof netzwerkContent.patronage.people)[number] & { imageUpload?: unknown }
type JuryMember = (typeof netzwerkContent.jury.members)[number] & { imageUpload?: unknown }
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 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 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 }) {
return (
@@ -443,7 +462,12 @@ function PartnerModal({ partner, copy, onClose }: { partner: Partner; copy: Part
const Netzwerk: React.FC = () => {
const isMobile = useIsMobile();
const [selectedPartner, setSelectedPartner] = useState<Partner | null>(null);
const [showGreetingVideo, setShowGreetingVideo] = useState(false);
const greetingVideoRef = useRef<HTMLVideoElement>(null);
const cms = (useCmsRoute()?.doc?.netzwerk || {}) as NetzwerkCms;
const homePage = useCmsCollection('pages').find((page) => page.spaPath === '/' || page.slug === 'startseite' || page.slug === 'home');
const home = (homePage?.home || {}) as HomeVideoCms;
const homeVideoModal = home.videoModal || {};
const hero = { ...netzwerkContent.hero, ...(cms.hero || {}) };
const patronage = { ...netzwerkContent.patronage, ...(cms.patronage || {}) };
const juryIntro = { ...netzwerkContent.juryIntro, ...(cms.juryIntro || {}) };
@@ -465,6 +489,21 @@ const Netzwerk: React.FC = () => {
const mobileStats = fallbackArray<StatItem>(juryIntro.stats, netzwerkContent.juryIntro.stats);
const desktopStats = fallbackArray<StatItem>(juryIntro.desktopStats, netzwerkContent.juryIntro.desktopStats);
const sponsorBenefits = fallbackArray<BenefitItem>(sponsoring.benefits, netzwerkContent.sponsoring.benefits);
const greetingVideoSrc = mediaUrl(homeVideoModal.video, '');
const greetingVideoTitle = fallbackText(homeVideoModal.title, homeContent.videoModal.title);
const greetingVideoLabel = fallbackText(home.hero?.videoLabel, homeContent.hero.videoLabel);
const greetingVideoCloseLabel = fallbackText(homeVideoModal.closeLabel, homeContent.videoModal.closeLabel);
const 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 (
<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 }}>
{fallbackText(patronage.greetingAttribution, netzwerkContent.patronage.greetingAttribution)}
</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>
@@ -757,6 +837,7 @@ const Netzwerk: React.FC = () => {
</div>
</div>
{/* Footnote */}
{showMembershipFootnote && (
<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 }}>
<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>
<Link
@@ -788,6 +869,7 @@ const Netzwerk: React.FC = () => {
{fallbackText(sponsoring.footnoteLabel, netzwerkContent.sponsoring.footnoteLabel)} <ChevronRight size={11} />
</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 }} />
</section>
@@ -797,6 +879,68 @@ const Netzwerk: React.FC = () => {
<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>
);
};

View File

@@ -1,5 +1,5 @@
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 BewerbungsForm from '@/spa/components/forms/BewerbungsForm';
import WegZurAuszeichnungSection from '@/spa/components/WegZurAuszeichnungSection';
@@ -18,10 +18,13 @@ const CREAM = '#E4E2E3';
type ParticipationCms = Partial<typeof participationContent> & {
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 };
};
type AwardCard = (typeof participationContent.awardsGrid.cards)[number];
type AwardCardCms = Partial<AwardCard> & { image?: unknown };
const ICONS = {
building2: Building2,
mapPin: MapPin,
@@ -38,6 +41,11 @@ const fallbackText = (value: unknown, fallback: string) =>
const fallbackArray = <T,>(value: unknown, fallback: T[]) =>
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 }) {
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 process = { ...participationContent.process, ...(cms.process || {}) };
const eligibility = { ...participationContent.eligibility, ...(cms.eligibility || {}) };
const evaluation = { ...participationContent.evaluation, ...(cms.evaluation || {}) };
const applicationWays = { ...participationContent.applicationWays, ...(cms.applicationWays || {}) };
const datesBanner = { ...participationContent.datesBanner, ...(cms.datesBanner || {}) };
const awardsGrid = { ...participationContent.awardsGrid, ...(cms.awardsGrid || {}) };
const applicationForm = { ...participationContent.applicationForm, ...(cms.applicationForm || {}) };
const form = { ...participationContent.form, ...(cms.form || {}) };
const eligibilityCriteria = fallbackArray(eligibility.criteria, participationContent.eligibility.criteria);
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 desktopDates = fallbackArray(datesBanner.desktopItems, participationContent.datesBanner.desktopItems);
const applicationFacts = fallbackArray(applicationForm.facts, participationContent.applicationForm.facts);
@@ -174,75 +181,6 @@ const Participation: React.FC = () => {
</div>
</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 ───────────────────────────────────────────────────── */}
<BewerbungswegeSection content={applicationWays} />
@@ -282,6 +220,9 @@ const Participation: React.FC = () => {
)}
</section>
{/* ── AWARDS GRID ─────────────────────────────────────────────────────── */}
<AwardsGridSection content={awardsGrid} />
{/* ── FORM SECTION ─────────────────────────────────────────────────────── */}
<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' }}>
@@ -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 ─────────────────────────────────────────────────────
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;

View File

@@ -1,6 +1,6 @@
import React, { useState, useMemo } from 'react';
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 { useCmsCollection, useCmsRoute, type CmsRouteDoc } from '@/spa/cmsRoute';
import { docImageUrl, mediaAlt, mediaUrl } from '@/spa/cmsMediaField';
@@ -47,14 +47,10 @@ const normalizeWinner = (
export default function Preistraeger() {
const isMobile = useIsMobile();
const [filterCategory, setFilterCategory] = useState('');
const [search, setSearch] = useState('');
const [mediaOnly, setMediaOnly] = useState(false);
const cms = (useCmsRoute()?.doc?.preistraegerIndex || {}) as PreistraegerIndexCms;
const cmsPreistraeger = useCmsCollection('preistraeger');
const hero = { ...preistraegerIndexContent.hero, ...(cms.hero || {}) };
const breadcrumb = { ...preistraegerIndexContent.breadcrumb, ...(cms.breadcrumb || {}) };
const filters = { ...preistraegerIndexContent.filters, ...(cms.filters || {}) };
const count = { ...preistraegerIndexContent.count, ...(cms.count || {}) };
const empty = { ...preistraegerIndexContent.empty, ...(cms.empty || {}) };
const card = useMemo(() => ({ ...preistraegerIndexContent.card, ...(cms.card || {}) }), [cms.card]);
@@ -69,20 +65,10 @@ export default function Preistraeger() {
return WINNERS;
}, [card, cardFallbackImage, cmsPreistraeger]);
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 selectedYear = activeYear ?? years[0] ?? new Date().getFullYear();
const filtered = useMemo(() => winners.filter(w => {
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);
const filtered = useMemo(() => winners.filter(w => w.year === selectedYear), [selectedYear, winners]);
return (
<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>
</div>
{/* Filter Section */}
<div style={{ background: '#fff', padding: isMobile ? '32px 24px 24px' : '48px 80px 32px' }}>
{/* Year Filter Section */}
<div style={{ background: '#fff', padding: isMobile ? '32px 24px 20px' : '48px 80px 24px' }}>
{/* #93: Headline entfernt – Seitenname steht bereits oben im Breadcrumb. */}
{/* 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 => (
<button
key={year}
onClick={() => { setActiveYear(year); reset(); }}
onClick={() => setActiveYear(year)}
style={{
fontFamily: FF, fontSize: 16, fontWeight: 700,
padding: isMobile ? '12px 20px' : '12px 32px',
@@ -128,54 +114,6 @@ export default function Preistraeger() {
</button>
))}
</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>
{/* Count bar */}
@@ -319,61 +257,3 @@ function WinnerCard({ winner, hoverLabel }: { winner: Winner; hoverLabel: string
</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 */}
<div
style={{
background: '#fff',
padding: isMobile ? '48px 24px' : '88px 80px',
display: 'flex',
flexDirection: 'column',

View File

@@ -1,3 +1,5 @@
import { participationAwardsGrid } from './awardArticles'
export const participationContent = {
hero: {
backgroundImageFilename: 'preisuebergabe.jpg',
@@ -74,24 +76,6 @@ export const participationContent = {
nudgeText: 'Alle 4 Punkte erfüllt? Dann jetzt kostenlos 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: {
eyebrow: 'Bewerbung 2026',
heading: 'HIER KÖNNEN SIE SICH\nBEWERBEN ODER EIN\nUNTERNEHMEN VORSCHLAGEN.',
@@ -165,6 +149,7 @@ export const participationContent = {
{ date: 'Oktober 2026', label: 'Gala-Verleihung' },
],
},
awardsGrid: participationAwardsGrid,
applicationForm: {
imageFilename: 'preistraeger-jubel.jpg',
imageAlt: 'Preisverleihung 2025',

View File

@@ -6,14 +6,6 @@ export const preistraegerIndexContent = {
breadcrumb: {
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: {
label: 'Preisträger:innen',
},

File diff suppressed because one or more lines are too long