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:
@@ -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
|
||||
|
||||
71
Dockerfile
71
Dockerfile
@@ -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
|
||||
13
README.md
13
README.md
@@ -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.
|
||||
|
||||
|
||||
@@ -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
218
docs/vps-deployment.md
Normal 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
|
||||
```
|
||||
@@ -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",
|
||||
|
||||
BIN
public/images/goldene-bavaria.png
Normal file
BIN
public/images/goldene-bavaria.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 4.8 MiB |
BIN
public/images/prof-roland-berger.jpg
Normal file
BIN
public/images/prof-roland-berger.jpg
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 14 KiB |
81
src/ApplicationPhase/config.ts
Normal file
81
src/ApplicationPhase/config.ts
Normal 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),
|
||||
],
|
||||
}
|
||||
@@ -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,
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
|
||||
@@ -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[],
|
||||
|
||||
@@ -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),
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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: [
|
||||
|
||||
@@ -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!')
|
||||
|
||||
33136
src/migrations/20260630_133242.json
Normal file
33136
src/migrations/20260630_133242.json
Normal file
File diff suppressed because one or more lines are too long
17
src/migrations/20260630_133242.ts
Normal file
17
src/migrations/20260630_133242.ts
Normal 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\`;`)
|
||||
}
|
||||
33485
src/migrations/20260630_165002_participation_awards_grid.json
Normal file
33485
src/migrations/20260630_165002_participation_awards_grid.json
Normal file
File diff suppressed because one or more lines are too long
326
src/migrations/20260630_165002_participation_awards_grid.ts
Normal file
326
src/migrations/20260630_165002_participation_awards_grid.ts
Normal 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
15
src/migrations/index.ts
Normal 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'
|
||||
},
|
||||
];
|
||||
@@ -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".
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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.')
|
||||
|
||||
@@ -84,7 +84,6 @@ async function main() {
|
||||
...introContent,
|
||||
image: introImage,
|
||||
},
|
||||
status: homeContent.status,
|
||||
winners: {
|
||||
...winnersContent,
|
||||
fallbackImage: fallbackWinnerImage,
|
||||
|
||||
218
src/scripts/preload-participation-awards-cms.ts
Normal file
218
src/scripts/preload-participation-awards-cms.ts
Normal 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)
|
||||
})
|
||||
@@ -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,
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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}
|
||||
|
||||
97
src/spa/applicationPhase.ts
Normal file
97
src/spa/applicationPhase.ts
Normal 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
186
src/spa/awardArticles.ts
Normal 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,
|
||||
})),
|
||||
}
|
||||
@@ -20,6 +20,7 @@ export const blogDetailContent = {
|
||||
tag: 'Presse',
|
||||
},
|
||||
categoryColors: {
|
||||
Auszeichnung: '#111D55',
|
||||
Innovation: '#2563EB',
|
||||
Engagement: '#16A34A',
|
||||
Wirtschaft: '#9333EA',
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
<div style={{ display:'flex', gap:18 }}>
|
||||
{SOCIAL.map(s => <SocialLink key={s.label} {...s} />)}
|
||||
</div>
|
||||
{/* Social row */}
|
||||
<div style={{ display:'flex', gap:18 }}>
|
||||
{HEADER_SOCIAL.map(s => <SocialLink key={s.label} {...s} />)}
|
||||
</div>
|
||||
{/* Secondary links */}
|
||||
<div style={{ display:'flex', flexWrap:'wrap', gap:'0 20px' }}>
|
||||
@@ -770,31 +733,33 @@ const Layout: React.FC<LayoutProps> = ({ children, header = defaultHeaderData, f
|
||||
}}
|
||||
>{footerCtas[0]?.label || fallbackFooterCtas[0].label}</Link>
|
||||
|
||||
<Link
|
||||
to={footerCtas[1]?.path || fallbackFooterCtas[1].path}
|
||||
style={{
|
||||
fontFamily: FF_DISPLAY,
|
||||
fontSize: 16,
|
||||
fontWeight: 700,
|
||||
textTransform: 'uppercase',
|
||||
letterSpacing: '0.12em',
|
||||
color: MUTED,
|
||||
border: '1.5px solid rgba(239,191,4,0.35)',
|
||||
padding: '8px 22px',
|
||||
textDecoration: 'none',
|
||||
display: 'inline-block',
|
||||
borderRadius: 2,
|
||||
transition: 'border-color 0.15s, color 0.15s',
|
||||
}}
|
||||
onMouseEnter={e => {
|
||||
e.currentTarget.style.borderColor = GOLD;
|
||||
e.currentTarget.style.color = GOLD;
|
||||
}}
|
||||
onMouseLeave={e => {
|
||||
e.currentTarget.style.borderColor = 'rgba(239,191,4,0.35)';
|
||||
e.currentTarget.style.color = MUTED;
|
||||
}}
|
||||
>{footerCtas[1]?.label || fallbackFooterCtas[1].label}</Link>
|
||||
{showFooterMembershipCta && (
|
||||
<Link
|
||||
to={footerCtas[1]?.path || fallbackFooterCtas[1].path}
|
||||
style={{
|
||||
fontFamily: FF_DISPLAY,
|
||||
fontSize: 16,
|
||||
fontWeight: 700,
|
||||
textTransform: 'uppercase',
|
||||
letterSpacing: '0.12em',
|
||||
color: MUTED,
|
||||
border: '1.5px solid rgba(239,191,4,0.35)',
|
||||
padding: '8px 22px',
|
||||
textDecoration: 'none',
|
||||
display: 'inline-block',
|
||||
borderRadius: 2,
|
||||
transition: 'border-color 0.15s, color 0.15s',
|
||||
}}
|
||||
onMouseEnter={e => {
|
||||
e.currentTarget.style.borderColor = GOLD;
|
||||
e.currentTarget.style.color = GOLD;
|
||||
}}
|
||||
onMouseLeave={e => {
|
||||
e.currentTarget.style.borderColor = 'rgba(239,191,4,0.35)';
|
||||
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>
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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,17 +431,19 @@ const About: React.FC = () => {
|
||||
>
|
||||
{fallbackText(cta.primaryCta?.label, aboutContent.cta.primaryCta.label)} <ChevronRight size={16} />
|
||||
</Link>
|
||||
<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
|
||||
to={fallbackText(cta.secondaryCta?.url, aboutContent.cta.secondaryCta.url)}
|
||||
style={{ fontFamily: FF, fontSize: 11, fontWeight: 700, letterSpacing: '0.12em', textTransform: 'uppercase', color: 'rgba(239,191,4,0.7)', textDecoration: 'none', display: 'inline-flex', alignItems: 'center', gap: 6, borderBottom: '1px solid rgba(239,191,4,0.3)', paddingBottom: 1, transition: 'color 0.15s, borderColor 0.15s' }}
|
||||
onMouseEnter={(e) => { e.currentTarget.style.color = '#EFBF04'; e.currentTarget.style.borderBottomColor = '#EFBF04' }}
|
||||
onMouseLeave={(e) => { e.currentTarget.style.color = 'rgba(239,191,4,0.7)'; e.currentTarget.style.borderBottomColor = 'rgba(239,191,4,0.3)' }}
|
||||
>
|
||||
{fallbackText(cta.secondaryCta?.label, aboutContent.cta.secondaryCta.label)} <ChevronRight size={10} />
|
||||
</Link>
|
||||
</div>
|
||||
{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
|
||||
to={fallbackText(cta.secondaryCta?.url, aboutContent.cta.secondaryCta.url)}
|
||||
style={{ fontFamily: FF, fontSize: 11, fontWeight: 700, letterSpacing: '0.12em', textTransform: 'uppercase', color: 'rgba(239,191,4,0.7)', textDecoration: 'none', display: 'inline-flex', alignItems: 'center', gap: 6, borderBottom: '1px solid rgba(239,191,4,0.3)', paddingBottom: 1, transition: 'color 0.15s, borderColor 0.15s' }}
|
||||
onMouseEnter={(e) => { e.currentTarget.style.color = '#EFBF04'; e.currentTarget.style.borderBottomColor = '#EFBF04' }}
|
||||
onMouseLeave={(e) => { e.currentTarget.style.color = 'rgba(239,191,4,0.7)'; e.currentTarget.style.borderBottomColor = 'rgba(239,191,4,0.3)' }}
|
||||
>
|
||||
{fallbackText(cta.secondaryCta?.label, aboutContent.cta.secondaryCta.label)} <ChevronRight size={10} />
|
||||
</Link>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
|
||||
@@ -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>
|
||||
<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',
|
||||
}}
|
||||
onMouseEnter={e => (e.currentTarget.style.background = GOLD)}
|
||||
onMouseLeave={e => (e.currentTarget.style.background = NAVY)}
|
||||
>
|
||||
{fallbackText(sidebarCta.label, blogDetailContent.sidebarCta.label)} <ArrowRight size={12} />
|
||||
</Link>
|
||||
{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={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>
|
||||
|
||||
@@ -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,24 +270,27 @@ 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' }}>
|
||||
<Link
|
||||
to={fallbackText(hero.secondaryCta?.url, homeContent.hero.secondaryCta.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)}
|
||||
</Link>
|
||||
<button
|
||||
onClick={() => setShowVideo(true)}
|
||||
className="flex items-center gap-4 text-white hover:text-accent transition-colors group"
|
||||
style={{ justifyContent: isMobile ? 'center' : 'flex-start' }}
|
||||
>
|
||||
<div className="w-12 h-12 rounded-full border border-white/30 flex items-center justify-center group-hover:border-accent group-hover:bg-accent/10 transition-all">
|
||||
<Play size={20} fill="currentColor" />
|
||||
</div>
|
||||
<span className="uppercase text-xs tracking-widest font-bold">{fallbackText(hero.videoLabel, homeContent.hero.videoLabel)}</span>
|
||||
</button>
|
||||
{showHeroSecondaryCta && (
|
||||
<Link
|
||||
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)'; }}
|
||||
>
|
||||
{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' }}
|
||||
>
|
||||
<div className="w-12 h-12 rounded-full border border-white/30 flex items-center justify-center group-hover:border-accent group-hover:bg-accent/10 transition-all">
|
||||
<Play size={20} fill="currentColor" />
|
||||
</div>
|
||||
<span className="uppercase text-xs tracking-widest font-bold">{fallbackText(hero.videoLabel, homeContent.hero.videoLabel)}</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -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">
|
||||
<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/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>
|
||||
</div>
|
||||
{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">{videoTitle}</p>
|
||||
<p className="text-white/60 mt-4 text-xs italic font-body">{fallbackText(videoModal.description, homeContent.videoModal.description)}</p>
|
||||
<Button type="button" onClick={() => setShowVideo(false)} className="mt-8" variant="outline">{fallbackText(videoModal.closeLabel, homeContent.videoModal.closeLabel)}</Button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
@@ -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>
|
||||
)}
|
||||
|
||||
@@ -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,37 +837,39 @@ const Netzwerk: React.FC = () => {
|
||||
</div>
|
||||
</div>
|
||||
{/* Footnote */}
|
||||
<div style={{ position: 'relative', zIndex: 1, padding: isMobile ? '16px 20px 20px' : '14px 56px 18px', display: 'flex', flexWrap: 'wrap', justifyContent: 'center', alignItems: 'center', borderTop: '1px solid rgba(255,255,255,0.06)', flexShrink: 0 }}>
|
||||
<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
|
||||
to={fallbackText(sponsoring.footnoteUrl, netzwerkContent.sponsoring.footnoteUrl)}
|
||||
style={{
|
||||
fontFamily: '"IBM Plex Sans", sans-serif',
|
||||
fontSize: 15,
|
||||
fontWeight: 700,
|
||||
letterSpacing: '0.12em',
|
||||
textTransform: 'uppercase',
|
||||
color: 'rgba(239,191,4,0.7)',
|
||||
textDecoration: 'none',
|
||||
display: 'inline-flex',
|
||||
alignItems: 'center',
|
||||
gap: 6,
|
||||
borderBottom: '1px solid rgba(239,191,4,0.3)',
|
||||
paddingBottom: 1,
|
||||
transition: 'color 0.15s, borderColor 0.15s',
|
||||
}}
|
||||
onMouseEnter={e => {
|
||||
(e.currentTarget as HTMLElement).style.color = '#EFBF04';
|
||||
(e.currentTarget as HTMLElement).style.borderBottomColor = '#EFBF04';
|
||||
}}
|
||||
onMouseLeave={e => {
|
||||
(e.currentTarget as HTMLElement).style.color = 'rgba(239,191,4,0.7)';
|
||||
(e.currentTarget as HTMLElement).style.borderBottomColor = 'rgba(239,191,4,0.3)';
|
||||
}}
|
||||
>
|
||||
{fallbackText(sponsoring.footnoteLabel, netzwerkContent.sponsoring.footnoteLabel)} <ChevronRight size={11} />
|
||||
</Link>
|
||||
</div>
|
||||
{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
|
||||
to={fallbackText(sponsoring.footnoteUrl, netzwerkContent.sponsoring.footnoteUrl)}
|
||||
style={{
|
||||
fontFamily: '"IBM Plex Sans", sans-serif',
|
||||
fontSize: 15,
|
||||
fontWeight: 700,
|
||||
letterSpacing: '0.12em',
|
||||
textTransform: 'uppercase',
|
||||
color: 'rgba(239,191,4,0.7)',
|
||||
textDecoration: 'none',
|
||||
display: 'inline-flex',
|
||||
alignItems: 'center',
|
||||
gap: 6,
|
||||
borderBottom: '1px solid rgba(239,191,4,0.3)',
|
||||
paddingBottom: 1,
|
||||
transition: 'color 0.15s, borderColor 0.15s',
|
||||
}}
|
||||
onMouseEnter={e => {
|
||||
(e.currentTarget as HTMLElement).style.color = '#EFBF04';
|
||||
(e.currentTarget as HTMLElement).style.borderBottomColor = '#EFBF04';
|
||||
}}
|
||||
onMouseLeave={e => {
|
||||
(e.currentTarget as HTMLElement).style.color = 'rgba(239,191,4,0.7)';
|
||||
(e.currentTarget as HTMLElement).style.borderBottomColor = 'rgba(239,191,4,0.3)';
|
||||
}}
|
||||
>
|
||||
{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>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -524,6 +524,7 @@ const Press: React.FC = () => {
|
||||
{/* Left col */}
|
||||
<div
|
||||
style={{
|
||||
background: '#fff',
|
||||
padding: isMobile ? '48px 24px' : '88px 80px',
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
|
||||
@@ -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: 'Innovationskraft', desc: 'Zukunftsweisende Produkte, Dienstleistungen oder interne Prozessinnovationen.' },
|
||||
{ title: 'Nachhaltigkeit', desc: 'Ökologische Verantwortung und Ressourcen-Effizienz im operativen Kern.' },
|
||||
{ title: 'Unternehmenskultur', 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',
|
||||
|
||||
@@ -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
Reference in New Issue
Block a user