feat(network): add responsive sponsor hierarchy
This commit is contained in:
@@ -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. |
|
||||
| `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.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`. |
|
||||
|
||||
## Global ownership
|
||||
|
||||
@@ -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')],
|
||||
},
|
||||
{
|
||||
|
||||
@@ -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 = '';`))
|
||||
}
|
||||
@@ -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',
|
||||
},
|
||||
];
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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: '' },
|
||||
|
||||
@@ -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>
|
||||
|
||||
59
src/spa/sponsorHierarchy.ts
Normal file
59
src/spa/sponsorHierarchy.ts
Normal 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})`
|
||||
}
|
||||
183
tests/int/netzwerk-sponsor-hierarchy.int.spec.tsx
Normal file
183
tests/int/netzwerk-sponsor-hierarchy.int.spec.tsx
Normal 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}"`)
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -106,7 +106,13 @@ describe('Payload field inventory', () => {
|
||||
'netzwerk.patronage.greetingVideoTitle',
|
||||
'netzwerk.patronage.greetingVideoDescription',
|
||||
'netzwerk.patronage.greetingVideoCloseLabel',
|
||||
'netzwerk.partners.eyebrow',
|
||||
'netzwerk.partners.heading',
|
||||
'netzwerk.partners.description',
|
||||
'netzwerk.partners.detailLabel',
|
||||
'netzwerk.partners.tiers',
|
||||
'netzwerk.partners.tiers.label',
|
||||
'netzwerk.partners.tiers.note',
|
||||
'netzwerk.partners.modal.websiteLabel',
|
||||
'netzwerk.sponsoringForm.steps',
|
||||
'netzwerk.sponsoringForm.requiredFieldError',
|
||||
@@ -153,6 +159,15 @@ describe('Payload field inventory', () => {
|
||||
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', () => {
|
||||
const field = findField(pressIndexFields, 'pressIndex.downloads.contactImage')
|
||||
|
||||
|
||||
Reference in New Issue
Block a user