feat(cms): clarify sponsor ordering

This commit is contained in:
syntaxbullet
2026-07-31 16:52:49 +02:00
parent 30efb4b36e
commit 95037e9908
5 changed files with 129 additions and 13 deletions

View File

@@ -35,6 +35,29 @@ This inventory records the frontend or platform owner for every retained `Pages`
| `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.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`. |
## Partner collection administration
The Partners list defaults to `name`, `tier`, `sortOrder`, `_status`, `active`, `showOnHomepage`, and `updatedAt`, so editors can review presentation order and visibility without opening every record. `tier` accepts only the numeric values 1, 2, and 3. Within each tier, lower `sortOrder` values render first; tiers remain separate presentation bands.
The current published local CMS data was verified on 2026-07-31 in this client-review order:
| Review order | Partner | Tier | `sortOrder` |
| ---: | --- | ---: | ---: |
| 1 | RSM Ebner Stolz | 1 | 1 |
| 2 | WWK | 1 | 2 |
| 3 | Radio Gong 96.3 | 2 | 3 |
| 4 | münchen.tv | 2 | 4 |
| 5 | METZLER | 3 | 5 |
| 6 | Dr. Wieselhuber & Partner | 3 | 6 |
| 7 | Fristads | 3 | 7 |
| 8 | New Edge | 3 | 8 |
| 9 | Moodtalk | 3 | 9 |
| 10 | Deutsche Bank | 3 | 10 |
| 11 | Bionorica | 3 | 11 |
| 12 | Primus | 3 | 12 |
Future sponsor order changes are CMS content operations: edit the relevant Partner records' `tier` and `sortOrder` values. They do not require frontend code changes.
## Global ownership ## Global ownership
| Global | Consumer or contract | | Global | Consumer or contract |

View File

@@ -1,8 +1,14 @@
import type { CollectionConfig } from 'payload' import type { CollectionConfig, NumberFieldSingleValidation } from 'payload'
import { authenticated } from '@/access/authenticated' import { authenticated } from '@/access/authenticated'
import { authenticatedOrPublished } from '@/access/authenticatedOrPublished' import { authenticatedOrPublished } from '@/access/authenticatedOrPublished'
import { populatePublishedAt } from '@/hooks/populatePublishedAt' import { populatePublishedAt } from '@/hooks/populatePublishedAt'
import { sponsorTierNumbers } from '@/spa/sponsorHierarchy'
export const validatePartnerTier: NumberFieldSingleValidation = (value) =>
typeof value === 'number' && sponsorTierNumbers.some((tier) => tier === value)
? true
: 'Tier must be one of 1, 2, or 3.'
export const Partners: CollectionConfig = { export const Partners: CollectionConfig = {
slug: 'partners', slug: 'partners',
@@ -17,7 +23,15 @@ export const Partners: CollectionConfig = {
update: authenticated, update: authenticated,
}, },
admin: { admin: {
defaultColumns: ['name', 'tier', 'sortOrder', 'active', 'showOnHomepage', 'updatedAt'], defaultColumns: [
'name',
'tier',
'sortOrder',
'_status',
'active',
'showOnHomepage',
'updatedAt',
],
group: 'Inhalte', group: 'Inhalte',
useAsTitle: 'name', useAsTitle: 'name',
}, },
@@ -40,14 +54,21 @@ export const Partners: CollectionConfig = {
name: 'tier', name: 'tier',
type: 'number', type: 'number',
defaultValue: 3, defaultValue: 3,
min: 1,
max: 3,
required: true, required: true,
validate: validatePartnerTier,
admin: {
description: 'Only tiers 1, 2, and 3 are supported.',
step: 1,
},
}, },
{ {
name: 'sortOrder', name: 'sortOrder',
type: 'number', type: 'number',
defaultValue: 100, defaultValue: 100,
admin: { admin: {
description: 'Lower numbers appear earlier on partner surfaces.', description: 'Lower numbers appear earlier within the selected tier on partner surfaces.',
}, },
}, },
{ {
@@ -68,7 +89,8 @@ export const Partners: CollectionConfig = {
relationTo: 'media', relationTo: 'media',
required: true, required: true,
admin: { admin: {
description: 'Partner logo displayed on partner cards, the detail modal, and the homepage ticker.', description:
'Partner logo displayed on partner cards, the detail modal, and the homepage ticker.',
}, },
}, },
{ {

View File

@@ -2171,9 +2171,12 @@ export interface Partner {
* Stable machine-readable key used when migrating or matching partner records. * Stable machine-readable key used when migrating or matching partner records.
*/ */
stableId?: string | null; stableId?: string | null;
/**
* Only tiers 1, 2, and 3 are supported.
*/
tier: number; tier: number;
/** /**
* Lower numbers appear earlier on partner surfaces. * Lower numbers appear earlier within the selected tier on partner surfaces.
*/ */
sortOrder?: number | null; sortOrder?: number | null;
active?: boolean | null; active?: boolean | null;

View File

@@ -35,13 +35,13 @@ const partner = (name: string, tier: 1 | 2 | 3, sortOrder: number) => ({
}) })
const partners = [ const partners = [
partner('Tier One B', 1, 2), partner('Tier One B', 1, 200),
partner('Tier Three H', 3, 8), partner('Tier Three H', 3, 8),
partner('Tier Two B', 2, 2), partner('Tier Two B', 2, 20),
partner('Tier Three A', 3, 1), partner('Tier Three A', 3, 1),
partner('Tier One A', 1, 1), partner('Tier One A', 1, 100),
partner('Tier Three C', 3, 3), partner('Tier Three C', 3, 3),
partner('Tier Two A', 2, 1), partner('Tier Two A', 2, 10),
partner('Tier Three B', 3, 2), partner('Tier Three B', 3, 2),
partner('Tier Three D', 3, 4), partner('Tier Three D', 3, 4),
partner('Tier Three E', 3, 5), partner('Tier Three E', 3, 5),
@@ -102,7 +102,7 @@ describe('responsive sponsor hierarchy', () => {
afterEach(() => cleanup()) afterEach(() => cleanup())
it('groups by Partner tier and sorts each band by Partner sortOrder', () => { it('keeps tiers separate and applies Partner sortOrder only within each tier', () => {
const grouped = groupSponsorsByTier(partners) const grouped = groupSponsorsByTier(partners)
expect(grouped[1].map(({ name }) => name)).toEqual(['Tier One A', 'Tier One B']) expect(grouped[1].map(({ name }) => name)).toEqual(['Tier One A', 'Tier One B'])
@@ -117,14 +117,32 @@ describe('responsive sponsor hierarchy', () => {
'Tier Three G', 'Tier Three G',
'Tier Three H', 'Tier Three H',
]) ])
expect([...grouped[1], ...grouped[2], ...grouped[3]].map(({ name }) => name)).toEqual([
'Tier One A',
'Tier One B',
'Tier Two A',
'Tier Two B',
'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', () => { 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, '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, '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, 'mobile'))).toEqual([1, 1, 2])
expect([1, 2, 3].map((tier) => sponsorColumns(tier as 1 | 2 | 3, 'narrow-mobile'))).toEqual([1, 1, 1]) expect([1, 2, 3].map((tier) => sponsorColumns(tier as 1 | 2 | 3, 'narrow-mobile'))).toEqual([
expect(sponsorViewport({ isMobile: false, isNarrowMobile: false, isTabletOrMobile: true })).toBe('tablet') 1, 1, 1,
])
expect(
sponsorViewport({ isMobile: false, isNarrowMobile: false, isTabletOrMobile: true }),
).toBe('tablet')
expect(sponsorCardBasis(3, 16)).toBe('calc((100% - 32px) / 3)') expect(sponsorCardBasis(3, 16)).toBe('calc((100% - 32px) / 3)')
}) })
@@ -140,7 +158,9 @@ describe('responsive sponsor hierarchy', () => {
}) })
expect(screen.getAllByText(sponsorCopy.detailLabel)).toHaveLength(partners.length) expect(screen.getAllByText(sponsorCopy.detailLabel)).toHaveLength(partners.length)
expect(screen.queryByText(netzwerkContent.partners.description)).toBeNull() expect(screen.queryByText(netzwerkContent.partners.description)).toBeNull()
netzwerkContent.partners.tiers.forEach(({ label }) => expect(screen.queryByText(label)).toBeNull()) netzwerkContent.partners.tiers.forEach(({ label }) =>
expect(screen.queryByText(label)).toBeNull(),
)
}) })
it.each([ it.each([

View File

@@ -0,0 +1,48 @@
import type { Field, NumberField, NumberFieldSingleValidation } from 'payload'
import { describe, expect, it } from 'vitest'
import { Partners } from '@/collections/Partners'
function partnerField(name: string): Field {
const field = Partners.fields.find((candidate) => 'name' in candidate && candidate.name === name)
if (!field) throw new Error(`Missing Partners field: ${name}`)
return field
}
describe('Partners admin ordering contract', () => {
it('shows the title and ordering state needed for content operations', () => {
expect(Partners.admin?.useAsTitle).toBe('name')
expect(Partners.admin?.defaultColumns).toEqual([
'name',
'tier',
'sortOrder',
'_status',
'active',
'showOnHomepage',
'updatedAt',
])
})
it('accepts only numeric tiers 1, 2, and 3', async () => {
const tierField = partnerField('tier')
expect(tierField).toMatchObject({
type: 'number',
min: 1,
max: 3,
required: true,
admin: { step: 1 },
})
const validate = (tierField as NumberField).validate as NumberFieldSingleValidation | undefined
expect(validate).toBeTypeOf('function')
for (const value of [1, 2, 3]) {
expect(await validate?.(value, {} as never)).toBe(true)
}
for (const value of [0, 1.5, 4, Number.NaN, null, undefined]) {
expect(await validate?.(value, {} as never)).toBe('Tier must be one of 1, 2, or 3.')
}
})
})