feat: manage preistraeger index via payload

This commit is contained in:
syntaxbullet
2026-06-22 11:04:34 +02:00
parent 9282d28cfa
commit aecdb65a14
8 changed files with 409 additions and 59 deletions

View File

@@ -23,6 +23,7 @@
"preload:home-page-cms": "cross-env NODE_OPTIONS=--no-deprecation tsx src/scripts/preload-home-page-cms.ts", "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: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: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", "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", "start": "cross-env NODE_OPTIONS=--no-deprecation next start",
"test": "pnpm run test:int && pnpm run test:e2e", "test": "pnpm run test:int && pnpm run test:e2e",

View File

@@ -14,6 +14,7 @@ import { datenschutzFields } from './datenschutzFields'
import { homeFields } from './homeFields' import { homeFields } from './homeFields'
import { impressumFields } from './impressumFields' import { impressumFields } from './impressumFields'
import { participationFields } from './participationFields' import { participationFields } from './participationFields'
import { preistraegerIndexFields } from './preistraegerIndexFields'
import { slugField } from 'payload' import { slugField } from 'payload'
import { populatePublishedAt } from '../../hooks/populatePublishedAt' import { populatePublishedAt } from '../../hooks/populatePublishedAt'
import { generatePreviewPath } from '../../utilities/generatePreviewPath' import { generatePreviewPath } from '../../utilities/generatePreviewPath'
@@ -75,13 +76,22 @@ const isDatenschutzPage = (_: unknown, siblingData?: { slug?: string; spaPath?:
return spaPath === '/datenschutz' || slug === 'datenschutz' || title === 'datenschutz' 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 }) => const isManagedSpaPage = (_: unknown, siblingData?: { slug?: string; spaPath?: string; title?: string }) =>
isHomePage(undefined, siblingData) || isHomePage(undefined, siblingData) ||
isContactPage(undefined, siblingData) || isContactPage(undefined, siblingData) ||
isParticipationPage(undefined, siblingData) || isParticipationPage(undefined, siblingData) ||
isAboutPage(undefined, siblingData) || isAboutPage(undefined, siblingData) ||
isImpressumPage(undefined, siblingData) || isImpressumPage(undefined, siblingData) ||
isDatenschutzPage(undefined, siblingData) isDatenschutzPage(undefined, siblingData) ||
isPreistraegerIndexPage(undefined, siblingData)
export const Pages: CollectionConfig<'pages'> = { export const Pages: CollectionConfig<'pages'> = {
slug: 'pages', slug: 'pages',
@@ -191,6 +201,13 @@ export const Pages: CollectionConfig<'pages'> = {
fields: datenschutzFields, fields: datenschutzFields,
label: 'Datenschutz Page', label: 'Datenschutz Page',
}, },
{
admin: {
condition: (data) => isPreistraegerIndexPage(undefined, data),
},
fields: preistraegerIndexFields,
label: 'Preisträger Index Page',
},
{ {
name: 'meta', name: 'meta',
label: 'SEO', label: 'SEO',

View File

@@ -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),
],
},
],
},
]

View File

@@ -1179,6 +1179,69 @@ export interface Page {
}[] }[]
| null; | 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?: { meta?: {
title?: string | null; title?: string | null;
/** /**
@@ -3000,6 +3063,65 @@ export interface PagesSelect<T extends boolean = true> {
items?: T; 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?: meta?:
| T | T
| { | {

View File

@@ -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)
})

View File

@@ -1,11 +1,12 @@
import React, { useState, useMemo } from 'react'; import React, { useState, useMemo } from 'react';
import { Link } from '@/spa/router'; import { Link } from '@/spa/router';
import { Trophy, Search, ChevronDown, ChevronRight, X, ArrowRight } from 'lucide-react'; import { Trophy, Search, ChevronDown, ChevronRight, X, ArrowRight } from 'lucide-react';
import { WINNERS, CATEGORIES, YEARS } from '@/spa/data/winners'; import { WINNERS, type Winner } from '@/spa/data/winners';
import { useCmsCollection } from '@/spa/cmsRoute'; import { useCmsCollection, useCmsRoute, type CmsRouteDoc } from '@/spa/cmsRoute';
import { docImageUrl } from '@/spa/cmsMediaField'; import { docImageUrl, mediaAlt, mediaUrl } from '@/spa/cmsMediaField';
import { useIsMobile } from '@/spa/hooks/useIsMobile'; import { useIsMobile } from '@/spa/hooks/useIsMobile';
import Image from '@/spa/components/ui/UnoptimizedImage' import Image from '@/spa/components/ui/UnoptimizedImage'
import { preistraegerIndexContent } from '@/spa/preistraegerIndexContent';
const FF = '"IBM Plex Sans", sans-serif'; const FF = '"IBM Plex Sans", sans-serif';
const NAVY = '#111D55'; const NAVY = '#111D55';
@@ -14,47 +15,61 @@ const BORDER = '#D0D5DD';
const GRAY = '#666666'; const GRAY = '#666666';
const BG_ALT = '#E4E2E3'; const BG_ALT = '#E4E2E3';
type PreistraegerIndexCms = Partial<typeof preistraegerIndexContent> & {
hero?: Partial<typeof preistraegerIndexContent.hero> & { 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() { export default function Preistraeger() {
const isMobile = useIsMobile(); const isMobile = useIsMobile();
const [activeYear, setActiveYear] = useState(YEARS[0]); // default: most recent
const [filterCategory, setFilterCategory] = useState(''); const [filterCategory, setFilterCategory] = useState('');
const [search, setSearch] = useState(''); const [search, setSearch] = useState('');
const [mediaOnly, setMediaOnly] = useState(false); const [mediaOnly, setMediaOnly] = useState(false);
const cms = (useCmsRoute()?.doc?.preistraegerIndex || {}) as PreistraegerIndexCms;
const cmsPreistraeger = useCmsCollection('preistraeger'); 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 winners = useMemo(() => {
const existingSlugs = new Set(WINNERS.map((winner) => winner.slug)); if (cmsPreistraeger.length) return cmsPreistraeger.map(normalizeWinner).filter((winner) => winner.slug);
return [ return WINNERS;
...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),
})),
];
}, [cmsPreistraeger]); }, [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<number | undefined>(undefined);
const selectedYear = activeYear ?? years[0] ?? new Date().getFullYear();
const filtered = useMemo(() => winners.filter(w => { 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 (filterCategory && w.category !== filterCategory) return false;
if (search && !w.name.toLowerCase().includes(search.toLowerCase())) return false; if (search && !w.name.toLowerCase().includes(search.toLowerCase())) return false;
if (mediaOnly && !w.hasMedia) return false; if (mediaOnly && !w.hasMedia) return false;
return true; return true;
}), [activeYear, filterCategory, search, mediaOnly, winners]); }), [selectedYear, filterCategory, search, mediaOnly, winners]);
const reset = () => { setFilterCategory(''); setSearch(''); setMediaOnly(false); }; const reset = () => { setFilterCategory(''); setSearch(''); setMediaOnly(false); };
const hasFilter = !!(filterCategory || search || mediaOnly); const hasFilter = !!(filterCategory || search || mediaOnly);
@@ -64,8 +79,8 @@ export default function Preistraeger() {
{/* Hero */} {/* Hero */}
<div style={{ width: '100%', height: '50vh', overflow: 'hidden', position: 'relative' }}> <div style={{ width: '100%', height: '50vh', overflow: 'hidden', position: 'relative' }}>
<Image unoptimized <Image unoptimized
src="https://images.unsplash.com/photo-1492684223066-81342ee5ff30?auto=format&fit=crop&q=80&w=2000" src={mediaUrl(hero.image, fallbackText(hero.imageUrl, preistraegerIndexContent.hero.imageUrl))}
alt="Preisverleihung" alt={mediaAlt(hero.image, fallbackText(hero.imageAlt, preistraegerIndexContent.hero.imageAlt))}
style={{ width: '100%', height: '100%', objectFit: 'cover', display: 'block' }} style={{ width: '100%', height: '100%', objectFit: 'cover', display: 'block' }}
/> />
<div style={{ position: 'absolute', inset: 0, background: 'linear-gradient(to bottom, transparent 40%, rgba(3,9,58,0.82) 100%)' }} /> <div style={{ position: 'absolute', inset: 0, background: 'linear-gradient(to bottom, transparent 40%, rgba(3,9,58,0.82) 100%)' }} />
@@ -74,7 +89,7 @@ export default function Preistraeger() {
{/* Breadcrumb */} {/* Breadcrumb */}
<div style={{ background: '#fff', borderBottom: `1px solid ${BORDER}`, padding: isMobile ? '18px 24px' : '18px 80px' }}> <div style={{ background: '#fff', borderBottom: `1px solid ${BORDER}`, padding: isMobile ? '18px 24px' : '18px 80px' }}>
<span style={{ fontFamily: FF, fontSize: 16, color: GOLD, fontWeight: 600, letterSpacing: '0.04em' }}>Preisträger:innen</span> <span style={{ fontFamily: FF, fontSize: 16, color: GOLD, fontWeight: 600, letterSpacing: '0.04em' }}>{fallbackText(breadcrumb.label, preistraegerIndexContent.breadcrumb.label)}</span>
</div> </div>
{/* Filter Section */} {/* Filter Section */}
@@ -83,7 +98,7 @@ export default function Preistraeger() {
{/* Year Tabs */} {/* Year Tabs */}
<div style={{ display: 'flex', flexWrap: isMobile ? 'nowrap' : 'wrap', gap: 0, marginBottom: 32, borderBottom: `2px solid ${BORDER}`, overflowX: isMobile ? 'auto' : 'visible', WebkitOverflowScrolling: 'touch', maxWidth: '100%' }}> <div style={{ display: 'flex', flexWrap: isMobile ? 'nowrap' : 'wrap', gap: 0, marginBottom: 32, borderBottom: `2px solid ${BORDER}`, overflowX: isMobile ? 'auto' : 'visible', WebkitOverflowScrolling: 'touch', maxWidth: '100%' }}>
{YEARS.map(year => ( {years.map(year => (
<button <button
key={year} key={year}
onClick={() => { setActiveYear(year); reset(); }} onClick={() => { setActiveYear(year); reset(); }}
@@ -92,8 +107,8 @@ export default function Preistraeger() {
padding: isMobile ? '12px 20px' : '12px 32px', padding: isMobile ? '12px 20px' : '12px 32px',
flexShrink: 0, flexShrink: 0,
background: 'none', border: 'none', cursor: 'pointer', background: 'none', border: 'none', cursor: 'pointer',
color: activeYear === year ? NAVY : GRAY, color: selectedYear === year ? NAVY : GRAY,
borderBottom: activeYear === year ? `2px solid ${GOLD}` : '2px solid transparent', borderBottom: selectedYear === year ? `2px solid ${GOLD}` : '2px solid transparent',
marginBottom: -2, marginBottom: -2,
letterSpacing: '0.04em', letterSpacing: '0.04em',
transition: 'color 0.15s, border-color 0.15s', transition: 'color 0.15s, border-color 0.15s',
@@ -109,22 +124,22 @@ export default function Preistraeger() {
<SelectControl <SelectControl
value={filterCategory} value={filterCategory}
onChange={setFilterCategory} onChange={setFilterCategory}
placeholder="Preiskategorie …" placeholder={fallbackText(filters.categoryPlaceholder, preistraegerIndexContent.filters.categoryPlaceholder)}
options={CATEGORIES.map(c => ({ label: c, value: c }))} options={categories.map(c => ({ label: c, value: c }))}
width={isMobile ? undefined : 280} width={isMobile ? undefined : 280}
/> />
<SearchControl value={search} onChange={setSearch} /> <SearchControl value={search} onChange={setSearch} placeholder={fallbackText(filters.searchPlaceholder, preistraegerIndexContent.filters.searchPlaceholder)} />
</div> </div>
{/* Toggle row */} {/* Toggle row */}
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', flexWrap: 'wrap', gap: 12 }}> <div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', flexWrap: 'wrap', gap: 12 }}>
<div style={{ display: 'flex', flexDirection: 'column', gap: 6 }}> <div style={{ display: 'flex', flexDirection: 'column', gap: 6 }}>
<span style={{ fontFamily: FF, fontSize: 15, fontWeight: 700, color: '#101828' }}> <span style={{ fontFamily: FF, fontSize: 15, fontWeight: 700, color: '#101828' }}>
Diese Erfolgsgeschichten müssen erzählt werden. {fallbackText(filters.storyHeading, preistraegerIndexContent.filters.storyHeading)}
</span> </span>
<div style={{ display: 'flex', alignItems: 'center', gap: 8 }}> <div style={{ display: 'flex', alignItems: 'center', gap: 8 }}>
<span style={{ fontSize: 15 }}>💬</span> <span style={{ fontSize: 15 }}>{fallbackText(filters.mediaIcon, preistraegerIndexContent.filters.mediaIcon)}</span>
<span style={{ fontFamily: FF, fontSize: 15, color: GRAY }}>Mit Medienbeiträgen (Videos & Storys)</span> <span style={{ fontFamily: FF, fontSize: 15, color: GRAY }}>{fallbackText(filters.mediaLabel, preistraegerIndexContent.filters.mediaLabel)}</span>
<button <button
onClick={() => setMediaOnly(v => !v)} onClick={() => setMediaOnly(v => !v)}
style={{ style={{
@@ -147,7 +162,7 @@ export default function Preistraeger() {
onMouseEnter={e => (e.currentTarget.style.color = GOLD)} onMouseEnter={e => (e.currentTarget.style.color = GOLD)}
onMouseLeave={e => (e.currentTarget.style.color = GRAY)} onMouseLeave={e => (e.currentTarget.style.color = GRAY)}
> >
<X size={14} /> Filter zurücksetzen <X size={14} /> {fallbackText(filters.resetLabel, preistraegerIndexContent.filters.resetLabel)}
</button> </button>
)} )}
</div> </div>
@@ -157,14 +172,14 @@ export default function Preistraeger() {
<div style={{ padding: isMobile ? '12px 24px' : '12px 80px', background: BG_ALT, borderTop: `1px solid ${BORDER}`, borderBottom: `1px solid ${BORDER}`, display: 'flex', alignItems: 'center', gap: 12 }}> <div style={{ padding: isMobile ? '12px 24px' : '12px 80px', background: BG_ALT, borderTop: `1px solid ${BORDER}`, borderBottom: `1px solid ${BORDER}`, display: 'flex', alignItems: 'center', gap: 12 }}>
<Trophy size={13} style={{ color: GOLD }} fill={GOLD} /> <Trophy size={13} style={{ color: GOLD }} fill={GOLD} />
<span style={{ fontFamily: FF, fontSize: 15, color: GRAY }}> <span style={{ fontFamily: FF, fontSize: 15, color: GRAY }}>
<strong style={{ color: '#101828' }}>{activeYear}</strong> {filtered.length} Preisträger:innen <strong style={{ color: '#101828' }}>{selectedYear}</strong> {filtered.length} {fallbackText(count.label, preistraegerIndexContent.count.label)}
</span> </span>
</div> </div>
{/* Grid */} {/* Grid */}
{filtered.length === 0 ? ( {filtered.length === 0 ? (
<div style={{ padding: isMobile ? '56px 24px' : '80px', textAlign: 'center', color: GRAY, fontFamily: FF }}> <div style={{ padding: isMobile ? '56px 24px' : '80px', textAlign: 'center', color: GRAY, fontFamily: FF }}>
Keine Preisträger für diese Auswahl. {fallbackText(empty.message, preistraegerIndexContent.empty.message)}
</div> </div>
) : ( ) : (
<div style={{ <div style={{
@@ -181,16 +196,16 @@ export default function Preistraeger() {
scrollbarWidth: 'none', scrollbarWidth: 'none',
}}> }}>
{filtered.map(w => ( {filtered.map(w => (
<WinnerCard key={w.id} winner={w} /> <WinnerCard key={w.id} winner={w} hoverLabel={fallbackText(card.hoverLabel, preistraegerIndexContent.card.hoverLabel)} />
))} ))}
</div> </div>
)} )}
{/* Bottom CTA */} {/* Bottom CTA */}
<div style={{ padding: isMobile ? '48px 24px' : '64px 80px', borderTop: `1px solid ${BORDER}`, textAlign: 'center' }}> <div style={{ padding: isMobile ? '48px 24px' : '64px 80px', borderTop: `1px solid ${BORDER}`, textAlign: 'center' }}>
<p style={{ fontFamily: FF, fontSize: 17, color: GRAY, marginBottom: 20 }}>Werden Sie der nächste Preisträger.</p> <p style={{ fontFamily: FF, fontSize: 17, color: GRAY, marginBottom: 20 }}>{fallbackText(cta.text, preistraegerIndexContent.cta.text)}</p>
<Link <Link
to="/teilnahme" to={fallbackText(cta.primaryCta?.url, preistraegerIndexContent.cta.primaryCta.url)}
style={{ style={{
fontFamily: FF, fontSize: 16, fontWeight: 700, textTransform: 'uppercase', fontFamily: FF, fontSize: 16, fontWeight: 700, textTransform: 'uppercase',
letterSpacing: '0.12em', color: '#101828', background: GOLD, letterSpacing: '0.12em', color: '#101828', background: GOLD,
@@ -200,13 +215,13 @@ export default function Preistraeger() {
onMouseEnter={e => { (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)'; }} onMouseEnter={e => { (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'; }} onMouseLeave={e => { (e.currentTarget as HTMLElement).style.background = GOLD; (e.currentTarget as HTMLElement).style.boxShadow = 'none'; }}
> >
Jetzt kostenlos bewerben <ArrowRight size={14} /> {fallbackText(cta.primaryCta?.label, preistraegerIndexContent.cta.primaryCta.label)} <ArrowRight size={14} />
</Link> </Link>
<div style={{ marginTop: 20, display: 'flex', alignItems: 'center', justifyContent: isMobile ? 'center' : undefined, flexWrap: isMobile ? 'wrap' : 'nowrap', gap: 8 }}> <div style={{ marginTop: 20, display: 'flex', alignItems: 'center', justifyContent: isMobile ? 'center' : undefined, flexWrap: isMobile ? 'wrap' : 'nowrap', gap: 8 }}>
<span style={{ width: 20, height: 1, background: 'rgba(239,191,4,0.3)', display: 'inline-block' }} /> <span style={{ width: 20, height: 1, background: 'rgba(239,191,4,0.3)', display: 'inline-block' }} />
<span style={{ fontFamily: '"IBM Plex Sans", sans-serif', fontSize: 11, color: 'rgba(16,24,40,0.4)' }}>Diese Arbeit unterstützen:</span> <span style={{ fontFamily: '"IBM Plex Sans", sans-serif', fontSize: 11, color: 'rgba(16,24,40,0.4)' }}>{fallbackText(cta.secondaryPrefix, preistraegerIndexContent.cta.secondaryPrefix)}</span>
<Link <Link
to="/mitglied-werden" to={fallbackText(cta.secondaryCta?.url, preistraegerIndexContent.cta.secondaryCta.url)}
style={{ style={{
fontFamily: '"IBM Plex Sans", sans-serif', fontFamily: '"IBM Plex Sans", sans-serif',
fontSize: 11, fontSize: 11,
@@ -231,7 +246,7 @@ export default function Preistraeger() {
(e.currentTarget as HTMLElement).style.borderBottomColor = 'rgba(239,191,4,0.3)'; (e.currentTarget as HTMLElement).style.borderBottomColor = 'rgba(239,191,4,0.3)';
}} }}
> >
Vereinsmitglied werden <ChevronRight size={10} /> {fallbackText(cta.secondaryCta?.label, preistraegerIndexContent.cta.secondaryCta.label)} <ChevronRight size={10} />
</Link> </Link>
</div> </div>
</div> </div>
@@ -241,7 +256,7 @@ export default function Preistraeger() {
// ── WinnerCard ─────────────────────────────────────────────────────────────── // ── WinnerCard ───────────────────────────────────────────────────────────────
function WinnerCard({ winner }: { winner: import('@/spa/data/winners').Winner }) { function WinnerCard({ winner, hoverLabel }: { winner: Winner; hoverLabel: string }) {
const [hovered, setHovered] = useState(false); const [hovered, setHovered] = useState(false);
const isMobile = useIsMobile(); const isMobile = useIsMobile();
return ( return (
@@ -288,7 +303,7 @@ function WinnerCard({ winner }: { winner: import('@/spa/data/winners').Winner })
transform: hovered ? 'translateY(0)' : 'translateY(6px)', transform: hovered ? 'translateY(0)' : 'translateY(6px)',
transition: 'opacity 0.25s, transform 0.25s', transition: 'opacity 0.25s, transform 0.25s',
}}> }}>
<Trophy size={14} /> Mehr erfahren <Trophy size={14} /> {hoverLabel}
</div> </div>
</div> </div>
</Link> </Link>
@@ -330,14 +345,14 @@ function SelectControl({ value, onChange, placeholder, options, width }: {
// ── SearchControl ──────────────────────────────────────────────────────────── // ── 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 ( return (
<div style={{ position: 'relative', flex: 1, minWidth: 200 }}> <div style={{ position: 'relative', flex: 1, minWidth: 200 }}>
<input <input
type="text" type="text"
value={value} value={value}
onChange={e => onChange(e.target.value)} onChange={e => onChange(e.target.value)}
placeholder="Stichwortsuche …" placeholder={placeholder}
style={{ style={{
width: '100%', height: 44, padding: '0 40px 0 14px', width: '100%', height: 44, padding: '0 40px 0 14px',
fontFamily: FF, fontSize: 16, fontFamily: FF, fontSize: 16,

View File

@@ -19,7 +19,7 @@ export default function PreistraegerDetail() {
const { slug } = useParams<{ slug: string }>(); const { slug } = useParams<{ slug: string }>();
const route = useCmsRoute(); const route = useCmsRoute();
const cmsWinner = route?.collection === 'preistraeger' ? route.doc : undefined; const cmsWinner = route?.collection === 'preistraeger' ? route.doc : undefined;
const winner = WINNERS.find(w => w.slug === slug) || (cmsWinner ? { const winner = cmsWinner ? {
id: String(cmsWinner.id), id: String(cmsWinner.id),
slug: String(cmsWinner.slug || slug), slug: String(cmsWinner.slug || slug),
name: String(cmsWinner.title || 'Preisträger'), name: String(cmsWinner.title || 'Preisträger'),
@@ -36,7 +36,7 @@ export default function PreistraegerDetail() {
industry: String(cmsWinner.industry || 'Mittelstand'), industry: String(cmsWinner.industry || 'Mittelstand'),
website: String(cmsWinner.website || '#'), website: String(cmsWinner.website || '#'),
hasMedia: Boolean(cmsWinner.hasMedia), hasMedia: Boolean(cmsWinner.hasMedia),
} : undefined); } : WINNERS.find(w => w.slug === slug);
const isMobile = useIsMobile(); const isMobile = useIsMobile();
if (!winner) return <Navigate to="/preistraeger" replace />; if (!winner) return <Navigate to="/preistraeger" replace />;

View File

@@ -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',
},
},
}