Compare commits
3 Commits
d6f8f55c09
...
0e41c890f9
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
0e41c890f9 | ||
|
|
b0aec7ba94 | ||
|
|
818f283e10 |
@@ -1,4 +1,8 @@
|
|||||||
.git
|
.git
|
||||||
|
.agents
|
||||||
|
.codex
|
||||||
|
.pnpm-store
|
||||||
|
skills-lock.json
|
||||||
.next
|
.next
|
||||||
.env
|
.env
|
||||||
.env.*
|
.env.*
|
||||||
@@ -6,7 +10,10 @@
|
|||||||
.DS_Store
|
.DS_Store
|
||||||
node_modules
|
node_modules
|
||||||
content.db
|
content.db
|
||||||
|
next-env.d.ts
|
||||||
public/media
|
public/media
|
||||||
|
public/robots.txt
|
||||||
|
public/sitemap*.xml
|
||||||
tsconfig.tsbuildinfo
|
tsconfig.tsbuildinfo
|
||||||
playwright-report
|
playwright-report
|
||||||
test-results
|
test-results
|
||||||
|
|||||||
1
.gitignore
vendored
1
.gitignore
vendored
@@ -1,6 +1,7 @@
|
|||||||
build
|
build
|
||||||
dist / media
|
dist / media
|
||||||
node_modules
|
node_modules
|
||||||
|
.pnpm-store/
|
||||||
.DS_Store
|
.DS_Store
|
||||||
.env
|
.env
|
||||||
.next
|
.next
|
||||||
|
|||||||
@@ -75,21 +75,15 @@ pnpm deploy:vps
|
|||||||
|
|
||||||
The deploy script:
|
The deploy script:
|
||||||
|
|
||||||
1. refuses to deploy a dirty worktree by default
|
1. refuses to deploy a dirty worktree
|
||||||
2. rsyncs code to `/var/www/bmp-website/current`
|
2. rsyncs code to `/var/www/bmp-website/current`
|
||||||
3. excludes `.env`, `content.db`, `public/media`, `.next`, and `node_modules`
|
3. excludes runtime state, dependency caches, generated files, and local agent tooling
|
||||||
4. writes the deployed Git revision to `.deploy-revision`
|
4. writes the deployed Git revision to `.deploy-revision`
|
||||||
5. builds the Docker image on the VPS
|
5. builds the Docker image on the VPS
|
||||||
6. recreates only the `bmp_website` app container
|
6. recreates only the `bmp_website` app container
|
||||||
7. verifies `/` and `/admin`
|
7. verifies `/` and `/admin`
|
||||||
|
|
||||||
If you intentionally need an ad-hoc deploy before committing:
|
The required workflow is:
|
||||||
|
|
||||||
```bash
|
|
||||||
ALLOW_DIRTY=1 pnpm deploy:vps
|
|
||||||
```
|
|
||||||
|
|
||||||
Use that sparingly. The preferred workflow is:
|
|
||||||
|
|
||||||
```text
|
```text
|
||||||
commit locally -> deploy that commit -> verify production
|
commit locally -> deploy that commit -> verify production
|
||||||
|
|||||||
@@ -11,7 +11,6 @@ SSH_KEY="${SSH_KEY:-$HOME/.ssh/bmp-vps-deploy}"
|
|||||||
APP_ROOT="${APP_ROOT:-/var/www/bmp-website}"
|
APP_ROOT="${APP_ROOT:-/var/www/bmp-website}"
|
||||||
APP_DOMAIN="${APP_DOMAIN:-srv1.bayerischer-mittelstandspreis.de}"
|
APP_DOMAIN="${APP_DOMAIN:-srv1.bayerischer-mittelstandspreis.de}"
|
||||||
PUBLIC_URL="${PUBLIC_URL:-https://$APP_DOMAIN}"
|
PUBLIC_URL="${PUBLIC_URL:-https://$APP_DOMAIN}"
|
||||||
ALLOW_DIRTY="${ALLOW_DIRTY:-0}"
|
|
||||||
DEPLOY_CADDY="${DEPLOY_CADDY:-0}"
|
DEPLOY_CADDY="${DEPLOY_CADDY:-0}"
|
||||||
RUN_MIGRATIONS="${RUN_MIGRATIONS:-0}"
|
RUN_MIGRATIONS="${RUN_MIGRATIONS:-0}"
|
||||||
SKIP_VERIFY="${SKIP_VERIFY:-0}"
|
SKIP_VERIFY="${SKIP_VERIFY:-0}"
|
||||||
@@ -24,9 +23,9 @@ if [[ ! -f "$SSH_KEY" ]]; then
|
|||||||
exit 1
|
exit 1
|
||||||
fi
|
fi
|
||||||
|
|
||||||
if [[ "$ALLOW_DIRTY" != "1" && -n "$(git status --porcelain)" ]]; then
|
if [[ -n "$(git status --porcelain)" ]]; then
|
||||||
echo "Refusing to deploy a dirty worktree." >&2
|
echo "Refusing to deploy a dirty worktree." >&2
|
||||||
echo "Commit or stash changes first, or run with ALLOW_DIRTY=1 for an intentional ad-hoc deploy." >&2
|
echo "Commit or stash changes first." >&2
|
||||||
exit 1
|
exit 1
|
||||||
fi
|
fi
|
||||||
|
|
||||||
@@ -36,13 +35,20 @@ echo "Deploying $REVISION to $SSH_TARGET:$APP_ROOT"
|
|||||||
|
|
||||||
rsync -az --delete \
|
rsync -az --delete \
|
||||||
--exclude '.git/' \
|
--exclude '.git/' \
|
||||||
|
--exclude '.agents/' \
|
||||||
|
--exclude '.codex/' \
|
||||||
|
--exclude '.pnpm-store/' \
|
||||||
|
--exclude 'skills-lock.json' \
|
||||||
--exclude '.env' \
|
--exclude '.env' \
|
||||||
--include '.env.example' \
|
--include '.env.example' \
|
||||||
--exclude '.env.*' \
|
--exclude '.env.*' \
|
||||||
--exclude '.next/' \
|
--exclude '.next/' \
|
||||||
--exclude 'node_modules/' \
|
--exclude 'node_modules/' \
|
||||||
--exclude 'content.db' \
|
--exclude 'content.db' \
|
||||||
|
--exclude 'next-env.d.ts' \
|
||||||
--exclude 'public/media/' \
|
--exclude 'public/media/' \
|
||||||
|
--exclude 'public/robots.txt' \
|
||||||
|
--exclude 'public/sitemap*.xml' \
|
||||||
--exclude '.DS_Store' \
|
--exclude '.DS_Store' \
|
||||||
--exclude 'tsconfig.tsbuildinfo' \
|
--exclude 'tsconfig.tsbuildinfo' \
|
||||||
-e "ssh -i $SSH_KEY -p $VPS_PORT" \
|
-e "ssh -i $SSH_KEY -p $VPS_PORT" \
|
||||||
@@ -50,7 +56,7 @@ rsync -az --delete \
|
|||||||
|
|
||||||
ssh -i "$SSH_KEY" -p "$VPS_PORT" "$SSH_TARGET" "printf '%s\n' '$REVISION' > '$APP_ROOT/current/.deploy-revision'"
|
ssh -i "$SSH_KEY" -p "$VPS_PORT" "$SSH_TARGET" "printf '%s\n' '$REVISION' > '$APP_ROOT/current/.deploy-revision'"
|
||||||
|
|
||||||
ssh -i "$SSH_KEY" -p "$VPS_PORT" "$SSH_TARGET" "
|
ssh -t -i "$SSH_KEY" -p "$VPS_PORT" "$SSH_TARGET" "
|
||||||
set -e
|
set -e
|
||||||
cd '$APP_ROOT'
|
cd '$APP_ROOT'
|
||||||
APP_ROOT='$APP_ROOT' APP_DOMAIN='$APP_DOMAIN' sudo docker compose -f '$COMPOSE_FILE' build app
|
APP_ROOT='$APP_ROOT' APP_DOMAIN='$APP_DOMAIN' sudo docker compose -f '$COMPOSE_FILE' build app
|
||||||
|
|||||||
@@ -11,7 +11,7 @@ APP_DOMAIN="${APP_DOMAIN:-srv1.bayerischer-mittelstandspreis.de}"
|
|||||||
SSH_TARGET="$SSH_USER@$VPS_HOST"
|
SSH_TARGET="$SSH_USER@$VPS_HOST"
|
||||||
COMPOSE_FILE="$APP_ROOT/current/deploy/vps/docker-compose.yml"
|
COMPOSE_FILE="$APP_ROOT/current/deploy/vps/docker-compose.yml"
|
||||||
|
|
||||||
ssh -i "$SSH_KEY" -p "$VPS_PORT" "$SSH_TARGET" "
|
ssh -t -i "$SSH_KEY" -p "$VPS_PORT" "$SSH_TARGET" "
|
||||||
set -e
|
set -e
|
||||||
cd '$APP_ROOT'
|
cd '$APP_ROOT'
|
||||||
|
|
||||||
|
|||||||
@@ -4,17 +4,6 @@ import { authenticated } from '@/access/authenticated'
|
|||||||
import { authenticatedOrPublished } from '@/access/authenticatedOrPublished'
|
import { authenticatedOrPublished } from '@/access/authenticatedOrPublished'
|
||||||
import { populatePublishedAt } from '@/hooks/populatePublishedAt'
|
import { populatePublishedAt } from '@/hooks/populatePublishedAt'
|
||||||
|
|
||||||
const logoKindOptions = [
|
|
||||||
{ label: 'Text', value: 'text' },
|
|
||||||
{ label: 'Serif text', value: 'serif' },
|
|
||||||
{ label: 'Badge', value: 'badge' },
|
|
||||||
{ label: 'Monogram', value: 'monogram' },
|
|
||||||
{ label: 'Dot mark', value: 'dot' },
|
|
||||||
{ label: 'Leaf mark', value: 'leaf' },
|
|
||||||
{ label: 'RSM blocks', value: 'rsm' },
|
|
||||||
{ label: 'Deutsche Bank mark', value: 'deutschebank' },
|
|
||||||
]
|
|
||||||
|
|
||||||
export const Partners: CollectionConfig = {
|
export const Partners: CollectionConfig = {
|
||||||
slug: 'partners',
|
slug: 'partners',
|
||||||
labels: {
|
labels: {
|
||||||
@@ -28,7 +17,7 @@ export const Partners: CollectionConfig = {
|
|||||||
update: authenticated,
|
update: authenticated,
|
||||||
},
|
},
|
||||||
admin: {
|
admin: {
|
||||||
defaultColumns: ['name', 'role', 'tier', 'sortOrder', 'active', 'showOnHomepage', 'updatedAt'],
|
defaultColumns: ['name', 'tier', 'sortOrder', 'active', 'showOnHomepage', 'updatedAt'],
|
||||||
group: 'Inhalte',
|
group: 'Inhalte',
|
||||||
useAsTitle: 'name',
|
useAsTitle: 'name',
|
||||||
},
|
},
|
||||||
@@ -47,18 +36,6 @@ export const Partners: CollectionConfig = {
|
|||||||
description: 'Stable machine-readable key used when migrating or matching partner records.',
|
description: 'Stable machine-readable key used when migrating or matching partner records.',
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
{
|
|
||||||
name: 'role',
|
|
||||||
type: 'select',
|
|
||||||
required: true,
|
|
||||||
options: [
|
|
||||||
{ label: 'Hauptsponsor', value: 'Hauptsponsor' },
|
|
||||||
{ label: 'Sponsor', value: 'Sponsor' },
|
|
||||||
{ label: 'Finanzpartner', value: 'Finanzpartner' },
|
|
||||||
{ label: 'Partner', value: 'Partner' },
|
|
||||||
{ label: 'Medienpartner', value: 'Medienpartner' },
|
|
||||||
],
|
|
||||||
},
|
|
||||||
{
|
{
|
||||||
name: 'tier',
|
name: 'tier',
|
||||||
type: 'number',
|
type: 'number',
|
||||||
@@ -89,8 +66,9 @@ export const Partners: CollectionConfig = {
|
|||||||
name: 'logo',
|
name: 'logo',
|
||||||
type: 'upload',
|
type: 'upload',
|
||||||
relationTo: 'media',
|
relationTo: 'media',
|
||||||
|
required: true,
|
||||||
admin: {
|
admin: {
|
||||||
description: 'Preferred real partner logo. Styled logo fallback fields below are used when this is empty.',
|
description: 'Partner logo displayed on partner cards, the detail modal, and the homepage ticker.',
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -98,28 +76,6 @@ export const Partners: CollectionConfig = {
|
|||||||
type: 'text',
|
type: 'text',
|
||||||
label: 'Logo alt text',
|
label: 'Logo alt text',
|
||||||
},
|
},
|
||||||
{
|
|
||||||
name: 'logoKind',
|
|
||||||
type: 'select',
|
|
||||||
defaultValue: 'text',
|
|
||||||
label: 'Fallback logo style',
|
|
||||||
options: logoKindOptions,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: 'logoText',
|
|
||||||
type: 'text',
|
|
||||||
label: 'Fallback logo text',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: 'logoSubtext',
|
|
||||||
type: 'text',
|
|
||||||
label: 'Fallback logo subtext',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: 'logoColor',
|
|
||||||
type: 'text',
|
|
||||||
label: 'Fallback logo color',
|
|
||||||
},
|
|
||||||
{
|
{
|
||||||
name: 'desc',
|
name: 'desc',
|
||||||
type: 'textarea',
|
type: 'textarea',
|
||||||
|
|||||||
56
src/migrations/20260712_120000_partner_media_only.ts
Normal file
56
src/migrations/20260712_120000_partner_media_only.ts
Normal file
@@ -0,0 +1,56 @@
|
|||||||
|
import { type MigrateDownArgs, type MigrateUpArgs, sql } from '@payloadcms/db-sqlite'
|
||||||
|
|
||||||
|
type MigrationDB = MigrateUpArgs['db']
|
||||||
|
|
||||||
|
const ident = (value: string) => `\`${value.replace(/`/g, '``')}\``
|
||||||
|
const literal = (value: string) => `'${value.replace(/'/g, "''")}'`
|
||||||
|
|
||||||
|
async function tableExists(db: MigrationDB, tableName: string) {
|
||||||
|
const rows = (await db.all(
|
||||||
|
sql.raw(`SELECT name FROM sqlite_master WHERE type = 'table' AND name = ${literal(tableName)}`),
|
||||||
|
)) as Array<{ name: string }>
|
||||||
|
|
||||||
|
return rows.length > 0
|
||||||
|
}
|
||||||
|
|
||||||
|
async function columnExists(db: MigrationDB, tableName: string, columnName: string) {
|
||||||
|
if (!(await tableExists(db, tableName))) return false
|
||||||
|
|
||||||
|
const columns = (await db.all(sql.raw(`PRAGMA table_info(${ident(tableName)})`))) as Array<{ name: string }>
|
||||||
|
|
||||||
|
return columns.some((column) => column.name === columnName)
|
||||||
|
}
|
||||||
|
|
||||||
|
async function addColumn(db: MigrationDB, tableName: string, columnName: string, definition: string) {
|
||||||
|
if (await columnExists(db, tableName, columnName)) return
|
||||||
|
|
||||||
|
await db.run(sql.raw(`ALTER TABLE ${ident(tableName)} ADD ${ident(columnName)} ${definition};`))
|
||||||
|
}
|
||||||
|
|
||||||
|
async function dropColumn(db: MigrationDB, tableName: string, columnName: string) {
|
||||||
|
if (!(await columnExists(db, tableName, columnName))) return
|
||||||
|
|
||||||
|
await db.run(sql.raw(`ALTER TABLE ${ident(tableName)} DROP COLUMN ${ident(columnName)};`))
|
||||||
|
}
|
||||||
|
|
||||||
|
const partnerColumns = ['role', 'logo_kind', 'logo_text', 'logo_subtext', 'logo_color'] as const
|
||||||
|
const versionColumns = partnerColumns.map((column) => `version_${column}`)
|
||||||
|
|
||||||
|
export async function up({ db, payload: _payload, req: _req }: MigrateUpArgs): Promise<void> {
|
||||||
|
for (const column of partnerColumns) await dropColumn(db, 'partners', column)
|
||||||
|
for (const column of versionColumns) await dropColumn(db, '_partners_v', column)
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function down({ db, payload: _payload, req: _req }: MigrateDownArgs): Promise<void> {
|
||||||
|
await addColumn(db, 'partners', 'role', 'text')
|
||||||
|
await addColumn(db, 'partners', 'logo_kind', `text DEFAULT ${literal('text')}`)
|
||||||
|
await addColumn(db, 'partners', 'logo_text', 'text')
|
||||||
|
await addColumn(db, 'partners', 'logo_subtext', 'text')
|
||||||
|
await addColumn(db, 'partners', 'logo_color', 'text')
|
||||||
|
|
||||||
|
await addColumn(db, '_partners_v', 'version_role', 'text')
|
||||||
|
await addColumn(db, '_partners_v', 'version_logo_kind', `text DEFAULT ${literal('text')}`)
|
||||||
|
await addColumn(db, '_partners_v', 'version_logo_text', 'text')
|
||||||
|
await addColumn(db, '_partners_v', 'version_logo_subtext', 'text')
|
||||||
|
await addColumn(db, '_partners_v', 'version_logo_color', 'text')
|
||||||
|
}
|
||||||
@@ -8,6 +8,7 @@ import * as migration_20260707_122300_preistraeger_zip_download from './20260707
|
|||||||
import * as migration_20260707_123500_preistraeger_gallery from './20260707_123500_preistraeger_gallery';
|
import * as migration_20260707_123500_preistraeger_gallery from './20260707_123500_preistraeger_gallery';
|
||||||
import * as migration_20260707_124500_site_settings_preistraeger_placeholder from './20260707_124500_site_settings_preistraeger_placeholder';
|
import * as migration_20260707_124500_site_settings_preistraeger_placeholder from './20260707_124500_site_settings_preistraeger_placeholder';
|
||||||
import * as migration_20260707_130500_preistraeger_awards_jury_members from './20260707_130500_preistraeger_awards_jury_members';
|
import * as migration_20260707_130500_preistraeger_awards_jury_members from './20260707_130500_preistraeger_awards_jury_members';
|
||||||
|
import * as migration_20260712_120000_partner_media_only from './20260712_120000_partner_media_only';
|
||||||
|
|
||||||
export const migrations = [
|
export const migrations = [
|
||||||
{
|
{
|
||||||
@@ -60,4 +61,9 @@ export const migrations = [
|
|||||||
down: migration_20260707_130500_preistraeger_awards_jury_members.down,
|
down: migration_20260707_130500_preistraeger_awards_jury_members.down,
|
||||||
name: '20260707_130500_preistraeger_awards_jury_members',
|
name: '20260707_130500_preistraeger_awards_jury_members',
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
up: migration_20260712_120000_partner_media_only.up,
|
||||||
|
down: migration_20260712_120000_partner_media_only.down,
|
||||||
|
name: '20260712_120000_partner_media_only',
|
||||||
|
},
|
||||||
];
|
];
|
||||||
|
|||||||
@@ -2763,7 +2763,6 @@ export interface Partner {
|
|||||||
* Stable machine-readable key used when migrating or matching partner records.
|
* Stable machine-readable key used when migrating or matching partner records.
|
||||||
*/
|
*/
|
||||||
stableId?: string | null;
|
stableId?: string | null;
|
||||||
role: 'Hauptsponsor' | 'Sponsor' | 'Finanzpartner' | 'Partner' | 'Medienpartner';
|
|
||||||
tier: number;
|
tier: number;
|
||||||
/**
|
/**
|
||||||
* Lower numbers appear earlier on partner surfaces.
|
* Lower numbers appear earlier on partner surfaces.
|
||||||
@@ -2772,14 +2771,10 @@ export interface Partner {
|
|||||||
active?: boolean | null;
|
active?: boolean | null;
|
||||||
showOnHomepage?: boolean | null;
|
showOnHomepage?: boolean | null;
|
||||||
/**
|
/**
|
||||||
* Preferred real partner logo. Styled logo fallback fields below are used when this is empty.
|
* Partner logo displayed on partner cards, the detail modal, and the homepage ticker.
|
||||||
*/
|
*/
|
||||||
logo?: (number | null) | Media;
|
logo: number | Media;
|
||||||
logoAlt?: string | null;
|
logoAlt?: string | null;
|
||||||
logoKind?: ('text' | 'serif' | 'badge' | 'monogram' | 'dot' | 'leaf' | 'rsm' | 'deutschebank') | null;
|
|
||||||
logoText?: string | null;
|
|
||||||
logoSubtext?: string | null;
|
|
||||||
logoColor?: string | null;
|
|
||||||
desc?: string | null;
|
desc?: string | null;
|
||||||
fullDesc?: string | null;
|
fullDesc?: string | null;
|
||||||
industry?: string | null;
|
industry?: string | null;
|
||||||
@@ -5089,17 +5084,12 @@ export interface JuryMitgliederSelect<T extends boolean = true> {
|
|||||||
export interface PartnersSelect<T extends boolean = true> {
|
export interface PartnersSelect<T extends boolean = true> {
|
||||||
name?: T;
|
name?: T;
|
||||||
stableId?: T;
|
stableId?: T;
|
||||||
role?: T;
|
|
||||||
tier?: T;
|
tier?: T;
|
||||||
sortOrder?: T;
|
sortOrder?: T;
|
||||||
active?: T;
|
active?: T;
|
||||||
showOnHomepage?: T;
|
showOnHomepage?: T;
|
||||||
logo?: T;
|
logo?: T;
|
||||||
logoAlt?: T;
|
logoAlt?: T;
|
||||||
logoKind?: T;
|
|
||||||
logoText?: T;
|
|
||||||
logoSubtext?: T;
|
|
||||||
logoColor?: T;
|
|
||||||
desc?: T;
|
desc?: T;
|
||||||
fullDesc?: T;
|
fullDesc?: T;
|
||||||
industry?: T;
|
industry?: T;
|
||||||
|
|||||||
@@ -6,28 +6,14 @@ type MediaLike = {
|
|||||||
alt?: string | null
|
alt?: string | null
|
||||||
}
|
}
|
||||||
|
|
||||||
const payloadFilePrefix = '/api/media/file/'
|
|
||||||
|
|
||||||
export function normalizeMediaUrl(url: string | null | undefined) {
|
export function normalizeMediaUrl(url: string | null | undefined) {
|
||||||
if (!url) return ''
|
return url || ''
|
||||||
if (url.startsWith(payloadFilePrefix)) return `/media/${url.slice(payloadFilePrefix.length)}`
|
|
||||||
|
|
||||||
try {
|
|
||||||
const parsed = new URL(url)
|
|
||||||
if (parsed.pathname.startsWith(payloadFilePrefix)) {
|
|
||||||
return `/media/${parsed.pathname.slice(payloadFilePrefix.length)}`
|
|
||||||
}
|
|
||||||
} catch {
|
|
||||||
// Relative URLs are handled above.
|
|
||||||
}
|
|
||||||
|
|
||||||
return url
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export function mediaUrl(media: unknown, fallback: string) {
|
export function mediaUrl(media: unknown, fallback: string) {
|
||||||
if (!media || typeof media !== 'object') return fallback
|
if (!media || typeof media !== 'object') return fallback
|
||||||
const doc = media as MediaLike
|
const doc = media as MediaLike
|
||||||
return normalizeMediaUrl(doc.url) || (doc.filename ? `/media/${doc.filename}` : fallback)
|
return normalizeMediaUrl(doc.url) || (doc.filename ? `/api/media/file/${encodeURIComponent(doc.filename)}` : fallback)
|
||||||
}
|
}
|
||||||
|
|
||||||
export function mediaAlt(media: unknown, fallback: string) {
|
export function mediaAlt(media: unknown, fallback: string) {
|
||||||
|
|||||||
@@ -42,6 +42,14 @@ export function useCmsCollection(collection: CmsRouteCollection) {
|
|||||||
return useCmsRoute()?.lists?.[collection] || []
|
return useCmsRoute()?.lists?.[collection] || []
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function resolveCmsRelationship(entry: unknown, docs: CmsRouteDoc[]) {
|
||||||
|
const entryID = entry && typeof entry === 'object' ? (entry as { id?: unknown }).id : entry
|
||||||
|
const populatedDoc = docs.find((doc) => String(doc.id) === String(entryID))
|
||||||
|
|
||||||
|
if (populatedDoc) return populatedDoc
|
||||||
|
return entry && typeof entry === 'object' ? (entry as CmsRouteDoc) : undefined
|
||||||
|
}
|
||||||
|
|
||||||
export function useApplicationPhase() {
|
export function useApplicationPhase() {
|
||||||
return useCmsRoute()?.applicationPhase
|
return useCmsRoute()?.applicationPhase
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,19 +3,12 @@ import React from 'react'
|
|||||||
import { mediaAlt, mediaUrl } from '@/spa/cmsMediaField'
|
import { mediaAlt, mediaUrl } from '@/spa/cmsMediaField'
|
||||||
import Image from '@/spa/components/ui/UnoptimizedImage'
|
import Image from '@/spa/components/ui/UnoptimizedImage'
|
||||||
|
|
||||||
const FF = '"IBM Plex Sans", sans-serif'
|
|
||||||
const FB = '"Inter", sans-serif'
|
|
||||||
|
|
||||||
export type PartnerLogoData = {
|
export type PartnerLogoData = {
|
||||||
id?: string | number | null
|
id?: string | number | null
|
||||||
stableId?: string | null
|
stableId?: string | null
|
||||||
name?: string | null
|
name?: string | null
|
||||||
logo?: unknown
|
logo?: unknown
|
||||||
logoAlt?: string | null
|
logoAlt?: string | null
|
||||||
logoKind?: 'text' | 'serif' | 'badge' | 'monogram' | 'dot' | 'leaf' | 'rsm' | 'deutschebank' | null
|
|
||||||
logoText?: string | null
|
|
||||||
logoSubtext?: string | null
|
|
||||||
logoColor?: string | null
|
|
||||||
sortOrder?: number | null
|
sortOrder?: number | null
|
||||||
active?: boolean | null
|
active?: boolean | null
|
||||||
showOnHomepage?: boolean | null
|
showOnHomepage?: boolean | null
|
||||||
@@ -37,31 +30,7 @@ const logoShellStyle: React.CSSProperties = {
|
|||||||
width: '100%',
|
width: '100%',
|
||||||
}
|
}
|
||||||
|
|
||||||
const containedTextStyle: React.CSSProperties = {
|
export function PartnerLogoMark({ partner }: { partner: PartnerLogoData }) {
|
||||||
display: 'block',
|
|
||||||
maxWidth: '100%',
|
|
||||||
minWidth: 0,
|
|
||||||
textAlign: 'center',
|
|
||||||
whiteSpace: 'nowrap',
|
|
||||||
}
|
|
||||||
|
|
||||||
const fittedTextSize = (text: string, preferred: number, compact = 12) => {
|
|
||||||
if (text.length > 24) return Math.max(9, compact - 3)
|
|
||||||
if (text.length > 18) return Math.max(10, compact - 2)
|
|
||||||
if (text.length > 13) return compact - 1
|
|
||||||
if (text.length > 9) return Math.max(compact + 1, preferred - 3)
|
|
||||||
return preferred
|
|
||||||
}
|
|
||||||
|
|
||||||
export function PartnerLogoMark({
|
|
||||||
partner,
|
|
||||||
defaultLogoColor = '#111D55',
|
|
||||||
scale = 1,
|
|
||||||
}: {
|
|
||||||
partner: PartnerLogoData
|
|
||||||
defaultLogoColor?: string
|
|
||||||
scale?: number
|
|
||||||
}) {
|
|
||||||
const realLogo = mediaUrl(partner.logo, '')
|
const realLogo = mediaUrl(partner.logo, '')
|
||||||
const alt = mediaAlt(partner.logo, fallbackText(partner.logoAlt, fallbackText(partner.name, 'Partner logo')))
|
const alt = mediaAlt(partner.logo, fallbackText(partner.logoAlt, fallbackText(partner.name, 'Partner logo')))
|
||||||
|
|
||||||
@@ -78,87 +47,10 @@ export function PartnerLogoMark({
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
const color = fallbackText(partner.logoColor, defaultLogoColor)
|
|
||||||
const text = fallbackText(partner.logoText, fallbackText(partner.name, 'Partner'))
|
|
||||||
const subtext = fallbackText(partner.logoSubtext, '')
|
|
||||||
const size = (value: number) => Math.round(value * scale)
|
|
||||||
const textSize = size(fittedTextSize(text, partner.logoKind === 'serif' ? 21 : 16))
|
|
||||||
|
|
||||||
if (partner.logoKind === 'badge') {
|
|
||||||
return (
|
return (
|
||||||
<span style={logoShellStyle}>
|
<span style={logoShellStyle}>
|
||||||
<span style={{ background: color, color: '#fff', fontWeight: 900, fontSize: size(fittedTextSize(text, 14, 11)), padding: `${size(4)}px ${size(10)}px`, lineHeight: 1.25, display: 'inline-block', maxWidth: '100%', textAlign: 'center', fontFamily: FF }}>
|
<span style={{ color: 'rgba(16,24,40,0.45)', fontFamily: '"IBM Plex Sans", sans-serif', fontSize: 13, fontWeight: 700, textAlign: 'center' }}>
|
||||||
{text.split('\n').map((line, index) => (
|
{fallbackText(partner.name, 'Logo fehlt')}
|
||||||
<React.Fragment key={`${line}-${index}`}>{index > 0 && <br />}{line}</React.Fragment>
|
|
||||||
))}
|
|
||||||
</span>
|
|
||||||
</span>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
if (partner.logoKind === 'monogram') {
|
|
||||||
return (
|
|
||||||
<span style={logoShellStyle}>
|
|
||||||
<span style={{ display: 'flex', alignItems: 'center', gap: size(6), maxWidth: '100%', minWidth: 0 }}>
|
|
||||||
<span style={{ width: size(22), height: size(22), background: '#3a3a3a', color: '#fff', display: 'flex', alignItems: 'center', justifyContent: 'center', fontWeight: 900, fontSize: size(11), fontFamily: FF, flexShrink: 0 }}>{subtext || text.slice(0, 2)}</span>
|
|
||||||
<span style={{ ...containedTextStyle, fontWeight: 700, fontSize: size(fittedTextSize(text, 15, 11)), color, fontFamily: FF }}>{text}</span>
|
|
||||||
</span>
|
|
||||||
</span>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
if (partner.logoKind === 'dot') {
|
|
||||||
return (
|
|
||||||
<span style={logoShellStyle}>
|
|
||||||
<span style={{ display: 'flex', alignItems: 'center', gap: size(6), maxWidth: '100%', minWidth: 0 }}>
|
|
||||||
<span style={{ width: size(20), height: size(20), borderRadius: '50%', background: color, display: 'block', flexShrink: 0 }} />
|
|
||||||
<span style={{ ...containedTextStyle, fontWeight: 700, fontSize: size(fittedTextSize(text, 16, 11)), color, fontFamily: FF }}>{text}</span>
|
|
||||||
</span>
|
|
||||||
</span>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
if (partner.logoKind === 'leaf') {
|
|
||||||
return (
|
|
||||||
<span style={logoShellStyle}>
|
|
||||||
<span style={{ display: 'flex', alignItems: 'center', gap: size(6), maxWidth: '100%', minWidth: 0 }}>
|
|
||||||
<svg width={size(20)} height={size(20)} viewBox="0 0 20 20" aria-hidden="true" style={{ flexShrink: 0 }}><circle cx="10" cy="10" r="9" fill={color} /><path d="M10 3 Q14 7 14 10 Q14 14 10 17 Q6 14 6 10 Q6 7 10 3Z" fill="white" opacity="0.8" /></svg>
|
|
||||||
<span style={{ ...containedTextStyle, fontWeight: 700, fontSize: size(fittedTextSize(text, 15, 11)), color, fontFamily: FB }}>{text}</span>
|
|
||||||
</span>
|
|
||||||
</span>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
if (partner.logoKind === 'rsm') {
|
|
||||||
return (
|
|
||||||
<span style={logoShellStyle}>
|
|
||||||
<span style={{ display: 'flex', alignItems: 'center', gap: size(6), maxWidth: '100%', minWidth: 0 }}>
|
|
||||||
<span style={{ display: 'flex', gap: Math.max(2, size(2)), flexShrink: 0 }}>
|
|
||||||
<span style={{ width: size(10), height: size(10), background: '#6b7280', display: 'block' }} />
|
|
||||||
<span style={{ width: size(10), height: size(10), background: '#22c55e', display: 'block' }} />
|
|
||||||
<span style={{ width: size(10), height: size(10), background: '#3b82f6', display: 'block' }} />
|
|
||||||
</span>
|
|
||||||
<span style={{ ...containedTextStyle, fontWeight: 900, fontSize: size(15), letterSpacing: 0, color, fontFamily: FF }}>{text} {subtext && <span style={{ color: '#6b7280', fontWeight: 600 }}>{subtext}</span>}</span>
|
|
||||||
</span>
|
|
||||||
</span>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
if (partner.logoKind === 'deutschebank') {
|
|
||||||
return (
|
|
||||||
<span style={logoShellStyle}>
|
|
||||||
<span style={{ display: 'flex', alignItems: 'center', gap: size(8), maxWidth: '100%', minWidth: 0 }}>
|
|
||||||
<svg width={size(20)} height={size(20)} viewBox="0 0 20 20" aria-hidden="true" style={{ flexShrink: 0 }}><rect x="1" y="1" width="18" height="18" fill="none" stroke={color} strokeWidth="1.5" /><line x1="5" y1="15" x2="15" y2="5" stroke={color} strokeWidth="2" /></svg>
|
|
||||||
<span style={{ ...containedTextStyle, fontWeight: 600, fontSize: size(fittedTextSize(text, 15, 11)), color, fontFamily: FB }}>{text}</span>
|
|
||||||
</span>
|
|
||||||
</span>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<span style={logoShellStyle}>
|
|
||||||
<span style={{ ...containedTextStyle, fontWeight: 900, fontSize: textSize, letterSpacing: partner.logoKind === 'serif' ? '0.08em' : 0, color, fontFamily: partner.logoKind === 'serif' ? 'Georgia, serif' : FF }}>
|
|
||||||
{text}
|
|
||||||
</span>
|
</span>
|
||||||
</span>
|
</span>
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -144,15 +144,10 @@ export const netzwerkContent = {
|
|||||||
{
|
{
|
||||||
id: 'rsm',
|
id: 'rsm',
|
||||||
name: 'RSM Ebner Stolz',
|
name: 'RSM Ebner Stolz',
|
||||||
role: 'Hauptsponsor',
|
|
||||||
tier: 1,
|
tier: 1,
|
||||||
desc: 'Führende mittelständische Wirtschaftsprüfungs- und Steuerberatungsgesellschaft.',
|
desc: 'Führende mittelständische Wirtschaftsprüfungs- und Steuerberatungsgesellschaft.',
|
||||||
fullDesc:
|
fullDesc:
|
||||||
'RSM Ebner Stolz ist eine der größten unabhängigen Wirtschaftsprüfungs- und Steuerberatungsgesellschaften Deutschlands mit besonderer Expertise im Mittelstand. Als Hauptsponsor des BMP unterstützen sie die Identifikation herausragender Unternehmen und bringen ihre Netzwerke aktiv in die Nominierungsphase ein.',
|
'RSM Ebner Stolz ist eine der größten unabhängigen Wirtschaftsprüfungs- und Steuerberatungsgesellschaften Deutschlands mit besonderer Expertise im Mittelstand. Als Hauptsponsor des BMP unterstützen sie die Identifikation herausragender Unternehmen und bringen ihre Netzwerke aktiv in die Nominierungsphase ein.',
|
||||||
logoKind: 'rsm',
|
|
||||||
logoText: 'RSM',
|
|
||||||
logoSubtext: 'EBNER STOLZ',
|
|
||||||
logoColor: '#374151',
|
|
||||||
industry: 'Wirtschaftsprüfung & Beratung',
|
industry: 'Wirtschaftsprüfung & Beratung',
|
||||||
location: 'München, Bayern',
|
location: 'München, Bayern',
|
||||||
website: 'rsm-ebner-stolz.de',
|
website: 'rsm-ebner-stolz.de',
|
||||||
@@ -161,14 +156,10 @@ export const netzwerkContent = {
|
|||||||
{
|
{
|
||||||
id: 'wwk',
|
id: 'wwk',
|
||||||
name: 'WWK',
|
name: 'WWK',
|
||||||
role: 'Hauptsponsor',
|
|
||||||
tier: 1,
|
tier: 1,
|
||||||
desc: 'Eine starke Gemeinschaft – Lebensversicherungsgruppe mit bayerischen Wurzeln.',
|
desc: 'Eine starke Gemeinschaft – Lebensversicherungsgruppe mit bayerischen Wurzeln.',
|
||||||
fullDesc:
|
fullDesc:
|
||||||
'Die WWK Lebensversicherung a. G. ist eine der leistungsstärksten deutschen Lebensversicherungsgruppen. Als Münchner Traditionsunternehmen verbindet die WWK tiefe bayerische Verwurzelung mit finanzieller Stärke – Werte, die sie mit dem Bayerischen Mittelstandspreis teilt.',
|
'Die WWK Lebensversicherung a. G. ist eine der leistungsstärksten deutschen Lebensversicherungsgruppen. Als Münchner Traditionsunternehmen verbindet die WWK tiefe bayerische Verwurzelung mit finanzieller Stärke – Werte, die sie mit dem Bayerischen Mittelstandspreis teilt.',
|
||||||
logoKind: 'text',
|
|
||||||
logoText: 'WWK',
|
|
||||||
logoColor: '#16a34a',
|
|
||||||
industry: 'Lebensversicherung',
|
industry: 'Lebensversicherung',
|
||||||
location: 'München, Bayern',
|
location: 'München, Bayern',
|
||||||
website: 'wwk.de',
|
website: 'wwk.de',
|
||||||
@@ -177,14 +168,10 @@ export const netzwerkContent = {
|
|||||||
{
|
{
|
||||||
id: 'radiogong',
|
id: 'radiogong',
|
||||||
name: 'Radio Gong 96.3',
|
name: 'Radio Gong 96.3',
|
||||||
role: 'Medienpartner',
|
|
||||||
tier: 2,
|
tier: 2,
|
||||||
desc: 'Der reichweitenstärkste Radiosender Münchens – Stimme des Mittelstands.',
|
desc: 'Der reichweitenstärkste Radiosender Münchens – Stimme des Mittelstands.',
|
||||||
fullDesc:
|
fullDesc:
|
||||||
'Radio Gong 96.3 ist der reichweitenstärkste Radiosender Münchens. Als Medienpartner des BMP sorgt er für Aufmerksamkeit für die Nominierten in Sendemitschnitten und redaktionellen Beiträgen.',
|
'Radio Gong 96.3 ist der reichweitenstärkste Radiosender Münchens. Als Medienpartner des BMP sorgt er für Aufmerksamkeit für die Nominierten in Sendemitschnitten und redaktionellen Beiträgen.',
|
||||||
logoKind: 'badge',
|
|
||||||
logoText: 'Radio\nGong 96.3',
|
|
||||||
logoColor: '#e11d48',
|
|
||||||
industry: 'Medien',
|
industry: 'Medien',
|
||||||
location: 'München',
|
location: 'München',
|
||||||
website: 'radiogong.com',
|
website: 'radiogong.com',
|
||||||
@@ -192,14 +179,10 @@ export const netzwerkContent = {
|
|||||||
{
|
{
|
||||||
id: 'muenchentv',
|
id: 'muenchentv',
|
||||||
name: 'münchen.tv',
|
name: 'münchen.tv',
|
||||||
role: 'Medienpartner',
|
|
||||||
tier: 2,
|
tier: 2,
|
||||||
desc: 'Der lokale Fernsehsender der Landeshauptstadt München.',
|
desc: 'Der lokale Fernsehsender der Landeshauptstadt München.',
|
||||||
fullDesc:
|
fullDesc:
|
||||||
'münchen.tv ist der lokale Fernsehsender der Landeshauptstadt München. Als Medienpartner überträgt münchen.tv Highlights der BMP-Gala und produziert Porträts der Nominierten.',
|
'münchen.tv ist der lokale Fernsehsender der Landeshauptstadt München. Als Medienpartner überträgt münchen.tv Highlights der BMP-Gala und produziert Porträts der Nominierten.',
|
||||||
logoKind: 'text',
|
|
||||||
logoText: 'münchen.tv',
|
|
||||||
logoColor: '#0ea5e9',
|
|
||||||
industry: 'Medien',
|
industry: 'Medien',
|
||||||
location: 'München',
|
location: 'München',
|
||||||
website: 'muenchen.tv',
|
website: 'muenchen.tv',
|
||||||
@@ -207,14 +190,10 @@ export const netzwerkContent = {
|
|||||||
{
|
{
|
||||||
id: 'metzler',
|
id: 'metzler',
|
||||||
name: 'METZLER',
|
name: 'METZLER',
|
||||||
role: 'Sponsor',
|
|
||||||
tier: 3,
|
tier: 3,
|
||||||
desc: 'Älteste Privatbank Deutschlands – Unabhängigkeit seit 1674.',
|
desc: 'Älteste Privatbank Deutschlands – Unabhängigkeit seit 1674.',
|
||||||
fullDesc:
|
fullDesc:
|
||||||
'B. Metzler seel. Sohn & Co. KGaA ist die älteste deutsche Privatbank in Familienbesitz – seit über 350 Jahren unabhängig. Ihr Engagement für den BMP unterstreicht die tiefe Verbundenheit mit dem Mittelstand, dessen Werte wie Verlässlichkeit, Substanz und Langfristigkeit Metzler selbst verkörpert.',
|
'B. Metzler seel. Sohn & Co. KGaA ist die älteste deutsche Privatbank in Familienbesitz – seit über 350 Jahren unabhängig. Ihr Engagement für den BMP unterstreicht die tiefe Verbundenheit mit dem Mittelstand, dessen Werte wie Verlässlichkeit, Substanz und Langfristigkeit Metzler selbst verkörpert.',
|
||||||
logoKind: 'serif',
|
|
||||||
logoText: 'METZLER',
|
|
||||||
logoColor: '#1a1a1a',
|
|
||||||
industry: 'Privatbankwesen',
|
industry: 'Privatbankwesen',
|
||||||
location: 'Frankfurt a. M.',
|
location: 'Frankfurt a. M.',
|
||||||
website: 'metzler.com',
|
website: 'metzler.com',
|
||||||
@@ -223,14 +202,10 @@ export const netzwerkContent = {
|
|||||||
{
|
{
|
||||||
id: 'wieselhuber',
|
id: 'wieselhuber',
|
||||||
name: 'Dr. Wieselhuber & Partner',
|
name: 'Dr. Wieselhuber & Partner',
|
||||||
role: 'Sponsor',
|
|
||||||
tier: 3,
|
tier: 3,
|
||||||
desc: 'Unabhängige Top-Management-Beratung für Familienunternehmen und Mittelstand.',
|
desc: 'Unabhängige Top-Management-Beratung für Familienunternehmen und Mittelstand.',
|
||||||
fullDesc:
|
fullDesc:
|
||||||
'Dr. Wieselhuber & Partner ist eine unabhängige, branchenübergreifende Top-Management-Beratung mit besonderem Fokus auf Familienunternehmen und Mittelstand. Als Sponsor bringt das Haus seine Strategie- und Transformationsexpertise in das Netzwerk des BMP ein.',
|
'Dr. Wieselhuber & Partner ist eine unabhängige, branchenübergreifende Top-Management-Beratung mit besonderem Fokus auf Familienunternehmen und Mittelstand. Als Sponsor bringt das Haus seine Strategie- und Transformationsexpertise in das Netzwerk des BMP ein.',
|
||||||
logoKind: 'text',
|
|
||||||
logoText: 'W&P WIESELHUBER',
|
|
||||||
logoColor: '#1a2b4a',
|
|
||||||
industry: 'Unternehmensberatung',
|
industry: 'Unternehmensberatung',
|
||||||
location: 'München, Bayern',
|
location: 'München, Bayern',
|
||||||
website: 'wieselhuber.de',
|
website: 'wieselhuber.de',
|
||||||
@@ -238,14 +213,10 @@ export const netzwerkContent = {
|
|||||||
{
|
{
|
||||||
id: 'fristads',
|
id: 'fristads',
|
||||||
name: 'Fristads',
|
name: 'Fristads',
|
||||||
role: 'Sponsor',
|
|
||||||
tier: 3,
|
tier: 3,
|
||||||
desc: 'Premium-Arbeitsbekleidung – Funktion, Qualität und Design.',
|
desc: 'Premium-Arbeitsbekleidung – Funktion, Qualität und Design.',
|
||||||
fullDesc:
|
fullDesc:
|
||||||
'Fristads steht für hochwertige, funktionale Arbeitsbekleidung, die Schutz, Komfort und Design vereint. Als Sponsor des Bayerischen Mittelstandspreises unterstützt das Unternehmen die Auszeichnung herausragender mittelständischer Betriebe.',
|
'Fristads steht für hochwertige, funktionale Arbeitsbekleidung, die Schutz, Komfort und Design vereint. Als Sponsor des Bayerischen Mittelstandspreises unterstützt das Unternehmen die Auszeichnung herausragender mittelständischer Betriebe.',
|
||||||
logoKind: 'text',
|
|
||||||
logoText: 'FRISTADS',
|
|
||||||
logoColor: '#1a1a1a',
|
|
||||||
industry: 'Workwear & Textil',
|
industry: 'Workwear & Textil',
|
||||||
location: 'Deutschland',
|
location: 'Deutschland',
|
||||||
website: 'fristads.com',
|
website: 'fristads.com',
|
||||||
@@ -253,15 +224,10 @@ export const netzwerkContent = {
|
|||||||
{
|
{
|
||||||
id: 'newedge',
|
id: 'newedge',
|
||||||
name: 'New Edge',
|
name: 'New Edge',
|
||||||
role: 'Sponsor',
|
|
||||||
tier: 3,
|
tier: 3,
|
||||||
desc: 'Digitale Markenführung, Web und Design für den Mittelstand.',
|
desc: 'Digitale Markenführung, Web und Design für den Mittelstand.',
|
||||||
fullDesc:
|
fullDesc:
|
||||||
'New Edge gestaltet Marken, Web-Erlebnisse und digitale Auftritte für mittelständische Unternehmen. Als Sponsor des BMP unterstützt die Agentur die Sichtbarkeit und das digitale Profil ausgezeichneter Unternehmen.',
|
'New Edge gestaltet Marken, Web-Erlebnisse und digitale Auftritte für mittelständische Unternehmen. Als Sponsor des BMP unterstützt die Agentur die Sichtbarkeit und das digitale Profil ausgezeichneter Unternehmen.',
|
||||||
logoKind: 'monogram',
|
|
||||||
logoText: 'New Edge',
|
|
||||||
logoSubtext: 'NE',
|
|
||||||
logoColor: '#005E67',
|
|
||||||
industry: 'Branding & Digital',
|
industry: 'Branding & Digital',
|
||||||
location: 'Bayern',
|
location: 'Bayern',
|
||||||
website: 'newedgebrand.com',
|
website: 'newedgebrand.com',
|
||||||
@@ -269,14 +235,10 @@ export const netzwerkContent = {
|
|||||||
{
|
{
|
||||||
id: 'moodtalk',
|
id: 'moodtalk',
|
||||||
name: 'Moodtalk',
|
name: 'Moodtalk',
|
||||||
role: 'Sponsor',
|
|
||||||
tier: 3,
|
tier: 3,
|
||||||
desc: 'Plattform für Teamkultur und Mitarbeiter-Feedback.',
|
desc: 'Plattform für Teamkultur und Mitarbeiter-Feedback.',
|
||||||
fullDesc:
|
fullDesc:
|
||||||
'Moodtalk ist eine Plattform für Teamkultur, Stimmung und kontinuierliches Mitarbeiter-Feedback. Als Sponsor des BMP steht das Unternehmen für moderne, wertebasierte Unternehmensführung.',
|
'Moodtalk ist eine Plattform für Teamkultur, Stimmung und kontinuierliches Mitarbeiter-Feedback. Als Sponsor des BMP steht das Unternehmen für moderne, wertebasierte Unternehmensführung.',
|
||||||
logoKind: 'dot',
|
|
||||||
logoText: 'moodtalk',
|
|
||||||
logoColor: '#7c3aed',
|
|
||||||
industry: 'HR & Software',
|
industry: 'HR & Software',
|
||||||
location: 'Bayern',
|
location: 'Bayern',
|
||||||
website: 'moodtalk.io',
|
website: 'moodtalk.io',
|
||||||
@@ -284,14 +246,10 @@ export const netzwerkContent = {
|
|||||||
{
|
{
|
||||||
id: 'deutschebank',
|
id: 'deutschebank',
|
||||||
name: 'Deutsche Bank',
|
name: 'Deutsche Bank',
|
||||||
role: 'Sponsor',
|
|
||||||
tier: 3,
|
tier: 3,
|
||||||
desc: 'Partnerbank für die Finanzierung und Skalierung von KMU-Wachstum.',
|
desc: 'Partnerbank für die Finanzierung und Skalierung von KMU-Wachstum.',
|
||||||
fullDesc:
|
fullDesc:
|
||||||
'Die Deutsche Bank begleitet mittelständische Unternehmen als verlässlicher Finanzpartner durch alle Wachstumsphasen. Im Rahmen des BMP stellt sie ihr deutschlandweites Netzwerk, spezifische Mittelstandsprodukte und direkte Ansprechpartner für die Nominierten bereit.',
|
'Die Deutsche Bank begleitet mittelständische Unternehmen als verlässlicher Finanzpartner durch alle Wachstumsphasen. Im Rahmen des BMP stellt sie ihr deutschlandweites Netzwerk, spezifische Mittelstandsprodukte und direkte Ansprechpartner für die Nominierten bereit.',
|
||||||
logoKind: 'deutschebank',
|
|
||||||
logoText: 'Deutsche Bank',
|
|
||||||
logoColor: '#1d1d1b',
|
|
||||||
industry: 'Bankwesen & Finanzen',
|
industry: 'Bankwesen & Finanzen',
|
||||||
location: 'Frankfurt a. M.',
|
location: 'Frankfurt a. M.',
|
||||||
website: 'db.com',
|
website: 'db.com',
|
||||||
@@ -300,14 +258,10 @@ export const netzwerkContent = {
|
|||||||
{
|
{
|
||||||
id: 'bionorica',
|
id: 'bionorica',
|
||||||
name: 'Bionorica',
|
name: 'Bionorica',
|
||||||
role: 'Sponsor',
|
|
||||||
tier: 3,
|
tier: 3,
|
||||||
desc: 'Weltmarktführer für pflanzliche Arzneimittel aus dem bayerischen Neumarkt.',
|
desc: 'Weltmarktführer für pflanzliche Arzneimittel aus dem bayerischen Neumarkt.',
|
||||||
fullDesc:
|
fullDesc:
|
||||||
'Bionorica SE ist ein international agierendes Pharmaunternehmen mit Sitz in Neumarkt i. d. OPf. und Weltmarktführer bei pflanzlichen Arzneimitteln. Das Unternehmen selbst ist ein Paradebeispiel eines bayerischen Mittelständlers mit globalem Anspruch und verkörpert die Innovationskraft, die der BMP würdigt.',
|
'Bionorica SE ist ein international agierendes Pharmaunternehmen mit Sitz in Neumarkt i. d. OPf. und Weltmarktführer bei pflanzlichen Arzneimitteln. Das Unternehmen selbst ist ein Paradebeispiel eines bayerischen Mittelständlers mit globalem Anspruch und verkörpert die Innovationskraft, die der BMP würdigt.',
|
||||||
logoKind: 'leaf',
|
|
||||||
logoText: 'Bionorica®',
|
|
||||||
logoColor: '#16a34a',
|
|
||||||
industry: 'Pharmaindustrie',
|
industry: 'Pharmaindustrie',
|
||||||
location: 'Neumarkt i.d.OPf., Bayern',
|
location: 'Neumarkt i.d.OPf., Bayern',
|
||||||
website: 'bionorica.de',
|
website: 'bionorica.de',
|
||||||
@@ -316,13 +270,9 @@ export const netzwerkContent = {
|
|||||||
{
|
{
|
||||||
id: 'primus',
|
id: 'primus',
|
||||||
name: 'Primus',
|
name: 'Primus',
|
||||||
role: 'Sponsor',
|
|
||||||
tier: 3,
|
tier: 3,
|
||||||
desc: 'Partner des Bayerischen Mittelstandspreises.',
|
desc: 'Partner des Bayerischen Mittelstandspreises.',
|
||||||
fullDesc: 'Primus unterstützt als Sponsor den Bayerischen Mittelstandspreis und sein Engagement für herausragende Unternehmen des bayerischen Mittelstands.',
|
fullDesc: 'Primus unterstützt als Sponsor den Bayerischen Mittelstandspreis und sein Engagement für herausragende Unternehmen des bayerischen Mittelstands.',
|
||||||
logoKind: 'serif',
|
|
||||||
logoText: 'PRIMUS',
|
|
||||||
logoColor: '#b8860b',
|
|
||||||
industry: 'Partner',
|
industry: 'Partner',
|
||||||
location: 'Bayern',
|
location: 'Bayern',
|
||||||
website: '',
|
website: '',
|
||||||
|
|||||||
@@ -6,7 +6,7 @@ import TestimonialsSection from '@/spa/components/TestimonialsSection'
|
|||||||
import MunichSkylineBg from '@/spa/components/ui/munich-skyline-bg'
|
import MunichSkylineBg from '@/spa/components/ui/munich-skyline-bg'
|
||||||
import Image from '@/spa/components/ui/UnoptimizedImage'
|
import Image from '@/spa/components/ui/UnoptimizedImage'
|
||||||
import { mediaAlt, mediaUrl } from '@/spa/cmsMediaField'
|
import { mediaAlt, mediaUrl } from '@/spa/cmsMediaField'
|
||||||
import { useCmsCollection, useCmsRoute, type CmsRouteDoc } from '@/spa/cmsRoute'
|
import { resolveCmsRelationship, useCmsCollection, useCmsRoute, type CmsRouteDoc } from '@/spa/cmsRoute'
|
||||||
import { WINNERS, type Winner } from '@/spa/data/winners'
|
import { WINNERS, type Winner } from '@/spa/data/winners'
|
||||||
import { useIsMobile } from '@/spa/hooks/useIsMobile'
|
import { useIsMobile } from '@/spa/hooks/useIsMobile'
|
||||||
import { Link } from '@/spa/router'
|
import { Link } from '@/spa/router'
|
||||||
@@ -108,17 +108,6 @@ const normalizeWinner = (
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const winnerFromRelationship = (
|
|
||||||
entry: unknown,
|
|
||||||
allDocs: CmsRouteDoc[],
|
|
||||||
placeholderImage: string,
|
|
||||||
fallbackWinner: typeof aboutContent.highlights.fallbackWinner,
|
|
||||||
) => {
|
|
||||||
if (entry && typeof entry === 'object') return normalizeWinner(entry as CmsRouteDoc, placeholderImage, fallbackWinner)
|
|
||||||
const doc = allDocs.find((item) => String(item.id) === String(entry))
|
|
||||||
return normalizeWinner(doc, placeholderImage, fallbackWinner)
|
|
||||||
}
|
|
||||||
|
|
||||||
const About: React.FC = () => {
|
const About: React.FC = () => {
|
||||||
const isMobile = useIsMobile()
|
const isMobile = useIsMobile()
|
||||||
const placeholderImage = usePreistraegerPlaceholderImage()
|
const placeholderImage = usePreistraegerPlaceholderImage()
|
||||||
@@ -144,7 +133,7 @@ const About: React.FC = () => {
|
|||||||
|
|
||||||
const highlightWinners = useMemo(() => {
|
const highlightWinners = useMemo(() => {
|
||||||
const selected = fallbackArray<unknown>(highlights.selectedWinners, [])
|
const selected = fallbackArray<unknown>(highlights.selectedWinners, [])
|
||||||
.map((entry) => winnerFromRelationship(entry, cmsWinners, placeholderImage, highlightFallbackWinner))
|
.map((entry) => normalizeWinner(resolveCmsRelationship(entry, cmsWinners), placeholderImage, highlightFallbackWinner))
|
||||||
.filter((winner): winner is Winner => Boolean(winner))
|
.filter((winner): winner is Winner => Boolean(winner))
|
||||||
|
|
||||||
if (selected.length) return selected.slice(0, 8)
|
if (selected.length) return selected.slice(0, 8)
|
||||||
|
|||||||
@@ -11,7 +11,7 @@ import MunichSkylineBg from '@/spa/components/ui/munich-skyline-bg';
|
|||||||
import { useIsMobile } from '@/spa/hooks/useIsMobile';
|
import { useIsMobile } from '@/spa/hooks/useIsMobile';
|
||||||
import Image from '@/spa/components/ui/UnoptimizedImage'
|
import Image from '@/spa/components/ui/UnoptimizedImage'
|
||||||
import type { SpaApplicationPhaseData, SpaApplicationPhaseItem } from '@/spa/applicationPhase'
|
import type { SpaApplicationPhaseData, SpaApplicationPhaseItem } from '@/spa/applicationPhase'
|
||||||
import { useApplicationPhase, useCmsCollection, useCmsRoute } from '@/spa/cmsRoute'
|
import { resolveCmsRelationship, useApplicationPhase, useCmsCollection, useCmsRoute } from '@/spa/cmsRoute'
|
||||||
import { mediaAlt, mediaUrl } from '@/spa/cmsMediaField'
|
import { mediaAlt, mediaUrl } from '@/spa/cmsMediaField'
|
||||||
import { homeContent } from '@/spa/homeContent'
|
import { homeContent } from '@/spa/homeContent'
|
||||||
import { netzwerkContent } from '@/spa/netzwerkContent'
|
import { netzwerkContent } from '@/spa/netzwerkContent'
|
||||||
@@ -90,9 +90,6 @@ type HomeCms = {
|
|||||||
videoModal?: HomeVideoModalCms;
|
videoModal?: HomeVideoModalCms;
|
||||||
winners?: HomeSectionCms;
|
winners?: HomeSectionCms;
|
||||||
};
|
};
|
||||||
type NetzwerkPartnersCms = {
|
|
||||||
defaultLogoColor?: string | null;
|
|
||||||
};
|
|
||||||
type RenderedStatusPhase = Omit<SpaApplicationPhaseItem, 'accent' | 'bg' | 'cta' | 'membershipCta'> & {
|
type RenderedStatusPhase = Omit<SpaApplicationPhaseItem, 'accent' | 'bg' | 'cta' | 'membershipCta'> & {
|
||||||
accent: string;
|
accent: string;
|
||||||
bg: string;
|
bg: string;
|
||||||
@@ -165,10 +162,7 @@ function Lines({ text }: { text: string }) {
|
|||||||
return <>{text.split('\n').map((line, i) => <React.Fragment key={`${line}-${i}`}>{i > 0 && <br />}{line}</React.Fragment>)}</>;
|
return <>{text.split('\n').map((line, i) => <React.Fragment key={`${line}-${i}`}>{i > 0 && <br />}{line}</React.Fragment>)}</>;
|
||||||
}
|
}
|
||||||
|
|
||||||
const isNetzwerkPage = (page: CmsRecord) =>
|
function partnerLogos(partners: PartnerLogoData[]) {
|
||||||
page.spaPath === '/netzwerk' || page.slug === 'netzwerk' || page.slug === 'network'
|
|
||||||
|
|
||||||
function partnerLogos(partners: PartnerLogoData[], defaultLogoColor: string) {
|
|
||||||
return partners.map((partner, index) => ({
|
return partners.map((partner, index) => ({
|
||||||
id: String(partner.stableId || partner.id || partner.name || index),
|
id: String(partner.stableId || partner.id || partner.name || index),
|
||||||
label: (
|
label: (
|
||||||
@@ -187,7 +181,7 @@ function partnerLogos(partners: PartnerLogoData[], defaultLogoColor: string) {
|
|||||||
justifyContent: 'center',
|
justifyContent: 'center',
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<PartnerLogoMark partner={partner} defaultLogoColor={defaultLogoColor} />
|
<PartnerLogoMark partner={partner} />
|
||||||
</span>
|
</span>
|
||||||
),
|
),
|
||||||
}));
|
}));
|
||||||
@@ -255,19 +249,12 @@ const Home: React.FC = () => {
|
|||||||
const isMobile = useIsMobile();
|
const isMobile = useIsMobile();
|
||||||
const placeholderImage = usePreistraegerPlaceholderImage();
|
const placeholderImage = usePreistraegerPlaceholderImage();
|
||||||
const home = (useCmsRoute()?.doc?.home || {}) as HomeCms;
|
const home = (useCmsRoute()?.doc?.home || {}) as HomeCms;
|
||||||
const netzwerkPage = useCmsCollection('pages').find((page) => isNetzwerkPage(page as CmsRecord)) as
|
|
||||||
| ({ netzwerk?: { partners?: NetzwerkPartnersCms } } & CmsRecord)
|
|
||||||
| undefined;
|
|
||||||
const partnerSection = netzwerkPage?.netzwerk?.partners;
|
|
||||||
const defaultLogoColor = fallbackText(partnerSection?.defaultLogoColor, netzwerkContent.partners.defaultLogoColor);
|
|
||||||
const cmsPartners = useCmsCollection('partners') as PartnerLogoData[];
|
const cmsPartners = useCmsCollection('partners') as PartnerLogoData[];
|
||||||
const partnerSource = cmsPartners.length ? cmsPartners : (netzwerkContent.partners.items as PartnerLogoData[]);
|
const networkPartners = sortedPartners(cmsPartners)
|
||||||
const networkPartners = sortedPartners(partnerSource)
|
|
||||||
.filter((partner) => partner.active !== false && partner.showOnHomepage !== false)
|
.filter((partner) => partner.active !== false && partner.showOnHomepage !== false)
|
||||||
.map((partner) => ({
|
.map((partner) => ({
|
||||||
...partner,
|
...partner,
|
||||||
id: partner.stableId || partner.id,
|
id: partner.stableId || partner.id,
|
||||||
logoColor: fallbackText(partner.logoColor, defaultLogoColor),
|
|
||||||
}));
|
}));
|
||||||
const hero = home.hero || {};
|
const hero = home.hero || {};
|
||||||
const quickCheck = home.quickCheck || {};
|
const quickCheck = home.quickCheck || {};
|
||||||
@@ -289,12 +276,7 @@ const Home: React.FC = () => {
|
|||||||
};
|
};
|
||||||
const showHeroSecondaryCta = !HIDE_MEMBERSHIP_HOME_HERO_CTA || !isMembershipCta(heroSecondaryCta);
|
const showHeroSecondaryCta = !HIDE_MEMBERSHIP_HOME_HERO_CTA || !isMembershipCta(heroSecondaryCta);
|
||||||
const selectedWinnerDocs = selectedWinners
|
const selectedWinnerDocs = selectedWinners
|
||||||
.map((selected) => {
|
.map((selected) => resolveCmsRelationship(selected, cmsPreistraeger))
|
||||||
const selectedRecord = selected as { id?: string | number }
|
|
||||||
const selectedID = typeof selected === 'object' && selected !== null ? selectedRecord.id : selected
|
|
||||||
const listDoc = cmsPreistraeger.find((doc) => String((doc as { id?: string | number }).id) === String(selectedID))
|
|
||||||
return listDoc || (typeof selected === 'object' ? selected : undefined)
|
|
||||||
})
|
|
||||||
.filter(Boolean)
|
.filter(Boolean)
|
||||||
const homeWinners = selectedWinnerDocs.length
|
const homeWinners = selectedWinnerDocs.length
|
||||||
? selectedWinnerDocs
|
? selectedWinnerDocs
|
||||||
@@ -391,7 +373,7 @@ const Home: React.FC = () => {
|
|||||||
<div style={{ display: 'flex', alignItems: 'center', gap: 16, flexShrink: 0 }}>
|
<div style={{ display: 'flex', alignItems: 'center', gap: 16, flexShrink: 0 }}>
|
||||||
<div style={{ width: 1, height: 20, background: 'rgba(255,255,255,0.2)' }} />
|
<div style={{ width: 1, height: 20, background: 'rgba(255,255,255,0.2)' }} />
|
||||||
<div style={{ width: 'min(820px, calc(100vw - 260px))', overflow: 'hidden', flexShrink: 0 }}>
|
<div style={{ width: 'min(820px, calc(100vw - 260px))', overflow: 'hidden', flexShrink: 0 }}>
|
||||||
<PartnerTicker logos={partnerLogos(networkPartners, defaultLogoColor)} />
|
<PartnerTicker logos={partnerLogos(networkPartners)} />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -48,7 +48,6 @@ type Partner = (typeof netzwerkContent.partners.items)[number] &
|
|||||||
active?: boolean | null
|
active?: boolean | null
|
||||||
heroImg?: string
|
heroImg?: string
|
||||||
linkedin?: string
|
linkedin?: string
|
||||||
logoSubtext?: string
|
|
||||||
showOnHomepage?: boolean | null
|
showOnHomepage?: boolean | null
|
||||||
sortOrder?: number | null
|
sortOrder?: number | null
|
||||||
stableId?: string | null
|
stableId?: string | null
|
||||||
@@ -241,7 +240,6 @@ const SponsorCell: React.FC<{ partner: Partner; variant: 'lg' | 'md' | 'sm'; idx
|
|||||||
const sm = variant === 'sm';
|
const sm = variant === 'sm';
|
||||||
const lastCol = (idx % cols) === cols - 1;
|
const lastCol = (idx % cols) === cols - 1;
|
||||||
const logoHeight = isMobileCell ? (lg ? 76 : sm ? 54 : 62) : lg ? 92 : sm ? 62 : 76;
|
const logoHeight = isMobileCell ? (lg ? 76 : sm ? 54 : 62) : lg ? 92 : sm ? 62 : 76;
|
||||||
const logoScale = lg ? 1.55 : sm ? 1.22 : 1.38;
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
onClick={onClick}
|
onClick={onClick}
|
||||||
@@ -259,7 +257,7 @@ const SponsorCell: React.FC<{ partner: Partner; variant: 'lg' | 'md' | 'sm'; idx
|
|||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<div style={{ height: logoHeight, display: 'flex', alignItems: 'center', justifyContent: 'center', width: '100%', minWidth: 0 }}>
|
<div style={{ height: logoHeight, display: 'flex', alignItems: 'center', justifyContent: 'center', width: '100%', minWidth: 0 }}>
|
||||||
<PartnerLogoMark partner={partner} scale={logoScale} />
|
<PartnerLogoMark partner={partner} />
|
||||||
</div>
|
</div>
|
||||||
<div style={{ width: hovered ? 56 : 24, height: 1, background: GOLD, transition: 'width 0.3s ease' }} />
|
<div style={{ width: hovered ? 56 : 24, height: 1, background: GOLD, transition: 'width 0.3s ease' }} />
|
||||||
<div style={{ fontFamily: FF, fontSize: lg ? 22 : 18, fontWeight: 700, color: '#101828', letterSpacing: '-0.01em', lineHeight: 1.15 }}>{partner.name}</div>
|
<div style={{ fontFamily: FF, fontSize: lg ? 22 : 18, fontWeight: 700, color: '#101828', letterSpacing: '-0.01em', lineHeight: 1.15 }}>{partner.name}</div>
|
||||||
@@ -488,14 +486,11 @@ const Netzwerk: React.FC = () => {
|
|||||||
? juryCollectionMembers
|
? juryCollectionMembers
|
||||||
: fallbackArray<JuryMember>(jury.members, netzwerkContent.jury.members);
|
: fallbackArray<JuryMember>(jury.members, netzwerkContent.jury.members);
|
||||||
const expertiseRows = fallbackArray<ExpertiseRow>(expertise.rows, netzwerkContent.expertise.rows);
|
const expertiseRows = fallbackArray<ExpertiseRow>(expertise.rows, netzwerkContent.expertise.rows);
|
||||||
const defaultLogoColor = fallbackText(partnerSection.defaultLogoColor, netzwerkContent.partners.defaultLogoColor);
|
const partners = sortedPartners(cmsPartners)
|
||||||
const partnerSource = cmsPartners.length ? cmsPartners : (netzwerkContent.partners.items as Partner[]);
|
|
||||||
const partners = sortedPartners(partnerSource)
|
|
||||||
.filter((partner) => partner.active !== false)
|
.filter((partner) => partner.active !== false)
|
||||||
.map((partner) => ({
|
.map((partner) => ({
|
||||||
...partner,
|
...partner,
|
||||||
id: partner.stableId || partner.id,
|
id: partner.stableId || partner.id,
|
||||||
logoColor: fallbackText(partner.logoColor, defaultLogoColor),
|
|
||||||
}));
|
}));
|
||||||
const partnerTiers = fallbackArray<PartnerTier>(partnerSection.tiers, netzwerkContent.partners.tiers);
|
const partnerTiers = fallbackArray<PartnerTier>(partnerSection.tiers, netzwerkContent.partners.tiers);
|
||||||
const mobileStats = fallbackArray<StatItem>(juryIntro.stats, netzwerkContent.juryIntro.stats);
|
const mobileStats = fallbackArray<StatItem>(juryIntro.stats, netzwerkContent.juryIntro.stats);
|
||||||
|
|||||||
13
tests/int/cms-media-field.int.spec.ts
Normal file
13
tests/int/cms-media-field.int.spec.ts
Normal file
@@ -0,0 +1,13 @@
|
|||||||
|
import { describe, expect, it } from 'vitest'
|
||||||
|
|
||||||
|
import { mediaUrl, normalizeMediaUrl } from '@/spa/cmsMediaField'
|
||||||
|
|
||||||
|
describe('CMS media URLs', () => {
|
||||||
|
it('preserves Payload file endpoints used by production', () => {
|
||||||
|
expect(normalizeMediaUrl('/api/media/file/image%20(26).jpg')).toBe('/api/media/file/image%20(26).jpg')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('builds a Payload file endpoint when only a filename is populated', () => {
|
||||||
|
expect(mediaUrl({ filename: 'image (26).jpg' }, '')).toBe('/api/media/file/image%20(26).jpg')
|
||||||
|
})
|
||||||
|
})
|
||||||
25
tests/int/cms-route-relationship.int.spec.ts
Normal file
25
tests/int/cms-route-relationship.int.spec.ts
Normal file
@@ -0,0 +1,25 @@
|
|||||||
|
import { describe, expect, it } from 'vitest'
|
||||||
|
|
||||||
|
import { resolveCmsRelationship, type CmsRouteDoc } from '@/spa/cmsRoute'
|
||||||
|
|
||||||
|
const fullWinner: CmsRouteDoc = {
|
||||||
|
id: 20,
|
||||||
|
title: 'Adelholzener Alpenquellen GmbH',
|
||||||
|
image: { id: 55, url: '/api/media/file/image%20(1).png' },
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('CMS relationship resolution', () => {
|
||||||
|
it('prefers the fully populated collection document over a shallow relationship object', () => {
|
||||||
|
expect(resolveCmsRelationship({ id: 20, image: 55 }, [fullWinner])).toBe(fullWinner)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('resolves an ID-only relationship', () => {
|
||||||
|
expect(resolveCmsRelationship(20, [fullWinner])).toBe(fullWinner)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('preserves a populated relationship when no list document is available', () => {
|
||||||
|
const relationship = { id: 20, image: { id: 55, url: '/api/media/file/image%20(1).png' } }
|
||||||
|
|
||||||
|
expect(resolveCmsRelationship(relationship, [])).toBe(relationship)
|
||||||
|
})
|
||||||
|
})
|
||||||
Reference in New Issue
Block a user