feat: add PartnerLogoMark component for dynamic partner logo rendering

This commit is contained in:
syntaxbullet
2026-07-01 11:39:45 +02:00
parent 1bc1f31940
commit 2fda7e8122
72 changed files with 99921 additions and 3421 deletions

View File

@@ -5,7 +5,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'
export type CmsRouteCollection = 'pages' | 'posts' | 'events' | 'preistraeger' | 'partners'
export type CmsRouteDoc = {
id: number | string

View File

@@ -0,0 +1,155 @@
import React from 'react'
import { mediaAlt, mediaUrl } from '@/spa/cmsMediaField'
import Image from '@/spa/components/ui/UnoptimizedImage'
const FF = '"IBM Plex Sans", sans-serif'
const FB = '"Inter", sans-serif'
export type PartnerLogoData = {
id?: string | number | null
stableId?: string | null
name?: string | null
logo?: unknown
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
active?: boolean | null
showOnHomepage?: boolean | null
}
const fallbackText = (value: unknown, fallback: string) =>
typeof value === 'string' && value.length > 0 ? value : fallback
const logoShellStyle: React.CSSProperties = {
alignItems: 'center',
display: 'flex',
height: '100%',
justifyContent: 'center',
maxHeight: '100%',
minWidth: 0,
width: '100%',
}
const containedTextStyle: React.CSSProperties = {
minWidth: 0,
whiteSpace: 'nowrap',
}
const fittedTextSize = (text: string, preferred: number, compact = 12) => {
if (text.length > 18) return compact - 1
if (text.length > 13) return compact
if (text.length > 9) return Math.max(compact + 1, preferred - 3)
return preferred
}
export function PartnerLogoMark({
partner,
defaultLogoColor = '#111D55',
}: {
partner: PartnerLogoData
defaultLogoColor?: string
}) {
const realLogo = mediaUrl(partner.logo, '')
const alt = mediaAlt(partner.logo, fallbackText(partner.logoAlt, fallbackText(partner.name, 'Partner logo')))
if (realLogo) {
return (
<span style={logoShellStyle}>
<Image
unoptimized
src={realLogo}
alt={alt}
style={{ display: 'block', maxWidth: '100%', maxHeight: '100%', width: 'auto', height: 'auto', objectFit: 'contain' }}
/>
</span>
)
}
const color = fallbackText(partner.logoColor, defaultLogoColor)
const text = fallbackText(partner.logoText, fallbackText(partner.name, 'Partner'))
const subtext = fallbackText(partner.logoSubtext, '')
const textSize = fittedTextSize(text, partner.logoKind === 'serif' ? 21 : 16)
if (partner.logoKind === 'badge') {
return (
<span style={logoShellStyle}>
<span style={{ background: color, color: '#fff', fontWeight: 900, fontSize: 14, padding: '4px 10px', lineHeight: 1.25, display: 'inline-block', textAlign: 'center', fontFamily: FF }}>
{text.split('\n').map((line, index) => (
<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: 6, minWidth: 0 }}>
<span style={{ width: 22, height: 22, background: '#3a3a3a', color: '#fff', display: 'flex', alignItems: 'center', justifyContent: 'center', fontWeight: 900, fontSize: 11, fontFamily: FF, flexShrink: 0 }}>{subtext || text.slice(0, 2)}</span>
<span style={{ ...containedTextStyle, fontWeight: 700, fontSize: 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: 6, minWidth: 0 }}>
<span style={{ width: 20, height: 20, borderRadius: '50%', background: color, display: 'block', flexShrink: 0 }} />
<span style={{ ...containedTextStyle, fontWeight: 700, fontSize: 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: 6, minWidth: 0 }}>
<svg width="20" height="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: 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: 6, minWidth: 0 }}>
<span style={{ display: 'flex', gap: 2, flexShrink: 0 }}>
<span style={{ width: 10, height: 10, background: '#6b7280', display: 'block' }} />
<span style={{ width: 10, height: 10, background: '#22c55e', display: 'block' }} />
<span style={{ width: 10, height: 10, background: '#3b82f6', display: 'block' }} />
</span>
<span style={{ ...containedTextStyle, fontWeight: 900, fontSize: 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: 8, minWidth: 0 }}>
<svg width="20" height="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: 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>
)
}

View File

@@ -30,9 +30,8 @@ export function PartnerTicker({ logos }: PartnerTickerProps) {
padding: '0 24px',
color: '#fff',
fontFamily: '"IBM Plex Sans", sans-serif',
textTransform: 'uppercase',
whiteSpace: 'nowrap',
width: 140,
width: 260,
flexShrink: 0,
textAlign: 'center',
}}

View File

@@ -13,6 +13,8 @@ import type { SpaApplicationPhaseData, SpaApplicationPhaseItem } from '@/spa/app
import { useApplicationPhase, useCmsCollection, useCmsRoute } from '@/spa/cmsRoute'
import { mediaAlt, mediaUrl } from '@/spa/cmsMediaField'
import { homeContent } from '@/spa/homeContent'
import { netzwerkContent } from '@/spa/netzwerkContent'
import { PartnerLogoMark, type PartnerLogoData } from '@/spa/components/ui/partner-logo'
const FF = '"IBM Plex Sans", sans-serif';
@@ -84,6 +86,9 @@ type HomeCms = {
videoModal?: HomeVideoModalCms;
winners?: HomeSectionCms;
};
type NetzwerkPartnersCms = {
defaultLogoColor?: string | null;
};
type RenderedStatusPhase = Omit<SpaApplicationPhaseItem, 'accent' | 'bg' | 'cta' | 'membershipCta'> & {
accent: string;
bg: string;
@@ -121,16 +126,39 @@ function Lines({ text }: { text: string }) {
return <>{text.split('\n').map((line, i) => <React.Fragment key={`${line}-${i}`}>{i > 0 && <br />}{line}</React.Fragment>)}</>;
}
function partnerLogos(partners: unknown) {
return fallbackArray(partners, homeContent.hero.partners).map((partner) => {
const item = partner as { id?: string; name?: string; label?: React.ReactNode };
return item.label
? { id: item.id || String(item.name || ''), label: item.label }
: {
id: item.id || String(item.name || ''),
label: <span style={{ fontWeight: 700, fontSize: 10, letterSpacing: 1.5 }}>{item.name}</span>,
};
});
const isNetzwerkPage = (page: CmsRecord) =>
page.spaPath === '/netzwerk' || page.slug === 'netzwerk' || page.slug === 'network'
function partnerLogos(partners: PartnerLogoData[], defaultLogoColor: string) {
return partners.map((partner, index) => ({
id: String(partner.stableId || partner.id || partner.name || index),
label: (
<span
aria-label={fallbackText(partner.name, 'Partner')}
style={{
width: 224,
height: 54,
padding: '9px 18px',
background: 'rgba(255,255,255,0.92)',
border: '1px solid rgba(255,255,255,0.18)',
borderRadius: 6,
boxShadow: '0 8px 24px rgba(0,0,0,0.12)',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
}}
>
<PartnerLogoMark partner={partner} defaultLogoColor={defaultLogoColor} />
</span>
),
}));
}
function sortedPartners<T extends PartnerLogoData>(partners: T[]) {
return partners
.map((partner, index) => ({ partner, index }))
.sort((a, b) => Number(a.partner.sortOrder ?? a.index) - Number(b.partner.sortOrder ?? b.index))
.map(({ partner }) => partner);
}
function HomeWinnerCard({
@@ -187,6 +215,20 @@ const Home: React.FC = () => {
const videoRef = useRef<HTMLVideoElement>(null);
const isMobile = useIsMobile();
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 partnerSource = cmsPartners.length ? cmsPartners : (netzwerkContent.partners.items as PartnerLogoData[]);
const networkPartners = sortedPartners(partnerSource)
.filter((partner) => partner.active !== false && partner.showOnHomepage !== false)
.map((partner) => ({
...partner,
id: partner.stableId || partner.id,
logoColor: fallbackText(partner.logoColor, defaultLogoColor),
}));
const hero = home.hero || {};
const quickCheck = home.quickCheck || {};
const intro = home.intro || {};
@@ -301,11 +343,11 @@ const Home: React.FC = () => {
<div className="px-8 py-5 flex items-center justify-between opacity-60 hover:opacity-100 transition-opacity duration-500">
{/* Left: label – stays put */}
<span style={{ fontFamily: '"IBM Plex Sans", sans-serif', fontSize: 9, fontWeight: 700, letterSpacing: '0.3em', textTransform: 'uppercase', color: 'rgba(255,255,255,0.5)', whiteSpace: 'nowrap', flexShrink: 0 }}>{fallbackText(hero.partnersLabel, homeContent.hero.partnersLabel)}</span>
{/* Right: ticker – fixed window, exactly 4 logos wide */}
{/* Right: ticker window */}
<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: 560, overflow: 'hidden', flexShrink: 0 }}>
<PartnerTicker logos={partnerLogos(hero.partners)} />
<div style={{ width: 'min(820px, calc(100vw - 260px))', overflow: 'hidden', flexShrink: 0 }}>
<PartnerTicker logos={partnerLogos(networkPartners, defaultLogoColor)} />
</div>
</div>
</div>

View File

@@ -9,6 +9,7 @@ import { mediaAlt, mediaUrl } from '@/spa/cmsMediaField';
import { homeContent } from '@/spa/homeContent';
import { netzwerkContent } from '@/spa/netzwerkContent';
import Image from '@/spa/components/ui/UnoptimizedImage'
import { PartnerLogoMark, type PartnerLogoData } from '@/spa/components/ui/partner-logo';
const FF = '"IBM Plex Sans", sans-serif';
const FB = '"Inter", sans-serif';
@@ -44,7 +45,17 @@ type HomeVideoCms = {
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 }
type Partner = (typeof netzwerkContent.partners.items)[number] &
PartnerLogoData & {
active?: boolean | null
heroImg?: string
linkedin?: string
logoSubtext?: string
showOnHomepage?: boolean | null
sortOrder?: number | null
stableId?: string | null
tier: 1 | 2 | 3
}
type PartnerTier = (typeof netzwerkContent.partners.tiers)[number]
type ExpertiseRow = (typeof netzwerkContent.expertise.rows)[number]
type StatItem = (typeof netzwerkContent.juryIntro.stats)[number]
@@ -54,6 +65,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 sortedPartners = <T extends PartnerLogoData>(partners: T[]) =>
partners
.map((partner, index) => ({ partner, index }))
.sort((a, b) => Number(a.partner.sortOrder ?? a.index) - Number(b.partner.sortOrder ?? b.index))
.map(({ partner }) => partner)
const isMembershipCta = (cta?: { label?: string | null; url?: string | null } | null) => {
const normalizedLabel = cta?.label?.trim().toLowerCase();
@@ -185,38 +201,6 @@ const JuryChairCard: React.FC<{ member: JuryMember; badge: string }> = ({ member
// ── SponsorCell (tiered) ──────────────────────────────────────────────────────
function PartnerLogo({ partner }: { partner: Partner }) {
const color = fallbackText(partner.logoColor, netzwerkContent.partners.defaultLogoColor);
const text = fallbackText(partner.logoText, partner.name);
const subtext = fallbackText(partner.logoSubtext, '');
if (partner.logoKind === 'badge') {
return <span style={{ background: color, color: '#fff', fontWeight: 900, fontSize: 14, padding: '4px 10px', lineHeight: 1.25, display: 'inline-block', textAlign: 'center' as const, fontFamily: FF }}>{text.split('\n').map((line, index) => <React.Fragment key={`${line}-${index}`}>{index > 0 && <br />}{line}</React.Fragment>)}</span>
}
if (partner.logoKind === 'monogram') {
return <span style={{ display: 'flex', alignItems: 'center', gap: 6 }}><span style={{ width: 22, height: 22, background: '#3a3a3a', color: '#fff', display: 'flex', alignItems: 'center', justifyContent: 'center', fontWeight: 900, fontSize: 11, fontFamily: FF }}>{subtext || text.slice(0, 2)}</span><span style={{ fontWeight: 700, fontSize: 15, color, fontFamily: FF }}>{text}</span></span>
}
if (partner.logoKind === 'dot') {
return <span style={{ display: 'flex', alignItems: 'center', gap: 6 }}><span style={{ width: 20, height: 20, borderRadius: '50%', background: color, display: 'block' }} /><span style={{ fontWeight: 700, fontSize: 16, color, fontFamily: FF }}>{text}</span></span>
}
if (partner.logoKind === 'leaf') {
return <span style={{ display: 'flex', alignItems: 'center', gap: 6 }}><svg width="20" height="20" viewBox="0 0 20 20"><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={{ fontWeight: 700, fontSize: 15, color, fontFamily: FB }}>{text}</span></span>
}
if (partner.logoKind === 'rsm') {
return <span style={{ display: 'flex', alignItems: 'center', gap: 6 }}><span style={{ display: 'flex', gap: 2 }}><span style={{ width: 10, height: 10, background: '#6b7280', display: 'block' }} /><span style={{ width: 10, height: 10, background: '#22c55e', display: 'block' }} /><span style={{ width: 10, height: 10, background: '#3b82f6', display: 'block' }} /></span><span style={{ fontWeight: 900, fontSize: 15, letterSpacing: '-0.02em', color, fontFamily: FF }}>{text} {subtext && <span style={{ color: '#6b7280', fontWeight: 600 }}>{subtext}</span>}</span></span>
}
if (partner.logoKind === 'deutschebank') {
return <span style={{ display: 'flex', alignItems: 'center', gap: 8 }}><svg width="20" height="20" viewBox="0 0 20 20"><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={{ fontWeight: 600, fontSize: 15, color, fontFamily: FB }}>{text}</span></span>
}
return <span style={{ fontWeight: 900, fontSize: partner.logoKind === 'serif' ? 21 : 16, letterSpacing: partner.logoKind === 'serif' ? '0.12em' : '-0.01em', color, fontFamily: partner.logoKind === 'serif' ? 'Georgia, serif' : FF }}>{text}</span>
}
const SponsorCell: React.FC<{ partner: Partner; variant: 'lg' | 'md' | 'sm'; idx: number; cols: number; detailLabel: string; premiumLabel: string; onClick: () => void }> = ({ partner, variant, idx, cols, detailLabel, premiumLabel, onClick }) => {
const [hovered, setHovered] = useState(false);
const isMobileCell = useIsMobile();
@@ -242,7 +226,7 @@ const SponsorCell: React.FC<{ partner: Partner; variant: 'lg' | 'md' | 'sm'; idx
{lg && (
<span style={{ position: 'absolute', top: 18, right: 18, fontFamily: FF, fontSize: 9, fontWeight: 700, textTransform: 'uppercase', letterSpacing: '0.18em', color: '#101828', background: GOLD, padding: '4px 10px', borderRadius: 100, zIndex: 1 }}>{premiumLabel}</span>
)}
<div style={{ height: lg ? 52 : 44, display: 'flex', alignItems: 'center' }}><PartnerLogo partner={partner} /></div>
<div style={{ height: lg ? 52 : 44, display: 'flex', alignItems: 'center' }}><PartnerLogoMark partner={partner} /></div>
<div style={{ width: hovered ? 48 : 20, 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: sm ? 10 : 11, color: GOLD, textTransform: 'uppercase', letterSpacing: '0.2em', fontWeight: 700 }}>{partner.role}</div>
@@ -353,7 +337,7 @@ function PartnerModal({ partner, copy, onClose }: { partner: Partner; copy: Part
display: 'flex', alignItems: 'center', justifyContent: 'center',
padding: 10, zIndex: 2,
}}>
<PartnerLogo partner={partner} />
<PartnerLogoMark partner={partner} />
</div>
</div>
@@ -466,6 +450,7 @@ const Netzwerk: React.FC = () => {
const greetingVideoRef = useRef<HTMLVideoElement>(null);
const cms = (useCmsRoute()?.doc?.netzwerk || {}) as NetzwerkCms;
const homePage = useCmsCollection('pages').find((page) => page.spaPath === '/' || page.slug === 'startseite' || page.slug === 'home');
const cmsPartners = useCmsCollection('partners') as unknown as Partner[];
const home = (homePage?.home || {}) as HomeVideoCms;
const homeVideoModal = home.videoModal || {};
const hero = { ...netzwerkContent.hero, ...(cms.hero || {}) };
@@ -480,11 +465,14 @@ const Netzwerk: React.FC = () => {
const juryMembers = fallbackArray<JuryMember>(jury.members, netzwerkContent.jury.members);
const expertiseRows = fallbackArray<ExpertiseRow>(expertise.rows, netzwerkContent.expertise.rows);
const defaultLogoColor = fallbackText(partnerSection.defaultLogoColor, netzwerkContent.partners.defaultLogoColor);
const partners = fallbackArray<Partner>(partnerSection.items, netzwerkContent.partners.items as Partner[]).map((partner) => ({
...partner,
id: partner.stableId || partner.id,
logoColor: fallbackText(partner.logoColor, defaultLogoColor),
}));
const partnerSource = cmsPartners.length ? cmsPartners : (netzwerkContent.partners.items as Partner[]);
const partners = sortedPartners(partnerSource)
.filter((partner) => partner.active !== false)
.map((partner) => ({
...partner,
id: partner.stableId || partner.id,
logoColor: fallbackText(partner.logoColor, defaultLogoColor),
}));
const partnerTiers = fallbackArray<PartnerTier>(partnerSection.tiers, netzwerkContent.partners.tiers);
const mobileStats = fallbackArray<StatItem>(juryIntro.stats, netzwerkContent.juryIntro.stats);
const desktopStats = fallbackArray<StatItem>(juryIntro.desktopStats, netzwerkContent.juryIntro.desktopStats);