diff --git a/package.json b/package.json index 802804a..aff3bca 100644 --- a/package.json +++ b/package.json @@ -23,6 +23,7 @@ "preload:home-page-cms": "cross-env NODE_OPTIONS=--no-deprecation tsx src/scripts/preload-home-page-cms.ts", "preload:impressum-page-cms": "cross-env NODE_OPTIONS=--no-deprecation tsx src/scripts/preload-impressum-page-cms.ts", "preload:participation-page-cms": "cross-env NODE_OPTIONS=--no-deprecation tsx src/scripts/preload-participation-page-cms.ts", + "preload:preistraeger-index-page-cms": "cross-env NODE_OPTIONS=--no-deprecation tsx src/scripts/preload-preistraeger-index-page-cms.ts", "reinstall": "cross-env NODE_OPTIONS=--no-deprecation rm -rf node_modules && rm pnpm-lock.yaml && pnpm --ignore-workspace install", "start": "cross-env NODE_OPTIONS=--no-deprecation next start", "test": "pnpm run test:int && pnpm run test:e2e", diff --git a/src/collections/Pages/index.ts b/src/collections/Pages/index.ts index 8368ccf..9cda35c 100644 --- a/src/collections/Pages/index.ts +++ b/src/collections/Pages/index.ts @@ -14,6 +14,7 @@ import { datenschutzFields } from './datenschutzFields' import { homeFields } from './homeFields' import { impressumFields } from './impressumFields' import { participationFields } from './participationFields' +import { preistraegerIndexFields } from './preistraegerIndexFields' import { slugField } from 'payload' import { populatePublishedAt } from '../../hooks/populatePublishedAt' import { generatePreviewPath } from '../../utilities/generatePreviewPath' @@ -75,13 +76,22 @@ const isDatenschutzPage = (_: unknown, siblingData?: { slug?: string; spaPath?: return spaPath === '/datenschutz' || slug === 'datenschutz' || title === 'datenschutz' } +const isPreistraegerIndexPage = (_: unknown, siblingData?: { slug?: string; spaPath?: string; title?: string }) => { + const slug = siblingData?.slug + const spaPath = siblingData?.spaPath + const title = siblingData?.title?.toLowerCase() + + return spaPath === '/preistraeger' || slug === 'preistraeger' || title === 'preisträger' || title === 'preistraeger' +} + const isManagedSpaPage = (_: unknown, siblingData?: { slug?: string; spaPath?: string; title?: string }) => isHomePage(undefined, siblingData) || isContactPage(undefined, siblingData) || isParticipationPage(undefined, siblingData) || isAboutPage(undefined, siblingData) || isImpressumPage(undefined, siblingData) || - isDatenschutzPage(undefined, siblingData) + isDatenschutzPage(undefined, siblingData) || + isPreistraegerIndexPage(undefined, siblingData) export const Pages: CollectionConfig<'pages'> = { slug: 'pages', @@ -191,6 +201,13 @@ export const Pages: CollectionConfig<'pages'> = { fields: datenschutzFields, label: 'Datenschutz Page', }, + { + admin: { + condition: (data) => isPreistraegerIndexPage(undefined, data), + }, + fields: preistraegerIndexFields, + label: 'Preisträger Index Page', + }, { name: 'meta', label: 'SEO', diff --git a/src/collections/Pages/preistraegerIndexFields.ts b/src/collections/Pages/preistraegerIndexFields.ts new file mode 100644 index 0000000..82b4eb6 --- /dev/null +++ b/src/collections/Pages/preistraegerIndexFields.ts @@ -0,0 +1,116 @@ +import type { Field } from 'payload' + +import { preistraegerIndexContent } from '@/spa/preistraegerIndexContent' + +const uploadField = (name: string, label: string, description?: string): Field => ({ + name, + type: 'upload', + relationTo: 'media', + label, + admin: description ? { description } : undefined, +}) + +const text = (name: string, label: string, defaultValue?: string): Field => ({ + name, + type: 'text', + label, + defaultValue, +}) + +const textarea = (name: string, label: string, defaultValue?: string): Field => ({ + name, + type: 'textarea', + label, + defaultValue, +}) + +const ctaGroup = (name: string, label: string, labelDefault: string, urlDefault: string): Field => ({ + name, + label, + type: 'group', + fields: [text('label', 'Label', labelDefault), text('url', 'URL', urlDefault)], +}) + +const sectionAdmin = (description: string) => ({ + description, + initCollapsed: true, +}) + +export const preistraegerIndexFields: Field[] = [ + { + name: 'preistraegerIndex', + label: 'Preisträger index page content', + type: 'group', + admin: { + description: + 'Edit the Preisträger listing page chrome. Individual winner cards are managed through the Preisträger collection.', + }, + fields: [ + { + name: 'hero', + label: '01 · Hero image', + type: 'group', + admin: sectionAdmin('Large image above the filters. Upload an image or keep the migrated URL fallback.'), + fields: [ + uploadField('image', 'Hero image'), + text('imageUrl', 'Fallback image URL', preistraegerIndexContent.hero.imageUrl), + text('imageAlt', 'Image alt text', preistraegerIndexContent.hero.imageAlt), + ], + }, + { + name: 'breadcrumb', + label: '02 · Breadcrumb bar', + type: 'group', + admin: sectionAdmin('Small page label below the image.'), + fields: [text('label', 'Label', preistraegerIndexContent.breadcrumb.label)], + }, + { + name: 'filters', + label: '03 · Filters', + type: 'group', + admin: sectionAdmin('Filter placeholders, toggle label, and reset label.'), + fields: [ + text('categoryPlaceholder', 'Category placeholder', preistraegerIndexContent.filters.categoryPlaceholder), + text('searchPlaceholder', 'Search placeholder', preistraegerIndexContent.filters.searchPlaceholder), + textarea('storyHeading', 'Story heading', preistraegerIndexContent.filters.storyHeading), + text('mediaIcon', 'Media icon', preistraegerIndexContent.filters.mediaIcon), + text('mediaLabel', 'Media label', preistraegerIndexContent.filters.mediaLabel), + text('resetLabel', 'Reset label', preistraegerIndexContent.filters.resetLabel), + ], + }, + { + name: 'count', + label: '04 · Count bar', + type: 'group', + admin: sectionAdmin('Label after the filtered result count.'), + fields: [text('label', 'Count label', preistraegerIndexContent.count.label)], + }, + { + name: 'empty', + label: '05 · Empty state', + type: 'group', + admin: sectionAdmin('Message when filters return no winners.'), + fields: [textarea('message', 'Empty message', preistraegerIndexContent.empty.message)], + }, + { + name: 'card', + label: '06 · Winner cards', + type: 'group', + admin: sectionAdmin('Hover label shown on winner cards.'), + fields: [text('hoverLabel', 'Hover label', preistraegerIndexContent.card.hoverLabel)], + }, + { + name: 'cta', + label: '07 · Bottom CTA', + type: 'group', + admin: sectionAdmin('Bottom application and membership CTA.'), + fields: [ + textarea('text', 'CTA text', preistraegerIndexContent.cta.text), + ctaGroup('primaryCta', 'Primary CTA', preistraegerIndexContent.cta.primaryCta.label, preistraegerIndexContent.cta.primaryCta.url), + text('secondaryPrefix', 'Secondary prefix', preistraegerIndexContent.cta.secondaryPrefix), + ctaGroup('secondaryCta', 'Secondary CTA', preistraegerIndexContent.cta.secondaryCta.label, preistraegerIndexContent.cta.secondaryCta.url), + ], + }, + ], + }, +] diff --git a/src/payload-types.ts b/src/payload-types.ts index ffa053b..74a9bfd 100644 --- a/src/payload-types.ts +++ b/src/payload-types.ts @@ -1179,6 +1179,69 @@ export interface Page { }[] | null; }; + /** + * Edit the Preisträger listing page chrome. Individual winner cards are managed through the Preisträger collection. + */ + preistraegerIndex?: { + /** + * Large image above the filters. Upload an image or keep the migrated URL fallback. + */ + hero?: { + image?: (number | null) | Media; + imageUrl?: string | null; + imageAlt?: string | null; + }; + /** + * Small page label below the image. + */ + breadcrumb?: { + label?: string | null; + }; + /** + * Filter placeholders, toggle label, and reset label. + */ + filters?: { + categoryPlaceholder?: string | null; + searchPlaceholder?: string | null; + storyHeading?: string | null; + mediaIcon?: string | null; + mediaLabel?: string | null; + resetLabel?: string | null; + }; + /** + * Label after the filtered result count. + */ + count?: { + label?: string | null; + }; + /** + * Message when filters return no winners. + */ + empty?: { + message?: string | null; + }; + /** + * Hover label shown on winner cards. + */ + card?: { + hoverLabel?: string | null; + }; + /** + * Bottom application and membership CTA. + */ + cta?: { + text?: string | null; + primaryCta?: { + label?: string | null; + url?: string | null; + }; + secondaryPrefix?: string | null; + secondaryCta?: { + label?: string | null; + url?: string | null; + }; + }; + }; meta?: { title?: string | null; /** @@ -3000,6 +3063,65 @@ export interface PagesSelect { items?: T; }; }; + preistraegerIndex?: + | T + | { + hero?: + | T + | { + image?: T; + imageUrl?: T; + imageAlt?: T; + }; + breadcrumb?: + | T + | { + label?: T; + }; + filters?: + | T + | { + categoryPlaceholder?: T; + searchPlaceholder?: T; + storyHeading?: T; + mediaIcon?: T; + mediaLabel?: T; + resetLabel?: T; + }; + count?: + | T + | { + label?: T; + }; + empty?: + | T + | { + message?: T; + }; + card?: + | T + | { + hoverLabel?: T; + }; + cta?: + | T + | { + text?: T; + primaryCta?: + | T + | { + label?: T; + url?: T; + }; + secondaryPrefix?: T; + secondaryCta?: + | T + | { + label?: T; + url?: T; + }; + }; + }; meta?: | T | { diff --git a/src/scripts/preload-preistraeger-index-page-cms.ts b/src/scripts/preload-preistraeger-index-page-cms.ts new file mode 100644 index 0000000..70887c1 --- /dev/null +++ b/src/scripts/preload-preistraeger-index-page-cms.ts @@ -0,0 +1,41 @@ +import 'dotenv/config' + +import config from '@payload-config' +import { getPayload } from 'payload' + +import { preistraegerIndexContent } from '@/spa/preistraegerIndexContent' + +async function main() { + const payload = await getPayload({ config }) + + const pageResult = await payload.find({ + collection: 'pages', + limit: 1, + pagination: false, + draft: true, + overrideAccess: true, + where: { + or: [{ spaPath: { equals: '/preistraeger' } }, { slug: { equals: 'preistraeger' } }], + }, + }) + + const page = pageResult.docs[0] + if (!page) throw new Error('Preisträger index page not found. Expected spaPath=/preistraeger or slug=preistraeger.') + + await payload.update({ + collection: 'pages', + id: page.id, + overrideAccess: true, + context: { disableRevalidate: true }, + data: { + preistraegerIndex: preistraegerIndexContent, + } as never, + }) + + payload.logger.info(`Preloaded Preisträger index CMS fields for page ${page.id}`) +} + +main().catch((error) => { + console.error(error) + process.exit(1) +}) diff --git a/src/spa/pages/Preistraeger.tsx b/src/spa/pages/Preistraeger.tsx index 6860f8d..5c78ce5 100644 --- a/src/spa/pages/Preistraeger.tsx +++ b/src/spa/pages/Preistraeger.tsx @@ -1,11 +1,12 @@ import React, { useState, useMemo } from 'react'; import { Link } from '@/spa/router'; import { Trophy, Search, ChevronDown, ChevronRight, X, ArrowRight } from 'lucide-react'; -import { WINNERS, CATEGORIES, YEARS } from '@/spa/data/winners'; -import { useCmsCollection } from '@/spa/cmsRoute'; -import { docImageUrl } from '@/spa/cmsMediaField'; +import { WINNERS, type Winner } from '@/spa/data/winners'; +import { useCmsCollection, useCmsRoute, type CmsRouteDoc } from '@/spa/cmsRoute'; +import { docImageUrl, mediaAlt, mediaUrl } from '@/spa/cmsMediaField'; import { useIsMobile } from '@/spa/hooks/useIsMobile'; import Image from '@/spa/components/ui/UnoptimizedImage' +import { preistraegerIndexContent } from '@/spa/preistraegerIndexContent'; const FF = '"IBM Plex Sans", sans-serif'; const NAVY = '#111D55'; @@ -14,47 +15,61 @@ const BORDER = '#D0D5DD'; const GRAY = '#666666'; const BG_ALT = '#E4E2E3'; +type PreistraegerIndexCms = Partial & { + hero?: Partial & { image?: unknown } +} + +const fallbackText = (value: unknown, fallback: string) => typeof value === 'string' && value.length > 0 ? value : fallback; + +const normalizeWinner = (doc: CmsRouteDoc): Winner => ({ + id: String(doc.id), + slug: String(doc.slug || ''), + name: String(doc.title || 'Preisträger'), + category: String(doc.category || 'Preisträger'), + year: Number(doc.year || new Date().getFullYear()), + type: String(doc.awardType || 'Auszeichnung'), + img: docImageUrl(doc, '/images/gala-saal-overview.jpg'), + shortDesc: String(doc.shortDesc || doc.description || doc.meta?.description || ''), + longDesc: String(doc.longDesc || doc.description || doc.meta?.description || ''), + quote: String(doc.quote || ''), + quotePerson: String(doc.quotePerson || ''), + quoteRole: String(doc.quoteRole || ''), + location: String(doc.location || 'Bayern'), + industry: String(doc.industry || 'Mittelstand'), + website: String(doc.website || '#'), + hasMedia: Boolean(doc.hasMedia), +}); + export default function Preistraeger() { const isMobile = useIsMobile(); - const [activeYear, setActiveYear] = useState(YEARS[0]); // default: most recent const [filterCategory, setFilterCategory] = useState(''); const [search, setSearch] = useState(''); const [mediaOnly, setMediaOnly] = useState(false); + const cms = (useCmsRoute()?.doc?.preistraegerIndex || {}) as PreistraegerIndexCms; const cmsPreistraeger = useCmsCollection('preistraeger'); + const hero = { ...preistraegerIndexContent.hero, ...(cms.hero || {}) }; + const breadcrumb = { ...preistraegerIndexContent.breadcrumb, ...(cms.breadcrumb || {}) }; + const filters = { ...preistraegerIndexContent.filters, ...(cms.filters || {}) }; + const count = { ...preistraegerIndexContent.count, ...(cms.count || {}) }; + const empty = { ...preistraegerIndexContent.empty, ...(cms.empty || {}) }; + const card = { ...preistraegerIndexContent.card, ...(cms.card || {}) }; + const cta = { ...preistraegerIndexContent.cta, ...(cms.cta || {}) }; const winners = useMemo(() => { - const existingSlugs = new Set(WINNERS.map((winner) => winner.slug)); - return [ - ...WINNERS, - ...cmsPreistraeger - .filter((doc) => doc.slug && !existingSlugs.has(String(doc.slug))) - .map((doc) => ({ - id: String(doc.id), - slug: String(doc.slug), - name: String(doc.title || 'Preisträger'), - category: String(doc.category || 'Preisträger'), - year: Number(doc.year || new Date().getFullYear()), - type: String(doc.awardType || 'Auszeichnung'), - img: docImageUrl(doc, '/images/gala-saal-overview.jpg'), - shortDesc: String(doc.shortDesc || doc.description || doc.meta?.description || ''), - longDesc: String(doc.longDesc || doc.description || doc.meta?.description || ''), - quote: String(doc.quote || ''), - quotePerson: String(doc.quotePerson || ''), - quoteRole: String(doc.quoteRole || ''), - location: String(doc.location || 'Bayern'), - industry: String(doc.industry || 'Mittelstand'), - website: String(doc.website || '#'), - hasMedia: Boolean(doc.hasMedia), - })), - ]; + if (cmsPreistraeger.length) return cmsPreistraeger.map(normalizeWinner).filter((winner) => winner.slug); + return WINNERS; }, [cmsPreistraeger]); + const years = useMemo(() => [...new Set(winners.map((winner) => winner.year))].sort((a, b) => b - a), [winners]); + const categories = useMemo(() => [...new Set(winners.map((winner) => winner.category))].sort((a, b) => a.localeCompare(b, 'de')), [winners]); + const [activeYear, setActiveYear] = useState(undefined); + const selectedYear = activeYear ?? years[0] ?? new Date().getFullYear(); const filtered = useMemo(() => winners.filter(w => { - if (w.year !== activeYear) return false; + if (w.year !== selectedYear) return false; if (filterCategory && w.category !== filterCategory) return false; if (search && !w.name.toLowerCase().includes(search.toLowerCase())) return false; if (mediaOnly && !w.hasMedia) return false; return true; - }), [activeYear, filterCategory, search, mediaOnly, winners]); + }), [selectedYear, filterCategory, search, mediaOnly, winners]); const reset = () => { setFilterCategory(''); setSearch(''); setMediaOnly(false); }; const hasFilter = !!(filterCategory || search || mediaOnly); @@ -64,8 +79,8 @@ export default function Preistraeger() { {/* Hero */}
Preisverleihung
@@ -74,7 +89,7 @@ export default function Preistraeger() { {/* Breadcrumb */}
- Preisträger:innen + {fallbackText(breadcrumb.label, preistraegerIndexContent.breadcrumb.label)}
{/* Filter Section */} @@ -83,7 +98,7 @@ export default function Preistraeger() { {/* Year Tabs */}
- {YEARS.map(year => ( + {years.map(year => (
{/* Toggle row */}
- Diese Erfolgsgeschichten müssen erzählt werden. + {fallbackText(filters.storyHeading, preistraegerIndexContent.filters.storyHeading)}
- 💬 - Mit Medienbeiträgen (Videos & Storys) + {fallbackText(filters.mediaIcon, preistraegerIndexContent.filters.mediaIcon)} + {fallbackText(filters.mediaLabel, preistraegerIndexContent.filters.mediaLabel)} )}
@@ -157,14 +172,14 @@ export default function Preistraeger() {
- {activeYear} – {filtered.length} Preisträger:innen + {selectedYear} – {filtered.length} {fallbackText(count.label, preistraegerIndexContent.count.label)}
{/* Grid */} {filtered.length === 0 ? (
- Keine Preisträger für diese Auswahl. + {fallbackText(empty.message, preistraegerIndexContent.empty.message)}
) : (
{filtered.map(w => ( - + ))}
)} {/* Bottom CTA */}
-

Werden Sie der nächste Preisträger.

+

{fallbackText(cta.text, preistraegerIndexContent.cta.text)}

{ (e.currentTarget as HTMLElement).style.background = '#FFD130'; (e.currentTarget as HTMLElement).style.boxShadow = '0 0 18px rgba(239,191,4,0.65), 0 0 40px rgba(239,191,4,0.3)'; }} onMouseLeave={e => { (e.currentTarget as HTMLElement).style.background = GOLD; (e.currentTarget as HTMLElement).style.boxShadow = 'none'; }} > - Jetzt kostenlos bewerben + {fallbackText(cta.primaryCta?.label, preistraegerIndexContent.cta.primaryCta.label)}
- Diese Arbeit unterstützen: + {fallbackText(cta.secondaryPrefix, preistraegerIndexContent.cta.secondaryPrefix)} - Vereinsmitglied werden + {fallbackText(cta.secondaryCta?.label, preistraegerIndexContent.cta.secondaryCta.label)}
@@ -241,7 +256,7 @@ export default function Preistraeger() { // ── WinnerCard ─────────────────────────────────────────────────────────────── -function WinnerCard({ winner }: { winner: import('@/spa/data/winners').Winner }) { +function WinnerCard({ winner, hoverLabel }: { winner: Winner; hoverLabel: string }) { const [hovered, setHovered] = useState(false); const isMobile = useIsMobile(); return ( @@ -288,7 +303,7 @@ function WinnerCard({ winner }: { winner: import('@/spa/data/winners').Winner }) transform: hovered ? 'translateY(0)' : 'translateY(6px)', transition: 'opacity 0.25s, transform 0.25s', }}> - Mehr erfahren + {hoverLabel}
@@ -330,14 +345,14 @@ function SelectControl({ value, onChange, placeholder, options, width }: { // ── SearchControl ──────────────────────────────────────────────────────────── -function SearchControl({ value, onChange }: { value: string; onChange: (v: string) => void }) { +function SearchControl({ value, onChange, placeholder }: { value: string; onChange: (v: string) => void; placeholder: string }) { return (
onChange(e.target.value)} - placeholder="Stichwortsuche …" + placeholder={placeholder} style={{ width: '100%', height: 44, padding: '0 40px 0 14px', fontFamily: FF, fontSize: 16, diff --git a/src/spa/pages/PreistraegerDetail.tsx b/src/spa/pages/PreistraegerDetail.tsx index 6b5b07d..da1dc87 100644 --- a/src/spa/pages/PreistraegerDetail.tsx +++ b/src/spa/pages/PreistraegerDetail.tsx @@ -19,7 +19,7 @@ export default function PreistraegerDetail() { const { slug } = useParams<{ slug: string }>(); const route = useCmsRoute(); const cmsWinner = route?.collection === 'preistraeger' ? route.doc : undefined; - const winner = WINNERS.find(w => w.slug === slug) || (cmsWinner ? { + const winner = cmsWinner ? { id: String(cmsWinner.id), slug: String(cmsWinner.slug || slug), name: String(cmsWinner.title || 'Preisträger'), @@ -36,7 +36,7 @@ export default function PreistraegerDetail() { industry: String(cmsWinner.industry || 'Mittelstand'), website: String(cmsWinner.website || '#'), hasMedia: Boolean(cmsWinner.hasMedia), - } : undefined); + } : WINNERS.find(w => w.slug === slug); const isMobile = useIsMobile(); if (!winner) return ; diff --git a/src/spa/preistraegerIndexContent.ts b/src/spa/preistraegerIndexContent.ts new file mode 100644 index 0000000..876b30b --- /dev/null +++ b/src/spa/preistraegerIndexContent.ts @@ -0,0 +1,38 @@ +export const preistraegerIndexContent = { + hero: { + imageUrl: 'https://images.unsplash.com/photo-1492684223066-81342ee5ff30?auto=format&fit=crop&q=80&w=2000', + imageAlt: 'Preisverleihung', + }, + breadcrumb: { + label: 'Preisträger:innen', + }, + filters: { + categoryPlaceholder: 'Preiskategorie …', + searchPlaceholder: 'Stichwortsuche …', + storyHeading: 'Diese Erfolgsgeschichten müssen erzählt werden.', + mediaIcon: '💬', + mediaLabel: 'Mit Medienbeiträgen (Videos & Storys)', + resetLabel: 'Filter zurücksetzen', + }, + count: { + label: 'Preisträger:innen', + }, + empty: { + message: 'Keine Preisträger für diese Auswahl.', + }, + card: { + hoverLabel: 'Mehr erfahren', + }, + cta: { + text: 'Werden Sie der nächste Preisträger.', + primaryCta: { + label: 'Jetzt kostenlos bewerben', + url: '/teilnahme', + }, + secondaryPrefix: 'Diese Arbeit unterstützen:', + secondaryCta: { + label: 'Vereinsmitglied werden', + url: '/mitglied-werden', + }, + }, +}