feat(cms): clarify sponsor ordering
This commit is contained in:
@@ -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 |
|
||||||
|
|||||||
@@ -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.',
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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([
|
||||||
|
|||||||
48
tests/int/partners-admin-ordering.int.spec.ts
Normal file
48
tests/int/partners-admin-ordering.int.spec.ts
Normal 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.')
|
||||||
|
}
|
||||||
|
})
|
||||||
|
})
|
||||||
Reference in New Issue
Block a user