feat(network): add responsive sponsor hierarchy

This commit is contained in:
syntaxbullet
2026-07-31 16:41:02 +02:00
parent ebecf66e6f
commit 30efb4b36e
11 changed files with 438 additions and 37 deletions

View File

@@ -201,10 +201,13 @@ export const netzwerkFields: Field[] = [
text('detailLabel', 'Detail label', netzwerkContent.partners.detailLabel),
{
name: 'tiers',
label: 'Tiers',
label: 'Tier headings and supporting copy',
type: 'array',
dbName: 'network_tiers',
defaultValue: netzwerkContent.partners.tiers,
admin: {
description: 'One copy row per sponsor tier. Partner membership and order come from each Partner record.',
},
fields: [numberField('tier', 'Tier'), text('label', 'Label'), text('note', 'Note')],
},
{

View File

@@ -0,0 +1,66 @@
import { type MigrateDownArgs, type MigrateUpArgs, sql } from '@payloadcms/db-sqlite'
const literal = (value: string) => `'${value.replace(/'/g, "''")}'`
const previousDescription =
'Partner in drei Kategorien: Hauptsponsoren, Medienpartner und weitere Sponsoren. Klicken für Details.'
const interimDescription =
'Ein starkes Netzwerk aus Unternehmen und Medien begleitet den Bayerischen Mittelstandspreis. Klicken für Details.'
const sponsorDescription =
'Ein starkes Netzwerk aus Unternehmen und Medien begleitet den Bayerischen Mittelstandspreis. Die gestaffelten Bänder machen die Gewichtung auf jeder Bildschirmgröße klar.'
const tierCopy = [
{ tier: 1, previousLabel: 'Hauptsponsoren', label: 'Große Darstellung' },
{ tier: 2, previousLabel: 'Medienpartner', label: 'Mittlere Darstellung' },
{ tier: 3, previousLabel: 'Weitere Sponsoren', label: 'Kompakte Darstellung' },
] as const
async function updateTierLabels(
db: MigrateUpArgs['db'],
tableName: 'network_tiers' | '_network_tiers_v',
direction: 'up' | 'down',
) {
for (const copy of tierCopy) {
const from = direction === 'up' ? copy.previousLabel : copy.label
const to = direction === 'up' ? copy.label : copy.previousLabel
await db.run(
sql.raw(
`UPDATE ${tableName} SET label = ${literal(to)} WHERE tier = ${copy.tier} AND label = ${literal(from)};`,
),
)
}
}
export async function up({ db, payload: _payload, req: _req }: MigrateUpArgs): Promise<void> {
await db.run(
sql.raw(
`UPDATE pages SET netzwerk_partners_description = ${literal(sponsorDescription)} WHERE netzwerk_partners_description IN (${literal(previousDescription)}, ${literal(interimDescription)});`,
),
)
await db.run(
sql.raw(
`UPDATE _pages_v SET version_netzwerk_partners_description = ${literal(sponsorDescription)} WHERE version_netzwerk_partners_description IN (${literal(previousDescription)}, ${literal(interimDescription)});`,
),
)
await updateTierLabels(db, 'network_tiers', 'up')
await updateTierLabels(db, '_network_tiers_v', 'up')
await db.run(sql.raw(`UPDATE network_tiers SET note = '' WHERE tier = 1 AND label = 'Große Darstellung' AND note = 'Premium';`))
await db.run(sql.raw(`UPDATE _network_tiers_v SET note = '' WHERE tier = 1 AND label = 'Große Darstellung' AND note = 'Premium';`))
}
export async function down({ db, payload: _payload, req: _req }: MigrateDownArgs): Promise<void> {
await db.run(
sql.raw(
`UPDATE pages SET netzwerk_partners_description = ${literal(previousDescription)} WHERE netzwerk_partners_description = ${literal(sponsorDescription)};`,
),
)
await db.run(
sql.raw(
`UPDATE _pages_v SET version_netzwerk_partners_description = ${literal(previousDescription)} WHERE version_netzwerk_partners_description = ${literal(sponsorDescription)};`,
),
)
await updateTierLabels(db, 'network_tiers', 'down')
await updateTierLabels(db, '_network_tiers_v', 'down')
await db.run(sql.raw(`UPDATE network_tiers SET note = 'Premium' WHERE tier = 1 AND label = 'Hauptsponsoren' AND note = '';`))
await db.run(sql.raw(`UPDATE _network_tiers_v SET note = 'Premium' WHERE tier = 1 AND label = 'Hauptsponsoren' AND note = '';`))
}

View File

@@ -19,6 +19,7 @@ import * as migration_20260731_143000_remove_press_record_fallbacks from './2026
import * as migration_20260731_160000_focused_payload_cleanup from './20260731_160000_focused_payload_cleanup';
import * as migration_20260731_170000_netzwerk_greeting_video from './20260731_170000_netzwerk_greeting_video';
import * as migration_20260731_180000_press_contact_image from './20260731_180000_press_contact_image';
import * as migration_20260731_190000_responsive_sponsor_hierarchy from './20260731_190000_responsive_sponsor_hierarchy';
export const migrations = [
{
@@ -126,4 +127,9 @@ export const migrations = [
down: migration_20260731_180000_press_contact_image.down,
name: '20260731_180000_press_contact_image',
},
{
up: migration_20260731_190000_responsive_sponsor_hierarchy.up,
down: migration_20260731_190000_responsive_sponsor_hierarchy.down,
name: '20260731_190000_responsive_sponsor_hierarchy',
},
];

View File

@@ -1337,6 +1337,9 @@ export interface Page {
heading?: string | null;
description?: string | null;
detailLabel?: string | null;
/**
* One copy row per sponsor tier. Partner membership and order come from each Partner record.
*/
tiers?:
| {
tier?: number | null;

View File

@@ -135,7 +135,7 @@ async function main() {
const sponsoringImage = await mediaByFilename(payload, netzwerkContent.sponsoring.imageFilename)
const { imageFilename: _heroFilename, ...heroContent } = netzwerkContent.hero
const { imageFilename: _sponsoringFilename, ...sponsoringContent } = netzwerkContent.sponsoring
const { items: _partnerItems, ...partnerSectionContent } = netzwerkContent.partners
const { items: _partnerItems, tiers: partnerTiers, ...partnerSectionContent } = netzwerkContent.partners
const existingGreetingVideo = page.netzwerk?.patronage?.greetingVideo || null
const [patronPeople, juryMembers] = await Promise.all([
itemsWithImageUploads(payload, netzwerkContent.patronage.people),
@@ -169,6 +169,7 @@ async function main() {
},
partners: {
...partnerSectionContent,
tiers: partnerTiers.map(({ tier, label, note }) => ({ tier, label, note })),
},
},
} as never,

View File

@@ -124,7 +124,7 @@ export const netzwerkContent = {
partners: {
eyebrow: 'Netzwerkqualität',
heading: 'PARTNER & SPONSOREN',
description: 'Ein starkes Netzwerk aus Unternehmen und Medien begleitet den Bayerischen Mittelstandspreis. Klicken für Details.',
description: 'Ein starkes Netzwerk aus Unternehmen und Medien begleitet den Bayerischen Mittelstandspreis. Die gestaffelten Bänder machen die Gewichtung auf jeder Bildschirmgröße klar.',
detailLabel: 'Details',
tiers: [
{ tier: 1, label: 'Große Darstellung', note: '' },

View File

@@ -10,6 +10,15 @@ import { selectJuryDocs, sortJuryMembersQuoteFirst } from '@/spa/juryMembers';
import { netzwerkContent } from '@/spa/netzwerkContent';
import Image from '@/spa/components/ui/UnoptimizedImage'
import { PartnerLogoMark, type PartnerLogoData } from '@/spa/components/ui/partner-logo';
import {
groupSponsorsByTier,
sponsorCardBasis,
sponsorColumns,
sponsorTierNumbers,
sponsorViewport,
type SponsorTierNumber,
type SponsorViewport,
} from '@/spa/sponsorHierarchy';
const FF = '"IBM Plex Sans", sans-serif';
const FB = '"Inter", sans-serif';
@@ -51,17 +60,7 @@ 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 deprecatedPartnerDescription = 'Partner in drei Kategorien: Hauptsponsoren, Medienpartner und weitere Sponsoren. Klicken für Details.'
const visiblePartnerDescription = (value: unknown) => {
const text = fallbackText(value, netzwerkContent.partners.description)
return text.trim() === deprecatedPartnerDescription ? netzwerkContent.partners.description : text
}
const juryImageFallback = '/images/preistraeger-placeholder.svg'
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 normalizeJuryMember = (member: CmsRouteDoc): JuryMember => {
const organization = fallbackText(member.organization, '')
const rawRole = fallbackText(member.role, '')
@@ -225,27 +224,46 @@ const JuryChairCard: React.FC<{ member: JuryMember; badge: string }> = ({ member
// ── SponsorCell (tiered) ──────────────────────────────────────────────────────
const SponsorCell: React.FC<{ partner: Partner; variant: 'lg' | 'md' | 'sm'; idx: number; cols: number; detailLabel: string; onClick: () => void }> = ({ partner, variant, idx, cols, detailLabel, onClick }) => {
const SponsorCell: React.FC<{
partner: Partner
variant: 'lg' | 'md' | 'sm'
columns: number
detailLabel: string
gap: number
viewport: SponsorViewport
onClick: () => void
}> = ({ partner, variant, columns, detailLabel, gap, viewport, onClick }) => {
const [hovered, setHovered] = useState(false);
const isMobileCell = useIsMobile();
const lg = variant === 'lg';
const sm = variant === 'sm';
const lastCol = (idx % cols) === cols - 1;
const logoHeight = isMobileCell ? (lg ? 76 : sm ? 54 : 62) : lg ? 92 : sm ? 62 : 76;
const mobileCell = viewport === 'mobile' || viewport === 'narrow-mobile';
const logoHeight = mobileCell ? (lg ? 96 : sm ? 58 : 76) : lg ? 116 : sm ? 72 : 92;
const basis = sponsorCardBasis(columns, gap);
return (
<div
<button
type="button"
aria-label={`${detailLabel}: ${partner.name}`}
data-testid={`sponsor-card-${partner.id}`}
onClick={onClick}
onMouseEnter={() => setHovered(true)}
onMouseLeave={() => setHovered(false)}
style={{
padding: isMobileCell ? '26px 20px' : lg ? '44px 38px 40px' : sm ? '30px 28px' : '36px 32px',
borderRight: !lastCol && !isMobileCell ? '1px solid rgba(3,9,58,0.08)' : 'none',
borderBottom: '1px solid rgba(3,9,58,0.08)',
appearance: 'none',
boxSizing: 'border-box',
width: basis,
flex: `0 0 ${basis}`,
minWidth: 0,
padding: mobileCell ? (sm ? '22px 16px' : '30px 24px') : lg ? '48px 42px 44px' : sm ? '30px 26px' : '38px 34px',
border: '1px solid rgba(3,9,58,0.1)',
borderTop: `${lg ? 4 : sm ? 2 : 3}px solid ${GOLD}`,
display: 'flex', flexDirection: 'column', gap: lg ? 18 : 16,
cursor: 'pointer',
background: hovered ? '#E8E5E4' : 'transparent',
background: hovered ? '#E8E5E4' : 'rgba(255,255,255,0.92)',
transition: 'background 0.15s',
position: 'relative',
textAlign: 'left',
color: 'inherit',
font: 'inherit',
}}
>
<div style={{ height: logoHeight, display: 'flex', alignItems: 'center', justifyContent: 'center', width: '100%', minWidth: 0 }}>
@@ -257,7 +275,7 @@ const SponsorCell: React.FC<{ partner: Partner; variant: 'lg' | 'md' | 'sm'; idx
<div style={{ fontFamily: FF, fontSize: 11, textTransform: 'uppercase', letterSpacing: '0.12em', color: GOLD, fontWeight: 700, display: 'flex', alignItems: 'center', gap: 4 }}>
{detailLabel} <ArrowRight size={10} />
</div>
</div>
</button>
);
};
@@ -452,6 +470,8 @@ function PartnerModal({ partner, copy, onClose }: { partner: Partner; copy: Part
const Netzwerk: React.FC = () => {
const isMobile = useIsMobile();
const isTabletOrMobile = useIsMobile(1200);
const isNarrowMobile = useIsMobile(360);
const [selectedPartner, setSelectedPartner] = useState<Partner | null>(null);
const [showGreetingVideo, setShowGreetingVideo] = useState(false);
const greetingVideoRef = useRef<HTMLVideoElement>(null);
@@ -474,13 +494,20 @@ const Netzwerk: React.FC = () => {
.filter((member) => member.active !== false && member.name);
const juryMembers = sortJuryMembersQuoteFirst(normalizedJuryMembers);
const expertiseRows = fallbackArray<ExpertiseRow>(expertise.rows, netzwerkContent.expertise.rows);
const partners = sortedPartners(cmsPartners)
const partners = cmsPartners
.filter((partner) => partner.active !== false)
.map((partner) => ({
...partner,
id: partner.stableId || partner.id,
}));
const partnerTiers = fallbackArray<PartnerTier>(partnerSection.tiers, netzwerkContent.partners.tiers);
const configuredPartnerTiers = fallbackArray<PartnerTier>(partnerSection.tiers, netzwerkContent.partners.tiers);
const partnerTiers = sponsorTierNumbers.map((tier) => ({
...(netzwerkContent.partners.tiers.find((entry) => entry.tier === tier) as PartnerTier),
...(configuredPartnerTiers.find((entry) => Number(entry.tier) === tier) || {}),
tier,
}));
const sponsorsByTier = groupSponsorsByTier(partners);
const sponsorLayoutViewport = sponsorViewport({ isMobile, isNarrowMobile, isTabletOrMobile });
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);
@@ -741,23 +768,61 @@ const Netzwerk: React.FC = () => {
<h2 className="type-section-title text-role-heading" style={{ textTransform: 'uppercase', margin: 0 }}><Lines text={fallbackText(partnerSection.heading, netzwerkContent.partners.heading)} /></h2>
</div>
<p className="type-body text-role-secondary" style={{ margin: 0 }}>
{visiblePartnerDescription(partnerSection.description)}
{fallbackText(partnerSection.description, netzwerkContent.partners.description)}
</p>
</div>
{/* Tiered grid – continuous, ranks marked by label bars */}
<div style={{ position: 'relative', zIndex: 1 }}>
{/* Independent tier bands keep rank legible without mixing tiers to fill a row. */}
<div style={{ position: 'relative', zIndex: 1, padding: isMobile ? '16px 20px 52px' : '24px 48px 80px' }}>
{partnerTiers.map(t => {
const tier = Number(t.tier) as 1 | 2 | 3;
const tier = Number(t.tier) as SponsorTierNumber;
const variant = tier === 1 ? 'lg' as const : tier === 2 ? 'md' as const : 'sm' as const;
const cols = isMobile ? (tier === 1 ? 1 : 2) : (tier === 3 ? 4 : 2);
const items = partners.filter(p => p.tier === tier);
const columns = sponsorColumns(tier, sponsorLayoutViewport);
const gap = isMobile ? 12 : tier === 3 ? 16 : 20;
const items = sponsorsByTier[tier];
const label = fallbackText(t.label, netzwerkContent.partners.tiers[tier - 1]?.label || '');
const note = fallbackText(t.note, '');
if (!items.length) return null;
return (
<div key={t.tier} style={{ display: 'grid', gridTemplateColumns: `repeat(${cols}, 1fr)` }}>
{items.map((p, i) => (
<SponsorCell key={p.id} partner={p} variant={variant} idx={i} cols={cols} detailLabel={fallbackText(partnerSection.detailLabel, netzwerkContent.partners.detailLabel)} onClick={() => setSelectedPartner(p)} />
))}
</div>
<section
key={tier}
data-testid={`sponsor-tier-${tier}`}
data-tier={tier}
data-columns={columns}
data-viewport={sponsorLayoutViewport}
style={{
maxWidth: 1240,
margin: '0 auto',
padding: isMobile ? (tier === 1 ? '34px 0 14px' : '42px 0 14px') : (tier === 1 ? '50px 0 18px' : '62px 0 18px'),
borderBottom: tier === 3 ? 'none' : '1px solid rgba(3,9,58,0.08)',
}}
>
<header style={{ maxWidth: tier === 1 ? 720 : 620, margin: '0 auto', textAlign: 'center', padding: isMobile ? '0 8px 24px' : '0 16px 32px' }}>
<h3 style={{ fontFamily: FF, fontSize: isMobile ? (tier === 1 ? 25 : 21) : (tier === 1 ? 34 : tier === 2 ? 28 : 23), fontWeight: 800, color: NAVY, textTransform: 'uppercase', letterSpacing: tier === 3 ? '0.08em' : '0.04em', lineHeight: 1.1, margin: 0 }}>
{label}
</h3>
{note ? (
<p style={{ fontFamily: FB, fontSize: isMobile ? 14 : 16, color: 'rgba(16,24,40,0.58)', lineHeight: 1.55, margin: '12px 0 0' }}>{note}</p>
) : null}
</header>
<div
data-testid={`sponsor-tier-${tier}-cards`}
style={{ display: 'flex', flexWrap: 'wrap', justifyContent: 'center', alignItems: 'stretch', gap }}
>
{items.map((partner) => (
<SponsorCell
key={partner.id}
partner={partner}
variant={variant}
columns={columns}
detailLabel={fallbackText(partnerSection.detailLabel, netzwerkContent.partners.detailLabel)}
gap={gap}
viewport={sponsorLayoutViewport}
onClick={() => setSelectedPartner(partner)}
/>
))}
</div>
</section>
);
})}
</div>

View File

@@ -0,0 +1,59 @@
export const sponsorTierNumbers = [1, 2, 3] as const
export type SponsorTierNumber = (typeof sponsorTierNumbers)[number]
export type SponsorViewport = 'desktop' | 'tablet' | 'mobile' | 'narrow-mobile'
export type SponsorRecord = {
sortOrder?: number | null
tier: SponsorTierNumber
}
export function groupSponsorsByTier<T extends SponsorRecord>(partners: T[]): Record<SponsorTierNumber, T[]> {
const grouped: Record<SponsorTierNumber, Array<{ index: number; partner: T }>> = {
1: [],
2: [],
3: [],
}
partners.forEach((partner, index) => {
if (sponsorTierNumbers.includes(partner.tier)) grouped[partner.tier].push({ index, partner })
})
return {
1: grouped[1]
.sort((a, b) => Number(a.partner.sortOrder ?? a.index) - Number(b.partner.sortOrder ?? b.index))
.map(({ partner }) => partner),
2: grouped[2]
.sort((a, b) => Number(a.partner.sortOrder ?? a.index) - Number(b.partner.sortOrder ?? b.index))
.map(({ partner }) => partner),
3: grouped[3]
.sort((a, b) => Number(a.partner.sortOrder ?? a.index) - Number(b.partner.sortOrder ?? b.index))
.map(({ partner }) => partner),
}
}
export function sponsorColumns(tier: SponsorTierNumber, viewport: SponsorViewport): number {
if (viewport === 'desktop') return tier === 1 ? 2 : tier === 2 ? 3 : 4
if (viewport === 'tablet') return tier === 3 ? 3 : 2
if (viewport === 'narrow-mobile') return 1
return tier === 3 ? 2 : 1
}
export function sponsorViewport({
isMobile,
isNarrowMobile,
isTabletOrMobile,
}: {
isMobile: boolean
isNarrowMobile: boolean
isTabletOrMobile: boolean
}): SponsorViewport {
if (isNarrowMobile) return 'narrow-mobile'
if (isMobile) return 'mobile'
if (isTabletOrMobile) return 'tablet'
return 'desktop'
}
export function sponsorCardBasis(columns: number, gap: number): string {
return `calc((100% - ${(columns - 1) * gap}px) / ${columns})`
}