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

@@ -32,7 +32,7 @@ This inventory records the frontend or platform owner for every retained `Pages`
| `pressIndex.hero.*`, `pressIndex.events.*`, `pressIndex.news.*`, `pressIndex.downloads.*` | `src/spa/pages/Press.tsx`; event/article records remain collection-owned, while missing-field labels and images are presentation fallbacks only. `pressIndex.downloads.contactImage` and `contactImageAlt` own the optional Presse contact visual; an empty relationship renders the same copy in a compact copy-only layout without a static image fallback. | | `pressIndex.hero.*`, `pressIndex.events.*`, `pressIndex.news.*`, `pressIndex.downloads.*` | `src/spa/pages/Press.tsx`; event/article records remain collection-owned, while missing-field labels and images are presentation fallbacks only. `pressIndex.downloads.contactImage` and `contactImageAlt` own the optional Presse contact visual; an empty relationship renders the same copy in a compact copy-only layout without a static image fallback. |
| `netzwerk.hero.*`, `netzwerk.patronage.*`, `netzwerk.juryIntro.*`, `netzwerk.expertise.*`, `netzwerk.sponsoring.*` | `src/spa/pages/Netzwerk.tsx`. The page-owned `netzwerk.patronage.greetingVideo` Media relationship and its adjacent button/modal copy control only the Ilse Aigner greeting on `/netzwerk`; they never inherit `home.videoModal.*`. | | `netzwerk.hero.*`, `netzwerk.patronage.*`, `netzwerk.juryIntro.*`, `netzwerk.expertise.*`, `netzwerk.sponsoring.*` | `src/spa/pages/Netzwerk.tsx`. The page-owned `netzwerk.patronage.greetingVideo` Media relationship and its adjacent button/modal copy control only the Ilse Aigner greeting on `/netzwerk`; they never inherit `home.videoModal.*`. |
| `netzwerk.jury.{eyebrow,heading,description,chairBadge,members}` | Jury heading and authoritative rendered relationship selection in `src/spa/pages/Netzwerk.tsx`. | | `netzwerk.jury.{eyebrow,heading,description,chairBadge,members}` | Jury heading and authoritative rendered relationship selection in `src/spa/pages/Netzwerk.tsx`. |
| `netzwerk.partners.{eyebrow,heading,description,detailLabel,tiers,modal}` | Partner section, tier labels, and modal copy in `src/spa/pages/Netzwerk.tsx`; partner records remain collection-owned. | | `netzwerk.partners.{eyebrow,heading,description,detailLabel,tiers[].{label,note},modal}` | All visible sponsor-section editorial copy, including each rendered tier heading/note and the shared card CTA, in `src/spa/pages/Netzwerk.tsx`; partner names, descriptions, links, logos, tier assignment, and ordering remain Partner collection-owned. Counts are derived and are not separately editable. |
| `netzwerk.sponsoringForm.*` | The two visible form steps, validation, success state, and footer copy in `src/spa/components/forms/SponsoringForm.tsx`. | | `netzwerk.sponsoringForm.*` | The two visible form steps, validation, success state, and footer copy in `src/spa/components/forms/SponsoringForm.tsx`. |
## Global ownership ## Global ownership

View File

@@ -201,10 +201,13 @@ export const netzwerkFields: Field[] = [
text('detailLabel', 'Detail label', netzwerkContent.partners.detailLabel), text('detailLabel', 'Detail label', netzwerkContent.partners.detailLabel),
{ {
name: 'tiers', name: 'tiers',
label: 'Tiers', label: 'Tier headings and supporting copy',
type: 'array', type: 'array',
dbName: 'network_tiers', dbName: 'network_tiers',
defaultValue: netzwerkContent.partners.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')], 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_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_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_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 = [ export const migrations = [
{ {
@@ -126,4 +127,9 @@ export const migrations = [
down: migration_20260731_180000_press_contact_image.down, down: migration_20260731_180000_press_contact_image.down,
name: '20260731_180000_press_contact_image', 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; heading?: string | null;
description?: string | null; description?: string | null;
detailLabel?: string | null; detailLabel?: string | null;
/**
* One copy row per sponsor tier. Partner membership and order come from each Partner record.
*/
tiers?: tiers?:
| { | {
tier?: number | null; tier?: number | null;

View File

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

View File

@@ -124,7 +124,7 @@ export const netzwerkContent = {
partners: { partners: {
eyebrow: 'Netzwerkqualität', eyebrow: 'Netzwerkqualität',
heading: 'PARTNER & SPONSOREN', 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', detailLabel: 'Details',
tiers: [ tiers: [
{ tier: 1, label: 'Große Darstellung', note: '' }, { 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 { netzwerkContent } from '@/spa/netzwerkContent';
import Image from '@/spa/components/ui/UnoptimizedImage' import Image from '@/spa/components/ui/UnoptimizedImage'
import { PartnerLogoMark, type PartnerLogoData } from '@/spa/components/ui/partner-logo'; 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 FF = '"IBM Plex Sans", sans-serif';
const FB = '"Inter", 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 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 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 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 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 normalizeJuryMember = (member: CmsRouteDoc): JuryMember => {
const organization = fallbackText(member.organization, '') const organization = fallbackText(member.organization, '')
const rawRole = fallbackText(member.role, '') const rawRole = fallbackText(member.role, '')
@@ -225,27 +224,46 @@ const JuryChairCard: React.FC<{ member: JuryMember; badge: string }> = ({ member
// ── SponsorCell (tiered) ────────────────────────────────────────────────────── // ── 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 [hovered, setHovered] = useState(false);
const isMobileCell = useIsMobile();
const lg = variant === 'lg'; const lg = variant === 'lg';
const sm = variant === 'sm'; const sm = variant === 'sm';
const lastCol = (idx % cols) === cols - 1; const mobileCell = viewport === 'mobile' || viewport === 'narrow-mobile';
const logoHeight = isMobileCell ? (lg ? 76 : sm ? 54 : 62) : lg ? 92 : sm ? 62 : 76; const logoHeight = mobileCell ? (lg ? 96 : sm ? 58 : 76) : lg ? 116 : sm ? 72 : 92;
const basis = sponsorCardBasis(columns, gap);
return ( return (
<div <button
type="button"
aria-label={`${detailLabel}: ${partner.name}`}
data-testid={`sponsor-card-${partner.id}`}
onClick={onClick} onClick={onClick}
onMouseEnter={() => setHovered(true)} onMouseEnter={() => setHovered(true)}
onMouseLeave={() => setHovered(false)} onMouseLeave={() => setHovered(false)}
style={{ style={{
padding: isMobileCell ? '26px 20px' : lg ? '44px 38px 40px' : sm ? '30px 28px' : '36px 32px', appearance: 'none',
borderRight: !lastCol && !isMobileCell ? '1px solid rgba(3,9,58,0.08)' : 'none', boxSizing: 'border-box',
borderBottom: '1px solid rgba(3,9,58,0.08)', 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, display: 'flex', flexDirection: 'column', gap: lg ? 18 : 16,
cursor: 'pointer', cursor: 'pointer',
background: hovered ? '#E8E5E4' : 'transparent', background: hovered ? '#E8E5E4' : 'rgba(255,255,255,0.92)',
transition: 'background 0.15s', transition: 'background 0.15s',
position: 'relative', position: 'relative',
textAlign: 'left',
color: 'inherit',
font: 'inherit',
}} }}
> >
<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 }}>
@@ -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 }}> <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} /> {detailLabel} <ArrowRight size={10} />
</div> </div>
</div> </button>
); );
}; };
@@ -452,6 +470,8 @@ function PartnerModal({ partner, copy, onClose }: { partner: Partner; copy: Part
const Netzwerk: React.FC = () => { const Netzwerk: React.FC = () => {
const isMobile = useIsMobile(); const isMobile = useIsMobile();
const isTabletOrMobile = useIsMobile(1200);
const isNarrowMobile = useIsMobile(360);
const [selectedPartner, setSelectedPartner] = useState<Partner | null>(null); const [selectedPartner, setSelectedPartner] = useState<Partner | null>(null);
const [showGreetingVideo, setShowGreetingVideo] = useState(false); const [showGreetingVideo, setShowGreetingVideo] = useState(false);
const greetingVideoRef = useRef<HTMLVideoElement>(null); const greetingVideoRef = useRef<HTMLVideoElement>(null);
@@ -474,13 +494,20 @@ const Netzwerk: React.FC = () => {
.filter((member) => member.active !== false && member.name); .filter((member) => member.active !== false && member.name);
const juryMembers = sortJuryMembersQuoteFirst(normalizedJuryMembers); const juryMembers = sortJuryMembersQuoteFirst(normalizedJuryMembers);
const expertiseRows = fallbackArray<ExpertiseRow>(expertise.rows, netzwerkContent.expertise.rows); const expertiseRows = fallbackArray<ExpertiseRow>(expertise.rows, netzwerkContent.expertise.rows);
const partners = sortedPartners(cmsPartners) const partners = cmsPartners
.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,
})); }));
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 mobileStats = fallbackArray<StatItem>(juryIntro.stats, netzwerkContent.juryIntro.stats);
const desktopStats = fallbackArray<StatItem>(juryIntro.desktopStats, netzwerkContent.juryIntro.desktopStats); const desktopStats = fallbackArray<StatItem>(juryIntro.desktopStats, netzwerkContent.juryIntro.desktopStats);
const sponsorBenefits = fallbackArray<BenefitItem>(sponsoring.benefits, netzwerkContent.sponsoring.benefits); 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> <h2 className="type-section-title text-role-heading" style={{ textTransform: 'uppercase', margin: 0 }}><Lines text={fallbackText(partnerSection.heading, netzwerkContent.partners.heading)} /></h2>
</div> </div>
<p className="type-body text-role-secondary" style={{ margin: 0 }}> <p className="type-body text-role-secondary" style={{ margin: 0 }}>
{visiblePartnerDescription(partnerSection.description)} {fallbackText(partnerSection.description, netzwerkContent.partners.description)}
</p> </p>
</div> </div>
{/* Tiered grid continuous, ranks marked by label bars */} {/* Independent tier bands keep rank legible without mixing tiers to fill a row. */}
<div style={{ position: 'relative', zIndex: 1 }}> <div style={{ position: 'relative', zIndex: 1, padding: isMobile ? '16px 20px 52px' : '24px 48px 80px' }}>
{partnerTiers.map(t => { {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 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 columns = sponsorColumns(tier, sponsorLayoutViewport);
const items = partners.filter(p => p.tier === tier); 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 ( return (
<div key={t.tier} style={{ display: 'grid', gridTemplateColumns: `repeat(${cols}, 1fr)` }}> <section
{items.map((p, i) => ( key={tier}
<SponsorCell key={p.id} partner={p} variant={variant} idx={i} cols={cols} detailLabel={fallbackText(partnerSection.detailLabel, netzwerkContent.partners.detailLabel)} onClick={() => setSelectedPartner(p)} /> data-testid={`sponsor-tier-${tier}`}
))} data-tier={tier}
</div> 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> </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})`
}

View File

@@ -0,0 +1,183 @@
import { cleanup, render, screen, within } from '@testing-library/react'
import { readFileSync } from 'node:fs'
import { join } from 'node:path'
import { afterEach, beforeAll, describe, expect, it, vi } from 'vitest'
import { CmsRouteProvider, type CmsRouteData } from '@/spa/cmsRoute'
import { netzwerkContent } from '@/spa/netzwerkContent'
import Netzwerk from '@/spa/pages/Netzwerk'
import {
groupSponsorsByTier,
sponsorCardBasis,
sponsorColumns,
sponsorViewport,
} from '@/spa/sponsorHierarchy'
vi.mock('next/navigation', () => ({
usePathname: () => '/netzwerk',
useRouter: () => ({ push: vi.fn(), replace: vi.fn() }),
}))
let viewportWidth = 1440
const partner = (name: string, tier: 1 | 2 | 3, sortOrder: number) => ({
id: name.toLowerCase().replace(/\s/g, '-'),
stableId: name.toLowerCase().replace(/\s/g, '-'),
name,
tier,
sortOrder,
active: true,
desc: `${name} description`,
fullDesc: `${name} details`,
industry: `${name} industry`,
location: `${name} location`,
website: `${name.toLowerCase()}.example`,
})
const partners = [
partner('Tier One B', 1, 2),
partner('Tier Three H', 3, 8),
partner('Tier Two B', 2, 2),
partner('Tier Three A', 3, 1),
partner('Tier One A', 1, 1),
partner('Tier Three C', 3, 3),
partner('Tier Two A', 2, 1),
partner('Tier Three B', 3, 2),
partner('Tier Three D', 3, 4),
partner('Tier Three E', 3, 5),
partner('Tier Three F', 3, 6),
partner('Tier Three G', 3, 7),
]
const sponsorCopy = {
eyebrow: 'CMS sponsor eyebrow',
heading: 'CMS sponsor heading',
description: 'CMS sponsor supporting copy',
detailLabel: 'CMS sponsor action',
tiers: [
{ tier: 1, label: 'CMS feature tier', note: 'CMS feature tier note' },
{ tier: 2, label: 'CMS standard tier', note: 'CMS standard tier note' },
{ tier: 3, label: 'CMS compact tier', note: 'CMS compact tier note' },
],
}
const routeData = (): CmsRouteData => ({
collection: 'pages',
doc: {
id: 'network-page',
slug: 'netzwerk',
spaPath: '/netzwerk',
netzwerk: { partners: sponsorCopy },
},
lists: {
partners,
'jury-mitglieder': [],
},
})
function renderNetwork(width: number) {
viewportWidth = width
return render(
<CmsRouteProvider value={routeData()}>
<Netzwerk />
</CmsRouteProvider>,
)
}
describe('responsive sponsor hierarchy', () => {
beforeAll(() => {
Object.defineProperty(window, 'matchMedia', {
configurable: true,
value: vi.fn().mockImplementation((query: string) => {
const maximum = Number(query.match(/max-width:\s*(\d+)px/)?.[1] || Number.POSITIVE_INFINITY)
return {
addEventListener: vi.fn(),
matches: viewportWidth <= maximum,
media: query,
removeEventListener: vi.fn(),
}
}),
})
})
afterEach(() => cleanup())
it('groups by Partner tier and sorts each band by Partner sortOrder', () => {
const grouped = groupSponsorsByTier(partners)
expect(grouped[1].map(({ name }) => name)).toEqual(['Tier One A', 'Tier One B'])
expect(grouped[2].map(({ name }) => name)).toEqual(['Tier Two A', 'Tier Two B'])
expect(grouped[3].map(({ name }) => name)).toEqual([
'Tier Three A',
'Tier Three B',
'Tier Three C',
'Tier Three D',
'Tier Three E',
'Tier Three F',
'Tier Three G',
'Tier Three H',
])
})
it('defines the approved columns and conservative narrow-mobile fallback', () => {
expect([1, 2, 3].map((tier) => sponsorColumns(tier as 1 | 2 | 3, 'desktop'))).toEqual([2, 3, 4])
expect([1, 2, 3].map((tier) => sponsorColumns(tier as 1 | 2 | 3, 'tablet'))).toEqual([2, 2, 3])
expect([1, 2, 3].map((tier) => sponsorColumns(tier as 1 | 2 | 3, 'mobile'))).toEqual([1, 1, 2])
expect([1, 2, 3].map((tier) => sponsorColumns(tier as 1 | 2 | 3, 'narrow-mobile'))).toEqual([1, 1, 1])
expect(sponsorViewport({ isMobile: false, isNarrowMobile: false, isTabletOrMobile: true })).toBe('tablet')
expect(sponsorCardBasis(3, 16)).toBe('calc((100% - 32px) / 3)')
})
it('renders every visible sponsor-section label from Netzwerk CMS fields', () => {
renderNetwork(1440)
expect(screen.getByText(sponsorCopy.eyebrow)).toBeTruthy()
expect(screen.getByText(sponsorCopy.heading)).toBeTruthy()
expect(screen.getByText(sponsorCopy.description)).toBeTruthy()
sponsorCopy.tiers.forEach(({ label, note }) => {
expect(screen.getByRole('heading', { name: label })).toBeTruthy()
expect(screen.getByText(note)).toBeTruthy()
})
expect(screen.getAllByText(sponsorCopy.detailLabel)).toHaveLength(partners.length)
expect(screen.queryByText(netzwerkContent.partners.description)).toBeNull()
netzwerkContent.partners.tiers.forEach(({ label }) => expect(screen.queryByText(label)).toBeNull())
})
it.each([
{ width: 1440, viewport: 'desktop', columns: [2, 3, 4] },
{ width: 1024, viewport: 'tablet', columns: [2, 2, 3] },
{ width: 390, viewport: 'mobile', columns: [1, 1, 2] },
{ width: 340, viewport: 'narrow-mobile', columns: [1, 1, 1] },
])('centers independent bands at $width px', ({ width, viewport, columns }) => {
renderNetwork(width)
columns.forEach((columnCount, index) => {
const tier = index + 1
const band = screen.getByTestId(`sponsor-tier-${tier}`)
const cards = screen.getByTestId(`sponsor-tier-${tier}-cards`)
expect(band.getAttribute('data-columns')).toBe(String(columnCount))
expect(band.getAttribute('data-viewport')).toBe(viewport)
expect(cards.style.display).toBe('flex')
expect(cards.style.flexWrap).toBe('wrap')
expect(cards.style.justifyContent).toBe('center')
expect(within(band).getAllByRole('button')).toHaveLength(tier === 3 ? 8 : 2)
})
})
it('keeps sponsor defaults and legacy labels out of the rendered component source', () => {
const source = readFileSync(join(process.cwd(), 'src/spa/pages/Netzwerk.tsx'), 'utf8')
const visibleDefaults = [
netzwerkContent.partners.eyebrow,
netzwerkContent.partners.heading,
netzwerkContent.partners.description,
netzwerkContent.partners.detailLabel,
...netzwerkContent.partners.tiers.map(({ label }) => label),
'Partner in drei Kategorien: Hauptsponsoren, Medienpartner und weitere Sponsoren. Klicken für Details.',
]
visibleDefaults.forEach((copy) => {
expect(source).not.toContain(`'${copy}'`)
expect(source).not.toContain(`"${copy}"`)
})
})
})

View File

@@ -106,7 +106,13 @@ describe('Payload field inventory', () => {
'netzwerk.patronage.greetingVideoTitle', 'netzwerk.patronage.greetingVideoTitle',
'netzwerk.patronage.greetingVideoDescription', 'netzwerk.patronage.greetingVideoDescription',
'netzwerk.patronage.greetingVideoCloseLabel', 'netzwerk.patronage.greetingVideoCloseLabel',
'netzwerk.partners.eyebrow',
'netzwerk.partners.heading',
'netzwerk.partners.description',
'netzwerk.partners.detailLabel',
'netzwerk.partners.tiers', 'netzwerk.partners.tiers',
'netzwerk.partners.tiers.label',
'netzwerk.partners.tiers.note',
'netzwerk.partners.modal.websiteLabel', 'netzwerk.partners.modal.websiteLabel',
'netzwerk.sponsoringForm.steps', 'netzwerk.sponsoringForm.steps',
'netzwerk.sponsoringForm.requiredFieldError', 'netzwerk.sponsoringForm.requiredFieldError',
@@ -153,6 +159,15 @@ describe('Payload field inventory', () => {
expect(field).not.toHaveProperty('defaultValue') expect(field).not.toHaveProperty('defaultValue')
}) })
it('keeps sponsor tier copy separate from collection-owned tier assignment and ordering', () => {
expect(findField(netzwerkFields, 'netzwerk.partners.tiers')).toMatchObject({
type: 'array',
dbName: 'network_tiers',
})
expect(findField(netzwerkFields, 'netzwerk.partners.tiers.label')).toMatchObject({ type: 'text' })
expect(findField(netzwerkFields, 'netzwerk.partners.tiers.note')).toMatchObject({ type: 'text' })
})
it('limits the press contact upload to image media', () => { it('limits the press contact upload to image media', () => {
const field = findField(pressIndexFields, 'pressIndex.downloads.contactImage') const field = findField(pressIndexFields, 'pressIndex.downloads.contactImage')