feat: manage preistraeger index via payload
This commit is contained in:
@@ -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",
|
||||
|
||||
@@ -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',
|
||||
|
||||
116
src/collections/Pages/preistraegerIndexFields.ts
Normal file
116
src/collections/Pages/preistraegerIndexFields.ts
Normal 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),
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
]
|
||||
@@ -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<T extends boolean = true> {
|
||||
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
|
||||
| {
|
||||
|
||||
41
src/scripts/preload-preistraeger-index-page-cms.ts
Normal file
41
src/scripts/preload-preistraeger-index-page-cms.ts
Normal 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)
|
||||
})
|
||||
@@ -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<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() {
|
||||
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<number | undefined>(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 */}
|
||||
<div style={{ width: '100%', height: '50vh', overflow: 'hidden', position: 'relative' }}>
|
||||
<Image unoptimized
|
||||
src="https://images.unsplash.com/photo-1492684223066-81342ee5ff30?auto=format&fit=crop&q=80&w=2000"
|
||||
alt="Preisverleihung"
|
||||
src={mediaUrl(hero.image, fallbackText(hero.imageUrl, preistraegerIndexContent.hero.imageUrl))}
|
||||
alt={mediaAlt(hero.image, fallbackText(hero.imageAlt, preistraegerIndexContent.hero.imageAlt))}
|
||||
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%)' }} />
|
||||
@@ -74,7 +89,7 @@ export default function Preistraeger() {
|
||||
|
||||
{/* Breadcrumb */}
|
||||
<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>
|
||||
|
||||
{/* Filter Section */}
|
||||
@@ -83,7 +98,7 @@ export default function Preistraeger() {
|
||||
|
||||
{/* 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%' }}>
|
||||
{YEARS.map(year => (
|
||||
{years.map(year => (
|
||||
<button
|
||||
key={year}
|
||||
onClick={() => { setActiveYear(year); reset(); }}
|
||||
@@ -92,8 +107,8 @@ export default function Preistraeger() {
|
||||
padding: isMobile ? '12px 20px' : '12px 32px',
|
||||
flexShrink: 0,
|
||||
background: 'none', border: 'none', cursor: 'pointer',
|
||||
color: activeYear === year ? NAVY : GRAY,
|
||||
borderBottom: activeYear === year ? `2px solid ${GOLD}` : '2px solid transparent',
|
||||
color: selectedYear === year ? NAVY : GRAY,
|
||||
borderBottom: selectedYear === year ? `2px solid ${GOLD}` : '2px solid transparent',
|
||||
marginBottom: -2,
|
||||
letterSpacing: '0.04em',
|
||||
transition: 'color 0.15s, border-color 0.15s',
|
||||
@@ -109,22 +124,22 @@ export default function Preistraeger() {
|
||||
<SelectControl
|
||||
value={filterCategory}
|
||||
onChange={setFilterCategory}
|
||||
placeholder="Preiskategorie …"
|
||||
options={CATEGORIES.map(c => ({ label: c, value: c }))}
|
||||
placeholder={fallbackText(filters.categoryPlaceholder, preistraegerIndexContent.filters.categoryPlaceholder)}
|
||||
options={categories.map(c => ({ label: c, value: c }))}
|
||||
width={isMobile ? undefined : 280}
|
||||
/>
|
||||
<SearchControl value={search} onChange={setSearch} />
|
||||
<SearchControl value={search} onChange={setSearch} placeholder={fallbackText(filters.searchPlaceholder, preistraegerIndexContent.filters.searchPlaceholder)} />
|
||||
</div>
|
||||
|
||||
{/* Toggle row */}
|
||||
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', flexWrap: 'wrap', gap: 12 }}>
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 6 }}>
|
||||
<span style={{ fontFamily: FF, fontSize: 15, fontWeight: 700, color: '#101828' }}>
|
||||
Diese Erfolgsgeschichten müssen erzählt werden.
|
||||
{fallbackText(filters.storyHeading, preistraegerIndexContent.filters.storyHeading)}
|
||||
</span>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 8 }}>
|
||||
<span style={{ fontSize: 15 }}>💬</span>
|
||||
<span style={{ fontFamily: FF, fontSize: 15, color: GRAY }}>Mit Medienbeiträgen (Videos & Storys)</span>
|
||||
<span style={{ fontSize: 15 }}>{fallbackText(filters.mediaIcon, preistraegerIndexContent.filters.mediaIcon)}</span>
|
||||
<span style={{ fontFamily: FF, fontSize: 15, color: GRAY }}>{fallbackText(filters.mediaLabel, preistraegerIndexContent.filters.mediaLabel)}</span>
|
||||
<button
|
||||
onClick={() => setMediaOnly(v => !v)}
|
||||
style={{
|
||||
@@ -147,7 +162,7 @@ export default function Preistraeger() {
|
||||
onMouseEnter={e => (e.currentTarget.style.color = GOLD)}
|
||||
onMouseLeave={e => (e.currentTarget.style.color = GRAY)}
|
||||
>
|
||||
<X size={14} /> Filter zurücksetzen
|
||||
<X size={14} /> {fallbackText(filters.resetLabel, preistraegerIndexContent.filters.resetLabel)}
|
||||
</button>
|
||||
)}
|
||||
</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 }}>
|
||||
<Trophy size={13} style={{ color: GOLD }} fill={GOLD} />
|
||||
<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>
|
||||
</div>
|
||||
|
||||
{/* Grid */}
|
||||
{filtered.length === 0 ? (
|
||||
<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 style={{
|
||||
@@ -181,16 +196,16 @@ export default function Preistraeger() {
|
||||
scrollbarWidth: 'none',
|
||||
}}>
|
||||
{filtered.map(w => (
|
||||
<WinnerCard key={w.id} winner={w} />
|
||||
<WinnerCard key={w.id} winner={w} hoverLabel={fallbackText(card.hoverLabel, preistraegerIndexContent.card.hoverLabel)} />
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Bottom CTA */}
|
||||
<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
|
||||
to="/teilnahme"
|
||||
to={fallbackText(cta.primaryCta?.url, preistraegerIndexContent.cta.primaryCta.url)}
|
||||
style={{
|
||||
fontFamily: FF, fontSize: 16, fontWeight: 700, textTransform: 'uppercase',
|
||||
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)'; }}
|
||||
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>
|
||||
<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={{ 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
|
||||
to="/mitglied-werden"
|
||||
to={fallbackText(cta.secondaryCta?.url, preistraegerIndexContent.cta.secondaryCta.url)}
|
||||
style={{
|
||||
fontFamily: '"IBM Plex Sans", sans-serif',
|
||||
fontSize: 11,
|
||||
@@ -231,7 +246,7 @@ export default function Preistraeger() {
|
||||
(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>
|
||||
</div>
|
||||
</div>
|
||||
@@ -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',
|
||||
}}>
|
||||
<Trophy size={14} /> Mehr erfahren
|
||||
<Trophy size={14} /> {hoverLabel}
|
||||
</div>
|
||||
</div>
|
||||
</Link>
|
||||
@@ -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 (
|
||||
<div style={{ position: 'relative', flex: 1, minWidth: 200 }}>
|
||||
<input
|
||||
type="text"
|
||||
value={value}
|
||||
onChange={e => onChange(e.target.value)}
|
||||
placeholder="Stichwortsuche …"
|
||||
placeholder={placeholder}
|
||||
style={{
|
||||
width: '100%', height: 44, padding: '0 40px 0 14px',
|
||||
fontFamily: FF, fontSize: 16,
|
||||
|
||||
@@ -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 <Navigate to="/preistraeger" replace />;
|
||||
|
||||
38
src/spa/preistraegerIndexContent.ts
Normal file
38
src/spa/preistraegerIndexContent.ts
Normal 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',
|
||||
},
|
||||
},
|
||||
}
|
||||
Reference in New Issue
Block a user