From 30efb4b36ee7c0187c0274f319679846287ae3cb Mon Sep 17 00:00:00 2001 From: syntaxbullet Date: Fri, 31 Jul 2026 16:41:02 +0200 Subject: [PATCH] feat(network): add responsive sponsor hierarchy --- docs/payload-field-inventory.md | 2 +- src/collections/Pages/netzwerkFields.ts | 5 +- ...731_190000_responsive_sponsor_hierarchy.ts | 66 +++++++ src/migrations/index.ts | 6 + src/payload-types.ts | 3 + src/scripts/preload-netzwerk-page-cms.ts | 3 +- src/spa/netzwerkContent.ts | 2 +- src/spa/pages/Netzwerk.tsx | 131 +++++++++---- src/spa/sponsorHierarchy.ts | 59 ++++++ .../netzwerk-sponsor-hierarchy.int.spec.tsx | 183 ++++++++++++++++++ tests/int/payload-field-inventory.int.spec.ts | 15 ++ 11 files changed, 438 insertions(+), 37 deletions(-) create mode 100644 src/migrations/20260731_190000_responsive_sponsor_hierarchy.ts create mode 100644 src/spa/sponsorHierarchy.ts create mode 100644 tests/int/netzwerk-sponsor-hierarchy.int.spec.tsx diff --git a/docs/payload-field-inventory.md b/docs/payload-field-inventory.md index 92174b4..db9f13a 100644 --- a/docs/payload-field-inventory.md +++ b/docs/payload-field-inventory.md @@ -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 diff --git a/src/collections/Pages/netzwerkFields.ts b/src/collections/Pages/netzwerkFields.ts index efac8e8..932926f 100644 --- a/src/collections/Pages/netzwerkFields.ts +++ b/src/collections/Pages/netzwerkFields.ts @@ -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')], }, { diff --git a/src/migrations/20260731_190000_responsive_sponsor_hierarchy.ts b/src/migrations/20260731_190000_responsive_sponsor_hierarchy.ts new file mode 100644 index 0000000..4a800ae --- /dev/null +++ b/src/migrations/20260731_190000_responsive_sponsor_hierarchy.ts @@ -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 { + 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 { + 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 = '';`)) +} diff --git a/src/migrations/index.ts b/src/migrations/index.ts index 9491ed4..ed972bc 100644 --- a/src/migrations/index.ts +++ b/src/migrations/index.ts @@ -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', + }, ]; diff --git a/src/payload-types.ts b/src/payload-types.ts index 9293e09..02bfa8b 100644 --- a/src/payload-types.ts +++ b/src/payload-types.ts @@ -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; diff --git a/src/scripts/preload-netzwerk-page-cms.ts b/src/scripts/preload-netzwerk-page-cms.ts index 5b5ba8c..cbe584a 100644 --- a/src/scripts/preload-netzwerk-page-cms.ts +++ b/src/scripts/preload-netzwerk-page-cms.ts @@ -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, diff --git a/src/spa/netzwerkContent.ts b/src/spa/netzwerkContent.ts index f46793d..15dba08 100644 --- a/src/spa/netzwerkContent.ts +++ b/src/spa/netzwerkContent.ts @@ -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: '' }, diff --git a/src/spa/pages/Netzwerk.tsx b/src/spa/pages/Netzwerk.tsx index b429c03..e6d3a77 100644 --- a/src/spa/pages/Netzwerk.tsx +++ b/src/spa/pages/Netzwerk.tsx @@ -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 = (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 = (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 ( -
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', }} >
@@ -257,7 +275,7 @@ const SponsorCell: React.FC<{ partner: Partner; variant: 'lg' | 'md' | 'sm'; idx
{detailLabel}
-
+ ); }; @@ -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(null); const [showGreetingVideo, setShowGreetingVideo] = useState(false); const greetingVideoRef = useRef(null); @@ -474,13 +494,20 @@ const Netzwerk: React.FC = () => { .filter((member) => member.active !== false && member.name); const juryMembers = sortJuryMembersQuoteFirst(normalizedJuryMembers); const expertiseRows = fallbackArray(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(partnerSection.tiers, netzwerkContent.partners.tiers); + const configuredPartnerTiers = fallbackArray(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(juryIntro.stats, netzwerkContent.juryIntro.stats); const desktopStats = fallbackArray(juryIntro.desktopStats, netzwerkContent.juryIntro.desktopStats); const sponsorBenefits = fallbackArray(sponsoring.benefits, netzwerkContent.sponsoring.benefits); @@ -741,23 +768,61 @@ const Netzwerk: React.FC = () => {

- {visiblePartnerDescription(partnerSection.description)} + {fallbackText(partnerSection.description, netzwerkContent.partners.description)}

- {/* Tiered grid – continuous, ranks marked by label bars */} -
+ {/* Independent tier bands keep rank legible without mixing tiers to fill a row. */} +
{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 ( -
- {items.map((p, i) => ( - setSelectedPartner(p)} /> - ))} -
+
+
+

+ {label} +

+ {note ? ( +

{note}

+ ) : null} +
+
+ {items.map((partner) => ( + setSelectedPartner(partner)} + /> + ))} +
+
); })}
diff --git a/src/spa/sponsorHierarchy.ts b/src/spa/sponsorHierarchy.ts new file mode 100644 index 0000000..cd17c0f --- /dev/null +++ b/src/spa/sponsorHierarchy.ts @@ -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(partners: T[]): Record { + const grouped: Record> = { + 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})` +} diff --git a/tests/int/netzwerk-sponsor-hierarchy.int.spec.tsx b/tests/int/netzwerk-sponsor-hierarchy.int.spec.tsx new file mode 100644 index 0000000..eb47336 --- /dev/null +++ b/tests/int/netzwerk-sponsor-hierarchy.int.spec.tsx @@ -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( + + + , + ) +} + +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}"`) + }) + }) +}) diff --git a/tests/int/payload-field-inventory.int.spec.ts b/tests/int/payload-field-inventory.int.spec.ts index 1d26a44..a4fbf87 100644 --- a/tests/int/payload-field-inventory.int.spec.ts +++ b/tests/int/payload-field-inventory.int.spec.ts @@ -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')