feat: implement contact form submission and database integration
- Added a new collection for contact submissions with validation and error handling. - Created a migration script to set up the contact submissions table in the database. - Updated the SponsoringForm component to handle newsletter subscriptions based on application phase. - Refactored the UI components for improved user experience and error messaging. - Removed the package selection step from the sponsoring form. - Updated the styling across various pages to maintain consistency with the new design.
This commit is contained in:
125
src/collections/ContactSubmissions.ts
Normal file
125
src/collections/ContactSubmissions.ts
Normal file
@@ -0,0 +1,125 @@
|
||||
import type { CollectionConfig, PayloadRequest } from 'payload'
|
||||
|
||||
import { authenticated } from '@/access/authenticated'
|
||||
|
||||
const emailPattern = /^[^\s@]+@[^\s@]+\.[^\s@]+$/
|
||||
|
||||
type ContactSubmissionBody = {
|
||||
subject?: unknown
|
||||
name?: unknown
|
||||
email?: unknown
|
||||
message?: unknown
|
||||
website?: unknown
|
||||
}
|
||||
|
||||
const normalizedText = (value: unknown) => typeof value === 'string' ? value.trim() : ''
|
||||
|
||||
async function submitContactForm(req: PayloadRequest) {
|
||||
let body: ContactSubmissionBody
|
||||
|
||||
try {
|
||||
if (!req.json) throw new Error('Request body is unavailable.')
|
||||
body = (await req.json()) as ContactSubmissionBody
|
||||
} catch {
|
||||
return Response.json({ success: false }, { status: 400 })
|
||||
}
|
||||
|
||||
if (normalizedText(body.website)) return Response.json({ success: true })
|
||||
|
||||
const subject = normalizedText(body.subject)
|
||||
const name = normalizedText(body.name)
|
||||
const email = normalizedText(body.email).toLowerCase()
|
||||
const message = normalizedText(body.message)
|
||||
|
||||
if (
|
||||
!subject || subject.length > 120 ||
|
||||
!name || name.length > 200 ||
|
||||
!email || email.length > 254 || !emailPattern.test(email) ||
|
||||
!message || message.length > 10_000
|
||||
) {
|
||||
return Response.json({ success: false }, { status: 400 })
|
||||
}
|
||||
|
||||
try {
|
||||
await req.payload.create({
|
||||
collection: 'contact-submissions',
|
||||
data: {
|
||||
subject,
|
||||
name,
|
||||
email,
|
||||
message,
|
||||
submittedAt: new Date().toISOString(),
|
||||
},
|
||||
})
|
||||
|
||||
return Response.json({ success: true })
|
||||
} catch (error) {
|
||||
req.payload.logger.error({ err: error, msg: 'Contact submission could not be stored.' })
|
||||
return Response.json({ success: false }, { status: 500 })
|
||||
}
|
||||
}
|
||||
|
||||
export const ContactSubmissions: CollectionConfig<'contact-submissions'> = {
|
||||
slug: 'contact-submissions',
|
||||
labels: {
|
||||
singular: 'Kontaktanfrage',
|
||||
plural: 'Kontaktanfragen',
|
||||
},
|
||||
access: {
|
||||
create: authenticated,
|
||||
delete: authenticated,
|
||||
read: authenticated,
|
||||
update: authenticated,
|
||||
},
|
||||
admin: {
|
||||
defaultColumns: ['submittedAt', 'subject', 'name', 'email'],
|
||||
group: 'Formulare',
|
||||
useAsTitle: 'email',
|
||||
},
|
||||
endpoints: [
|
||||
{
|
||||
path: '/submit',
|
||||
method: 'post',
|
||||
handler: submitContactForm,
|
||||
},
|
||||
],
|
||||
fields: [
|
||||
{
|
||||
name: 'subject',
|
||||
label: 'Betreff',
|
||||
type: 'text',
|
||||
required: true,
|
||||
},
|
||||
{
|
||||
name: 'name',
|
||||
label: 'Name',
|
||||
type: 'text',
|
||||
required: true,
|
||||
},
|
||||
{
|
||||
name: 'email',
|
||||
label: 'E-Mail-Adresse',
|
||||
type: 'email',
|
||||
required: true,
|
||||
index: true,
|
||||
},
|
||||
{
|
||||
name: 'message',
|
||||
label: 'Nachricht',
|
||||
type: 'textarea',
|
||||
required: true,
|
||||
},
|
||||
{
|
||||
name: 'submittedAt',
|
||||
label: 'Eingegangen am',
|
||||
type: 'date',
|
||||
required: true,
|
||||
defaultValue: () => new Date().toISOString(),
|
||||
index: true,
|
||||
admin: {
|
||||
date: { displayFormat: 'dd.MM.yyyy HH:mm' },
|
||||
readOnly: true,
|
||||
},
|
||||
},
|
||||
],
|
||||
}
|
||||
@@ -4,8 +4,8 @@ import { authenticated } from '@/access/authenticated'
|
||||
import type { NewsletterPhase, NewsletterSource } from '@/spa/newsletterForm'
|
||||
|
||||
const emailPattern = /^[^\s@]+@[^\s@]+\.[^\s@]+$/
|
||||
const sources = new Set<NewsletterSource>(['homepage', 'participation'])
|
||||
const phases = new Set<NewsletterPhase>(['evaluation', 'completed'])
|
||||
const sources = new Set<NewsletterSource>(['homepage', 'participation', 'network'])
|
||||
const phases = new Set<NewsletterPhase>(['open', 'evaluation', 'completed'])
|
||||
|
||||
type SubscribeBody = {
|
||||
email?: unknown
|
||||
@@ -118,6 +118,7 @@ export const NewsletterSubscriptions: CollectionConfig<'newsletter-subscriptions
|
||||
options: [
|
||||
{ label: 'Startseite', value: 'homepage' },
|
||||
{ label: 'Teilnahme-Seite', value: 'participation' },
|
||||
{ label: 'Netzwerk / Sponsoring', value: 'network' },
|
||||
],
|
||||
},
|
||||
{
|
||||
@@ -126,6 +127,7 @@ export const NewsletterSubscriptions: CollectionConfig<'newsletter-subscriptions
|
||||
type: 'select',
|
||||
required: true,
|
||||
options: [
|
||||
{ label: 'Bewerbungsphase offen', value: 'open' },
|
||||
{ label: 'Jury / Auswertung', value: 'evaluation' },
|
||||
{ label: 'Preisverleihung abgeschlossen', value: 'completed' },
|
||||
],
|
||||
|
||||
118
src/migrations/20260713_194500_contact_submissions.ts
Normal file
118
src/migrations/20260713_194500_contact_submissions.ts
Normal file
@@ -0,0 +1,118 @@
|
||||
import { type MigrateDownArgs, type MigrateUpArgs, sql } from '@payloadcms/db-sqlite'
|
||||
|
||||
type MigrationDB = MigrateUpArgs['db']
|
||||
|
||||
const lockedRelationColumns = [
|
||||
'id',
|
||||
'order',
|
||||
'parent_id',
|
||||
'path',
|
||||
'pages_id',
|
||||
'preistraeger_id',
|
||||
'jury_mitglieder_id',
|
||||
'partners_id',
|
||||
'posts_id',
|
||||
'events_id',
|
||||
'newsletter_subscriptions_id',
|
||||
'media_id',
|
||||
'users_id',
|
||||
'payload_folders_id',
|
||||
] as const
|
||||
|
||||
async function tableExists(db: MigrationDB, tableName: string) {
|
||||
const rows = (await db.all(
|
||||
sql.raw(`SELECT name FROM sqlite_master WHERE type = 'table' AND name = '${tableName.replace(/'/g, "''")}'`),
|
||||
)) as Array<{ name: string }>
|
||||
|
||||
return rows.length > 0
|
||||
}
|
||||
|
||||
async function rebuildLockedDocumentRelations(db: MigrationDB, includeContactSubmissions: boolean) {
|
||||
if (!(await tableExists(db, 'payload_locked_documents_rels'))) return
|
||||
|
||||
const contactColumn = includeContactSubmissions ? ',\n `contact_submissions_id` integer' : ''
|
||||
const contactForeignKey = includeContactSubmissions
|
||||
? ',\n FOREIGN KEY (`contact_submissions_id`) REFERENCES `contact_submissions`(`id`) ON UPDATE no action ON DELETE cascade'
|
||||
: ''
|
||||
|
||||
await db.run(sql.raw('DROP TABLE IF EXISTS `__new_payload_locked_documents_rels`;'))
|
||||
await db.run(sql.raw(`
|
||||
CREATE TABLE "__new_payload_locked_documents_rels" (
|
||||
"id" integer PRIMARY KEY NOT NULL,
|
||||
"order" integer,
|
||||
"parent_id" integer NOT NULL,
|
||||
"path" text NOT NULL,
|
||||
"pages_id" integer,
|
||||
"preistraeger_id" integer,
|
||||
"jury_mitglieder_id" integer,
|
||||
"partners_id" integer,
|
||||
"posts_id" integer,
|
||||
"events_id" integer,
|
||||
"newsletter_subscriptions_id" integer${contactColumn},
|
||||
"media_id" integer,
|
||||
"users_id" integer,
|
||||
"payload_folders_id" integer,
|
||||
FOREIGN KEY ("parent_id") REFERENCES "payload_locked_documents"("id") ON UPDATE no action ON DELETE cascade,
|
||||
FOREIGN KEY ("pages_id") REFERENCES "pages"("id") ON UPDATE no action ON DELETE cascade,
|
||||
FOREIGN KEY ("preistraeger_id") REFERENCES "preistraeger"("id") ON UPDATE no action ON DELETE cascade,
|
||||
FOREIGN KEY ("jury_mitglieder_id") REFERENCES "jury_mitglieder"("id") ON UPDATE no action ON DELETE cascade,
|
||||
FOREIGN KEY ("partners_id") REFERENCES "partners"("id") ON UPDATE no action ON DELETE cascade,
|
||||
FOREIGN KEY ("posts_id") REFERENCES "posts"("id") ON UPDATE no action ON DELETE cascade,
|
||||
FOREIGN KEY ("events_id") REFERENCES "events"("id") ON UPDATE no action ON DELETE cascade,
|
||||
FOREIGN KEY ("newsletter_subscriptions_id") REFERENCES "newsletter_subscriptions"("id") ON UPDATE no action ON DELETE cascade${contactForeignKey},
|
||||
FOREIGN KEY ("media_id") REFERENCES "media"("id") ON UPDATE no action ON DELETE cascade,
|
||||
FOREIGN KEY ("users_id") REFERENCES "users"("id") ON UPDATE no action ON DELETE cascade,
|
||||
FOREIGN KEY ("payload_folders_id") REFERENCES "payload_folders"("id") ON UPDATE no action ON DELETE cascade
|
||||
);
|
||||
`))
|
||||
|
||||
const columns = lockedRelationColumns.map((column) => `"${column}"`).join(', ')
|
||||
await db.run(sql.raw(`INSERT INTO "__new_payload_locked_documents_rels" (${columns}) SELECT ${columns} FROM "payload_locked_documents_rels";`))
|
||||
await db.run(sql.raw('DROP TABLE `payload_locked_documents_rels`;'))
|
||||
await db.run(sql.raw('ALTER TABLE `__new_payload_locked_documents_rels` RENAME TO `payload_locked_documents_rels`;'))
|
||||
|
||||
for (const [suffix, column] of [
|
||||
['order', 'order'],
|
||||
['parent', 'parent_id'],
|
||||
['path', 'path'],
|
||||
['pages_id', 'pages_id'],
|
||||
['preistraeger_id', 'preistraeger_id'],
|
||||
['jury_mitglieder_id', 'jury_mitglieder_id'],
|
||||
['partners_id', 'partners_id'],
|
||||
['posts_id', 'posts_id'],
|
||||
['events_id', 'events_id'],
|
||||
['newsletter_subscriptions_i', 'newsletter_subscriptions_id'],
|
||||
...(includeContactSubmissions ? [['contact_submissions_id', 'contact_submissions_id']] : []),
|
||||
['media_id', 'media_id'],
|
||||
['users_id', 'users_id'],
|
||||
['payload_folders_id', 'payload_folders_id'],
|
||||
] as string[][]) {
|
||||
await db.run(sql.raw(`CREATE INDEX "payload_locked_documents_rels_${suffix}_idx" ON "payload_locked_documents_rels" ("${column}");`))
|
||||
}
|
||||
}
|
||||
|
||||
export async function up({ db, payload: _payload, req: _req }: MigrateUpArgs): Promise<void> {
|
||||
await db.run(sql.raw(`
|
||||
CREATE TABLE IF NOT EXISTS "contact_submissions" (
|
||||
"id" integer PRIMARY KEY NOT NULL,
|
||||
"subject" text NOT NULL,
|
||||
"name" text NOT NULL,
|
||||
"email" text NOT NULL,
|
||||
"message" text NOT NULL,
|
||||
"submitted_at" text NOT NULL,
|
||||
"updated_at" text DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ', 'now')) NOT NULL,
|
||||
"created_at" text DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ', 'now')) NOT NULL
|
||||
);
|
||||
`))
|
||||
await db.run(sql.raw('CREATE INDEX IF NOT EXISTS `contact_submissions_email_idx` ON `contact_submissions` (`email`);'))
|
||||
await db.run(sql.raw('CREATE INDEX IF NOT EXISTS `contact_submissions_submitted_at_idx` ON `contact_submissions` (`submitted_at`);'))
|
||||
await db.run(sql.raw('CREATE INDEX IF NOT EXISTS `contact_submissions_updated_at_idx` ON `contact_submissions` (`updated_at`);'))
|
||||
await db.run(sql.raw('CREATE INDEX IF NOT EXISTS `contact_submissions_created_at_idx` ON `contact_submissions` (`created_at`);'))
|
||||
|
||||
await rebuildLockedDocumentRelations(db, true)
|
||||
}
|
||||
|
||||
export async function down({ db, payload: _payload, req: _req }: MigrateDownArgs): Promise<void> {
|
||||
await rebuildLockedDocumentRelations(db, false)
|
||||
await db.run(sql.raw('DROP TABLE IF EXISTS `contact_submissions`;'))
|
||||
}
|
||||
@@ -12,6 +12,7 @@ import * as migration_20260712_120000_partner_media_only from './20260712_120000
|
||||
import * as migration_20260713_133000_newsletter_form_and_subscriptions from './20260713_133000_newsletter_form_and_subscriptions';
|
||||
import * as migration_20260713_155500_shared_application_form from './20260713_155500_shared_application_form';
|
||||
import * as migration_20260713_163000_participation_supporting_awards_copy from './20260713_163000_participation_supporting_awards_copy';
|
||||
import * as migration_20260713_194500_contact_submissions from './20260713_194500_contact_submissions';
|
||||
|
||||
export const migrations = [
|
||||
{
|
||||
@@ -84,4 +85,9 @@ export const migrations = [
|
||||
down: migration_20260713_163000_participation_supporting_awards_copy.down,
|
||||
name: '20260713_163000_participation_supporting_awards_copy',
|
||||
},
|
||||
{
|
||||
up: migration_20260713_194500_contact_submissions.up,
|
||||
down: migration_20260713_194500_contact_submissions.down,
|
||||
name: '20260713_194500_contact_submissions',
|
||||
},
|
||||
];
|
||||
|
||||
@@ -73,6 +73,7 @@ export interface Config {
|
||||
partners: Partner;
|
||||
posts: Post;
|
||||
events: Event;
|
||||
'contact-submissions': ContactSubmission;
|
||||
'newsletter-subscriptions': NewsletterSubscription;
|
||||
media: Media;
|
||||
users: User;
|
||||
@@ -95,6 +96,7 @@ export interface Config {
|
||||
partners: PartnersSelect<false> | PartnersSelect<true>;
|
||||
posts: PostsSelect<false> | PostsSelect<true>;
|
||||
events: EventsSelect<false> | EventsSelect<true>;
|
||||
'contact-submissions': ContactSubmissionsSelect<false> | ContactSubmissionsSelect<true>;
|
||||
'newsletter-subscriptions': NewsletterSubscriptionsSelect<false> | NewsletterSubscriptionsSelect<true>;
|
||||
media: MediaSelect<false> | MediaSelect<true>;
|
||||
users: UsersSelect<false> | UsersSelect<true>;
|
||||
@@ -2814,6 +2816,20 @@ export interface Event {
|
||||
createdAt: string;
|
||||
_status?: ('draft' | 'published') | null;
|
||||
}
|
||||
/**
|
||||
* This interface was referenced by `Config`'s JSON-Schema
|
||||
* via the `definition` "contact-submissions".
|
||||
*/
|
||||
export interface ContactSubmission {
|
||||
id: number;
|
||||
subject: string;
|
||||
name: string;
|
||||
email: string;
|
||||
message: string;
|
||||
submittedAt: string;
|
||||
updatedAt: string;
|
||||
createdAt: string;
|
||||
}
|
||||
/**
|
||||
* This interface was referenced by `Config`'s JSON-Schema
|
||||
* via the `definition` "newsletter-subscriptions".
|
||||
@@ -2821,8 +2837,8 @@ export interface Event {
|
||||
export interface NewsletterSubscription {
|
||||
id: number;
|
||||
email: string;
|
||||
source: 'homepage' | 'participation';
|
||||
phase: 'evaluation' | 'completed';
|
||||
source: 'homepage' | 'participation' | 'network';
|
||||
phase: 'open' | 'evaluation' | 'completed';
|
||||
lastSubmittedAt: string;
|
||||
updatedAt: string;
|
||||
createdAt: string;
|
||||
@@ -2967,6 +2983,10 @@ export interface PayloadLockedDocument {
|
||||
relationTo: 'events';
|
||||
value: number | Event;
|
||||
} | null)
|
||||
| ({
|
||||
relationTo: 'contact-submissions';
|
||||
value: number | ContactSubmission;
|
||||
} | null)
|
||||
| ({
|
||||
relationTo: 'newsletter-subscriptions';
|
||||
value: number | NewsletterSubscription;
|
||||
@@ -5120,6 +5140,19 @@ export interface EventsSelect<T extends boolean = true> {
|
||||
createdAt?: T;
|
||||
_status?: T;
|
||||
}
|
||||
/**
|
||||
* This interface was referenced by `Config`'s JSON-Schema
|
||||
* via the `definition` "contact-submissions_select".
|
||||
*/
|
||||
export interface ContactSubmissionsSelect<T extends boolean = true> {
|
||||
subject?: T;
|
||||
name?: T;
|
||||
email?: T;
|
||||
message?: T;
|
||||
submittedAt?: T;
|
||||
updatedAt?: T;
|
||||
createdAt?: T;
|
||||
}
|
||||
/**
|
||||
* This interface was referenced by `Config`'s JSON-Schema
|
||||
* via the `definition` "newsletter-subscriptions_select".
|
||||
|
||||
@@ -5,6 +5,7 @@ import { buildConfig, PayloadRequest } from 'payload'
|
||||
import { fileURLToPath } from 'url'
|
||||
|
||||
import { Events } from './collections/Events'
|
||||
import { ContactSubmissions } from './collections/ContactSubmissions'
|
||||
import { JuryMitglieder } from './collections/JuryMitglieder'
|
||||
import { Media } from './collections/Media'
|
||||
import { NewsletterSubscriptions } from './collections/NewsletterSubscriptions'
|
||||
@@ -70,7 +71,7 @@ export default buildConfig({
|
||||
url: process.env.DATABASE_URL || '',
|
||||
},
|
||||
}),
|
||||
collections: [Pages, Preistraeger, JuryMitglieder, Partners, Posts, Events, NewsletterSubscriptions, Media, Users],
|
||||
collections: [Pages, Preistraeger, JuryMitglieder, Partners, Posts, Events, ContactSubmissions, NewsletterSubscriptions, Media, Users],
|
||||
cors: [getServerSideURL()].filter(Boolean),
|
||||
globals: [Header, Footer, SiteSettings, ApplicationPhase, ApplicationForm, NewsletterForm],
|
||||
plugins,
|
||||
|
||||
@@ -5,6 +5,7 @@ import { useIsMobile } from '@/spa/hooks/useIsMobile';
|
||||
import { contactFormContent } from '@/spa/contactFormContent';
|
||||
|
||||
const FF = '"IBM Plex Sans", sans-serif';
|
||||
const SUBMISSION_ERROR = 'Die Nachricht konnte nicht gespeichert werden. Bitte versuchen Sie es später erneut.';
|
||||
|
||||
const DARK = {
|
||||
border: 'rgba(255,255,255,0.12)',
|
||||
@@ -201,12 +202,16 @@ export default function KontaktForm({ theme = 'dark', content }: { theme?: 'dark
|
||||
const [step, setStep] = useState(0);
|
||||
const [dir, setDir] = useState(1);
|
||||
const [submitted, setSubmitted] = useState(false);
|
||||
const [submitting, setSubmitting] = useState(false);
|
||||
const [submissionError, setSubmissionError] = useState('');
|
||||
const [website, setWebsite] = useState('');
|
||||
const [data, setData] = useState<FormData>({ betreff: 'allgemein', name: '', email: '', nachricht: '' });
|
||||
const [errors, setErrors] = useState<Partial<Record<keyof FormData, string>>>({});
|
||||
|
||||
const set = (k: keyof FormData, v: string) => {
|
||||
setData(d => ({ ...d, [k]: v }));
|
||||
setErrors(e => { const n = { ...e }; delete n[k]; return n; });
|
||||
setSubmissionError('');
|
||||
};
|
||||
|
||||
const validate = () => {
|
||||
@@ -223,7 +228,33 @@ export default function KontaktForm({ theme = 'dark', content }: { theme?: 'dark
|
||||
|
||||
const next = () => { if (validate()) { setDir(1); setStep(s => s + 1); } };
|
||||
const prev = () => { setDir(-1); setStep(s => s - 1); };
|
||||
const submit = () => { if (validate()) setSubmitted(true); };
|
||||
const submit = async () => {
|
||||
if (!validate() || submitting) return;
|
||||
|
||||
setSubmissionError('');
|
||||
setSubmitting(true);
|
||||
|
||||
try {
|
||||
const response = await fetch('/api/contact-submissions/submit', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
subject: copy.subjects.find((subject) => subject.value === data.betreff)?.label || data.betreff,
|
||||
name: data.name,
|
||||
email: data.email,
|
||||
message: data.nachricht,
|
||||
website,
|
||||
}),
|
||||
});
|
||||
|
||||
if (!response.ok) throw new Error(`Contact submission failed with ${response.status}`);
|
||||
setSubmitted(true);
|
||||
} catch {
|
||||
setSubmissionError(SUBMISSION_ERROR);
|
||||
} finally {
|
||||
setSubmitting(false);
|
||||
}
|
||||
};
|
||||
|
||||
if (submitted) {
|
||||
return (
|
||||
@@ -246,6 +277,19 @@ export default function KontaktForm({ theme = 'dark', content }: { theme?: 'dark
|
||||
|
||||
return (
|
||||
<div style={{ display: 'flex', flexDirection: 'column', flex: 1, minHeight: 0 }}>
|
||||
<div aria-hidden="true" style={{ position: 'absolute', left: '-10000px', width: 1, height: 1, overflow: 'hidden' }}>
|
||||
<label htmlFor="contact-website">Website</label>
|
||||
<input
|
||||
id="contact-website"
|
||||
name="website"
|
||||
type="text"
|
||||
tabIndex={-1}
|
||||
autoComplete="off"
|
||||
value={website}
|
||||
onChange={(event) => setWebsite(event.target.value)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Progress */}
|
||||
<div style={{ height: 3, background: c.progressBg, flexShrink: 0 }}>
|
||||
<div style={{ height: '100%', background: c.progressFill, width: step === 0 ? '0%' : `${(step / (copy.steps.length - 1)) * 100}%`, transition: 'width 0.4s ease' }} />
|
||||
@@ -347,10 +391,10 @@ export default function KontaktForm({ theme = 'dark', content }: { theme?: 'dark
|
||||
{fallbackText(copy.navigation.next, contactFormContent.navigation.next)} <ArrowRight size={14} />
|
||||
</button>
|
||||
) : (
|
||||
<button type="button" onClick={submit} style={{
|
||||
fontSize: 14, fontWeight: 700, color: c.btnText, background: c.btnBg, border: 'none', cursor: 'pointer',
|
||||
<button type="button" onClick={submit} disabled={submitting} style={{
|
||||
fontSize: 14, fontWeight: 700, color: c.btnText, background: c.btnBg, border: 'none', cursor: submitting ? 'wait' : 'pointer',
|
||||
padding: '12px 28px', fontFamily: FF, display: 'flex', alignItems: 'center', gap: 8,
|
||||
transition: 'background 0.15s',
|
||||
transition: 'background 0.15s', opacity: submitting ? 0.7 : 1,
|
||||
...(isMobile ? { width: '100%', justifyContent: 'center' } : {}),
|
||||
}}
|
||||
onMouseEnter={e => (e.currentTarget.style.background = c.btnBgHover)}
|
||||
@@ -361,6 +405,7 @@ export default function KontaktForm({ theme = 'dark', content }: { theme?: 'dark
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
{submissionError && <p role="alert" style={{ fontFamily: FF, fontSize: 11, color: c.errorText, margin: '10px 0 0', textAlign: 'right' }}>{submissionError}</p>}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,8 +1,10 @@
|
||||
import React, { useState } from 'react';
|
||||
import { AnimatePresence, motion } from 'framer-motion';
|
||||
import { ArrowRight, ArrowLeft, Check, Layers, Building2, Mail } from 'lucide-react';
|
||||
import { ArrowRight, ArrowLeft, Check, Building2, Mail } from 'lucide-react';
|
||||
import { useIsMobile } from '@/spa/hooks/useIsMobile';
|
||||
import { useApplicationPhase } from '@/spa/cmsRoute';
|
||||
import { netzwerkContent } from '@/spa/netzwerkContent';
|
||||
import { newsletterPhaseForApplicationPhase } from '@/spa/newsletterForm';
|
||||
|
||||
const FF = '"IBM Plex Sans", sans-serif';
|
||||
|
||||
@@ -87,23 +89,21 @@ const GOLD = {
|
||||
type C = typeof DARK;
|
||||
|
||||
type FormData = {
|
||||
paket: string;
|
||||
unternehmen: string;
|
||||
branche: string;
|
||||
kontakt: string;
|
||||
email: string;
|
||||
};
|
||||
|
||||
const STEP_ICONS = [Layers, Building2, Mail];
|
||||
const STEP_ICONS = [Building2, Mail];
|
||||
|
||||
type SponsoringFormContent = Partial<typeof netzwerkContent.sponsoringForm>;
|
||||
type PackageOption = (typeof netzwerkContent.sponsoringForm.packages)[number];
|
||||
type StepOption = (typeof netzwerkContent.sponsoringForm.steps)[number];
|
||||
|
||||
const fallbackText = (value: unknown, fallback: string) => typeof value === 'string' && value.length > 0 ? value : fallback;
|
||||
const fallbackArray = <T,>(value: unknown, fallback: T[]) => Array.isArray(value) && value.length ? value as T[] : fallback;
|
||||
const isPremiumPackage = (pkg: PackageOption) =>
|
||||
[pkg.value, pkg.title].some((value) => typeof value === 'string' && value.toLowerCase().includes('premium'));
|
||||
const numberedStepLabel = (value: unknown, fallback: string, step: number) =>
|
||||
fallbackText(value, fallback).replace(/^Schritt\s+\d+/i, `Schritt ${step}`);
|
||||
|
||||
const variants = {
|
||||
enter: (dir: number) => ({ x: dir > 0 ? 40 : -40, opacity: 0 }),
|
||||
@@ -142,51 +142,31 @@ function StyledInput({ c, ...props }: React.InputHTMLAttributes<HTMLInputElement
|
||||
);
|
||||
}
|
||||
|
||||
function PaketCard({ c, pkg, selected, onClick }: { c: C; pkg: PackageOption; selected: boolean; onClick: () => void }) {
|
||||
return (
|
||||
<button type="button" onClick={onClick} style={{
|
||||
width: '100%', padding: '13px 18px', textAlign: 'left', cursor: 'pointer',
|
||||
border: `2px solid ${selected ? c.cardBorderSel : c.cardBorder}`,
|
||||
background: selected ? c.cardBgSel : c.cardBg,
|
||||
fontFamily: FF, transition: 'all 0.15s',
|
||||
display: 'flex', alignItems: 'flex-start', justifyContent: 'space-between', gap: 12,
|
||||
}}>
|
||||
<div style={{ flex: 1 }}>
|
||||
<div style={{ fontSize: 14, fontWeight: 700, textTransform: 'uppercase', letterSpacing: '0.06em', color: selected ? c.cardTitleSel : c.cardTitle, marginBottom: 5 }}>{pkg.title}</div>
|
||||
<div style={{ fontSize: 13, color: c.cardDesc, lineHeight: 1.45 }}>{pkg.desc}</div>
|
||||
</div>
|
||||
<div style={{
|
||||
width: 18, height: 18, borderRadius: '50%', flexShrink: 0, marginTop: 2,
|
||||
border: `2px solid ${selected ? c.cardBorderSel : c.cardBorder}`,
|
||||
background: selected ? c.radioFill : 'transparent',
|
||||
transition: 'all 0.15s',
|
||||
}} />
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
export default function SponsoringForm({ theme = 'dark', content }: { theme?: 'dark' | 'gold'; content?: SponsoringFormContent }) {
|
||||
const c = theme === 'gold' ? GOLD : DARK;
|
||||
const isMobile = useIsMobile();
|
||||
const applicationPhase = useApplicationPhase();
|
||||
const copy = { ...netzwerkContent.sponsoringForm, ...(content || {}) };
|
||||
const steps = fallbackArray<StepOption>(copy.steps, netzwerkContent.sponsoringForm.steps);
|
||||
const packages = fallbackArray<PackageOption>(copy.packages, netzwerkContent.sponsoringForm.packages).filter((pkg) => !isPremiumPackage(pkg));
|
||||
const configuredSteps = fallbackArray<StepOption>(copy.steps, netzwerkContent.sponsoringForm.steps);
|
||||
const steps = configuredSteps.slice(-2);
|
||||
const [step, setStep] = useState(0);
|
||||
const [dir, setDir] = useState(1);
|
||||
const [submitted, setSubmitted] = useState(false);
|
||||
const [data, setData] = useState<FormData>({ paket: '', unternehmen: '', branche: '', kontakt: '', email: '' });
|
||||
const [submitting, setSubmitting] = useState(false);
|
||||
const [submissionError, setSubmissionError] = useState('');
|
||||
const [data, setData] = useState<FormData>({ unternehmen: '', branche: '', kontakt: '', email: '' });
|
||||
const [errors, setErrors] = useState<Partial<Record<keyof FormData, string>>>({});
|
||||
|
||||
const set = (k: keyof FormData, v: string) => {
|
||||
setData(d => ({ ...d, [k]: v }));
|
||||
setErrors(e => { const n = { ...e }; delete n[k]; return n; });
|
||||
setSubmissionError('');
|
||||
};
|
||||
|
||||
const validate = () => {
|
||||
const e: typeof errors = {};
|
||||
if (step === 0 && !data.paket) e.paket = fallbackText(copy.requiredPackageError, netzwerkContent.sponsoringForm.requiredPackageError);
|
||||
if (step === 1 && !data.unternehmen) e.unternehmen = fallbackText(copy.requiredFieldError, netzwerkContent.sponsoringForm.requiredFieldError);
|
||||
if (step === 2) {
|
||||
if (step === 0 && !data.unternehmen) e.unternehmen = fallbackText(copy.requiredFieldError, netzwerkContent.sponsoringForm.requiredFieldError);
|
||||
if (step === 1) {
|
||||
if (!data.kontakt) e.kontakt = fallbackText(copy.requiredFieldError, netzwerkContent.sponsoringForm.requiredFieldError);
|
||||
if (!data.email) e.email = fallbackText(copy.requiredFieldError, netzwerkContent.sponsoringForm.requiredFieldError);
|
||||
else if (!/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(data.email)) e.email = fallbackText(copy.invalidEmailError, netzwerkContent.sponsoringForm.invalidEmailError);
|
||||
@@ -197,7 +177,32 @@ export default function SponsoringForm({ theme = 'dark', content }: { theme?: 'd
|
||||
|
||||
const next = () => { if (validate()) { setDir(1); setStep(s => s + 1); } };
|
||||
const prev = () => { setDir(-1); setStep(s => s - 1); };
|
||||
const submit = () => { if (validate()) setSubmitted(true); };
|
||||
const submit = async () => {
|
||||
if (!validate() || submitting) return;
|
||||
|
||||
setSubmissionError('');
|
||||
setSubmitting(true);
|
||||
|
||||
try {
|
||||
const response = await fetch('/api/newsletter-subscriptions/subscribe', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
email: data.email.trim(),
|
||||
phase: newsletterPhaseForApplicationPhase(applicationPhase?.activePhase),
|
||||
source: 'network',
|
||||
website: '',
|
||||
}),
|
||||
});
|
||||
|
||||
if (!response.ok) throw new Error(`Sponsoring request failed with ${response.status}`);
|
||||
setSubmitted(true);
|
||||
} catch {
|
||||
setSubmissionError('Die Anfrage konnte nicht gespeichert werden. Bitte versuchen Sie es später erneut.');
|
||||
} finally {
|
||||
setSubmitting(false);
|
||||
}
|
||||
};
|
||||
|
||||
const progress = (step / (steps.length - 1)) * 100;
|
||||
|
||||
@@ -227,7 +232,7 @@ export default function SponsoringForm({ theme = 'dark', content }: { theme?: 'd
|
||||
{/* Step tabs */}
|
||||
<div style={{ display: 'flex', borderBottom: `1px solid ${c.border}`, flexShrink: 0 }}>
|
||||
{steps.map((s, i) => {
|
||||
const Icon = STEP_ICONS[i] || Layers;
|
||||
const Icon = STEP_ICONS[i] || Building2;
|
||||
const done = i < step, active = i === step;
|
||||
return (
|
||||
<div key={i} style={{
|
||||
@@ -240,7 +245,7 @@ export default function SponsoringForm({ theme = 'dark', content }: { theme?: 'd
|
||||
marginBottom: -1, transition: 'color 0.2s',
|
||||
}}>
|
||||
<Icon size={11} strokeWidth={active ? 2 : 1.5} />
|
||||
<span>{done ? fallbackText(copy.doneLabel, netzwerkContent.sponsoringForm.doneLabel) : fallbackText(s.label, netzwerkContent.sponsoringForm.steps[i]?.label || '')}</span>
|
||||
<span>{done ? fallbackText(copy.doneLabel, netzwerkContent.sponsoringForm.doneLabel) : fallbackText(s.label, '')}</span>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
@@ -254,24 +259,9 @@ export default function SponsoringForm({ theme = 'dark', content }: { theme?: 'd
|
||||
transition={{ duration: 0.22, ease: 'easeInOut' }}>
|
||||
|
||||
{step === 0 && (
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 10 }}>
|
||||
<div>
|
||||
<p style={{ fontFamily: FF, fontSize: 10, color: c.accentLabel, textTransform: 'uppercase', letterSpacing: '0.25em', fontWeight: 700, marginBottom: 6 }}>{fallbackText(copy.step1Eyebrow, netzwerkContent.sponsoringForm.step1Eyebrow)}</p>
|
||||
<h3 style={{ fontFamily: FF, fontSize: 18, fontWeight: 900, color: c.heading, textTransform: 'uppercase', letterSpacing: '-0.01em', marginBottom: 12 }}>{fallbackText(copy.step1Heading, netzwerkContent.sponsoringForm.step1Heading)}</h3>
|
||||
</div>
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 6 }}>
|
||||
{packages.map(pkg => (
|
||||
<PaketCard key={pkg.value} c={c} pkg={pkg} selected={data.paket === pkg.value} onClick={() => set('paket', pkg.value)} />
|
||||
))}
|
||||
</div>
|
||||
{errors.paket && <p style={{ fontFamily: FF, fontSize: 11, color: c.errorText, marginTop: 4 }}>{errors.paket}</p>}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{step === 1 && (
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 16 }}>
|
||||
<div>
|
||||
<p style={{ fontFamily: FF, fontSize: 10, color: c.accentLabel, textTransform: 'uppercase', letterSpacing: '0.25em', fontWeight: 700, marginBottom: 8 }}>{fallbackText(copy.step2Eyebrow, netzwerkContent.sponsoringForm.step2Eyebrow)}</p>
|
||||
<p style={{ fontFamily: FF, fontSize: 10, color: c.accentLabel, textTransform: 'uppercase', letterSpacing: '0.25em', fontWeight: 700, marginBottom: 8 }}>{numberedStepLabel(copy.step2Eyebrow, netzwerkContent.sponsoringForm.step2Eyebrow, 1)}</p>
|
||||
<h3 style={{ fontFamily: FF, fontSize: 18, fontWeight: 900, color: c.heading, textTransform: 'uppercase', letterSpacing: '-0.01em' }}>{fallbackText(copy.step2Heading, netzwerkContent.sponsoringForm.step2Heading)}</h3>
|
||||
</div>
|
||||
<Field c={c} label={fallbackText(copy.companyLabel, netzwerkContent.sponsoringForm.companyLabel)} error={errors.unternehmen}>
|
||||
@@ -283,10 +273,10 @@ export default function SponsoringForm({ theme = 'dark', content }: { theme?: 'd
|
||||
</div>
|
||||
)}
|
||||
|
||||
{step === 2 && (
|
||||
{step === 1 && (
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 16 }}>
|
||||
<div>
|
||||
<p style={{ fontFamily: FF, fontSize: 10, color: c.accentLabel, textTransform: 'uppercase', letterSpacing: '0.25em', fontWeight: 700, marginBottom: 8 }}>{fallbackText(copy.step3Eyebrow, netzwerkContent.sponsoringForm.step3Eyebrow)}</p>
|
||||
<p style={{ fontFamily: FF, fontSize: 10, color: c.accentLabel, textTransform: 'uppercase', letterSpacing: '0.25em', fontWeight: 700, marginBottom: 8 }}>{numberedStepLabel(copy.step3Eyebrow, netzwerkContent.sponsoringForm.step3Eyebrow, 2)}</p>
|
||||
<h3 style={{ fontFamily: FF, fontSize: 18, fontWeight: 900, color: c.heading, textTransform: 'uppercase', letterSpacing: '-0.01em' }}>{fallbackText(copy.step3Heading, netzwerkContent.sponsoringForm.step3Heading)}</h3>
|
||||
</div>
|
||||
<Field c={c} label={fallbackText(copy.contactLabel, netzwerkContent.sponsoringForm.contactLabel)} error={errors.kontakt}>
|
||||
@@ -331,9 +321,9 @@ export default function SponsoringForm({ theme = 'dark', content }: { theme?: 'd
|
||||
{fallbackText(copy.nextLabel, netzwerkContent.sponsoringForm.nextLabel)} <ArrowRight size={13} />
|
||||
</button>
|
||||
) : (
|
||||
<button type="button" onClick={submit} style={{
|
||||
<button type="button" onClick={submit} disabled={submitting} style={{
|
||||
fontFamily: FF, fontSize: 12, fontWeight: 700, textTransform: 'uppercase', letterSpacing: '0.1em',
|
||||
color: c.btnText, background: c.btnBg, border: 'none', cursor: 'pointer',
|
||||
color: c.btnText, background: c.btnBg, border: 'none', cursor: submitting ? 'wait' : 'pointer', opacity: submitting ? 0.7 : 1,
|
||||
padding: isMobile ? '14px 24px' : '12px 24px', minHeight: isMobile ? 44 : undefined,
|
||||
display: 'flex', alignItems: 'center', gap: 7, transition: 'background 0.15s',
|
||||
}}
|
||||
@@ -345,6 +335,8 @@ export default function SponsoringForm({ theme = 'dark', content }: { theme?: 'd
|
||||
)}
|
||||
</div>
|
||||
|
||||
{submissionError && <p role="alert" style={{ fontFamily: FF, fontSize: 11, color: c.errorText, margin: '10px 0 0', textAlign: 'right' }}>{submissionError}</p>}
|
||||
|
||||
<p style={{ fontFamily: FF, textAlign: 'center', fontSize: 12, color: c.footerText, paddingTop: 12 }}>
|
||||
{fallbackText(copy.footerText, netzwerkContent.sponsoringForm.footerText)}
|
||||
</p>
|
||||
|
||||
@@ -299,7 +299,6 @@ export const netzwerkContent = {
|
||||
},
|
||||
sponsoringForm: {
|
||||
steps: [
|
||||
{ label: 'Paket' },
|
||||
{ label: 'Unternehmen' },
|
||||
{ label: 'Kontakt' },
|
||||
],
|
||||
|
||||
@@ -34,8 +34,8 @@ export type SpaNewsletterFormData = {
|
||||
completed: NewsletterInterestCopy
|
||||
}
|
||||
|
||||
export type NewsletterSource = 'homepage' | 'participation'
|
||||
export type NewsletterPhase = 'evaluation' | 'completed'
|
||||
export type NewsletterSource = 'homepage' | 'participation' | 'network'
|
||||
export type NewsletterPhase = 'open' | 'evaluation' | 'completed'
|
||||
|
||||
export const defaultNewsletterFormData: SpaNewsletterFormData = {
|
||||
evaluation: {
|
||||
@@ -142,5 +142,6 @@ export function newsletterFormCopyForPhase(data: SpaNewsletterFormData | undefin
|
||||
}
|
||||
|
||||
export function newsletterPhaseForApplicationPhase(activePhase?: string): NewsletterPhase {
|
||||
if (activePhase === '0') return 'open'
|
||||
return activePhase === '1' ? 'evaluation' : 'completed'
|
||||
}
|
||||
|
||||
@@ -11,7 +11,7 @@ import { contactContent } from '@/spa/contactContent';
|
||||
|
||||
const NAVY = '#111D55';
|
||||
const GOLD = '#EFBF04';
|
||||
const CREAM = '#EFE5E3';
|
||||
const WHITE = '#fff';
|
||||
const INK = '#3A3A3A';
|
||||
|
||||
type ContactCms = NonNullable<Page['contact']>;
|
||||
@@ -53,7 +53,7 @@ const Contact: React.FC = () => {
|
||||
const faqItems = fallbackArray<FaqItem>(faq.items, contactContent.faq.items);
|
||||
|
||||
return (
|
||||
<div style={{ background: CREAM }}>
|
||||
<div style={{ background: WHITE }}>
|
||||
|
||||
{/* ── Hero ───────────────────────────────────────────────────────── */}
|
||||
<section style={{ position: 'relative', height: '68vh', minHeight: 520, overflow: 'hidden', display: 'flex', alignItems: 'flex-end' }}>
|
||||
@@ -210,7 +210,7 @@ const Contact: React.FC = () => {
|
||||
</section>
|
||||
|
||||
{/* ── FAQ ────────────────────────────────────────────────────────── */}
|
||||
<section id="faq" style={{ background: CREAM, padding: isMobile ? '56px 0 72px' : '96px 0 112px' }}>
|
||||
<section id="faq" style={{ background: WHITE, padding: isMobile ? '56px 0 72px' : '96px 0 112px' }}>
|
||||
<div className="container mx-auto px-6">
|
||||
<div style={{ display: 'grid', gridTemplateColumns: isMobile ? '1fr' : '1fr 2fr', gap: isMobile ? 40 : 80 }}>
|
||||
|
||||
|
||||
@@ -15,7 +15,6 @@ const FF = '"IBM Plex Sans", sans-serif';
|
||||
const FB = '"Inter", sans-serif';
|
||||
const NAVY = '#111D55';
|
||||
const GOLD = '#EFBF04';
|
||||
const CREAM = '#E4E2E3';
|
||||
|
||||
type NetzwerkCms = Partial<typeof netzwerkContent> & {
|
||||
hero?: Partial<typeof netzwerkContent.hero> & { image?: unknown }
|
||||
@@ -81,7 +80,7 @@ const normalizeJuryMember = (member: CmsRouteDoc): JuryMember => {
|
||||
return {
|
||||
name: fallbackText(member.name, ''),
|
||||
role,
|
||||
bio: fallbackText(member.bio, fallbackText(member.quote, '')),
|
||||
bio: fallbackText(member.bio, ''),
|
||||
image: mediaUrl(member.image, juryImageFallback),
|
||||
imageAlt: fallbackText(member.imageAlt, fallbackText(member.name, 'Jury-Mitglied')),
|
||||
imageUpload: member.image,
|
||||
@@ -91,10 +90,18 @@ const normalizeJuryMember = (member: CmsRouteDoc): JuryMember => {
|
||||
active: member.active !== false,
|
||||
}
|
||||
}
|
||||
const hasJuryText = (value: unknown) => typeof value === 'string' && value.trim().length > 0
|
||||
const hasCompleteJuryProfile = (member: JuryMember) => hasJuryText(member.bio) && hasJuryText(member.quote)
|
||||
const sortedJuryMembers = (members: JuryMember[]) =>
|
||||
members
|
||||
.map((member, index) => ({ member, index }))
|
||||
.sort((a, b) => Number(a.member.sortOrder ?? a.index) - Number(b.member.sortOrder ?? b.index))
|
||||
.sort((a, b) => {
|
||||
const completenessOrder = Number(hasCompleteJuryProfile(b.member)) - Number(hasCompleteJuryProfile(a.member))
|
||||
if (completenessOrder !== 0) return completenessOrder
|
||||
|
||||
const sortOrder = Number(a.member.sortOrder ?? a.index) - Number(b.member.sortOrder ?? b.index)
|
||||
return sortOrder !== 0 ? sortOrder : a.index - b.index
|
||||
})
|
||||
.map(({ member }) => member)
|
||||
function Lines({ text, highlight }: { text: string; highlight?: string }) {
|
||||
return (
|
||||
@@ -161,7 +168,8 @@ function PersonCard({ imgSrc, imgAlt, name, titleLine1, titleLine2, institution,
|
||||
const JuryCard: React.FC<{ member: JuryMember }> = ({ member }) => {
|
||||
const [hovered, setHovered] = useState(false);
|
||||
const isMobileCard = useIsMobile();
|
||||
const quote = fallbackText(member.quote, '');
|
||||
const bio = hasJuryText(member.bio) ? member.bio : '';
|
||||
const quote = hasJuryText(member.quote) ? member.quote : '';
|
||||
return (
|
||||
<div
|
||||
onMouseEnter={() => setHovered(true)}
|
||||
@@ -176,7 +184,9 @@ const JuryCard: React.FC<{ member: JuryMember }> = ({ member }) => {
|
||||
<div style={{ fontFamily: FF, fontSize: isMobileCard ? 16 : 17, fontWeight: 700, color: '#fff', lineHeight: 1.15, marginBottom: 5, minWidth: 0, overflowWrap: 'anywhere', wordBreak: 'break-word' }}>{member.name}</div>
|
||||
<div style={{ fontFamily: FF, fontSize: isMobileCard ? 9 : 10, color: GOLD, textTransform: 'uppercase', letterSpacing: isMobileCard ? '0.1em' : '0.14em', fontWeight: 700, lineHeight: 1.35, marginBottom: 10, minWidth: 0, overflowWrap: 'anywhere', wordBreak: 'break-word' }}>{member.role}</div>
|
||||
<div style={{ width: hovered ? '100%' : 20, height: 1, background: GOLD, opacity: 0.4, transition: 'width 0.4s ease', marginBottom: 10 }} />
|
||||
<div style={{ fontFamily: FB, fontSize: isMobileCard ? 14 : 15, color: 'rgba(255,255,255,0.55)', lineHeight: 1.55, minWidth: 0, overflowWrap: 'anywhere', wordBreak: 'break-word' }}>{member.bio}</div>
|
||||
{bio ? (
|
||||
<div style={{ fontFamily: FB, fontSize: isMobileCard ? 14 : 15, color: 'rgba(255,255,255,0.55)', lineHeight: 1.55, minWidth: 0, overflowWrap: 'anywhere', wordBreak: 'break-word' }}>{bio}</div>
|
||||
) : null}
|
||||
{quote ? (
|
||||
<div style={{ borderLeft: `2px solid ${GOLD}`, color: 'rgba(255,255,255,0.72)', fontFamily: FB, fontSize: isMobileCard ? 13 : 14, fontStyle: 'italic', lineHeight: 1.5, marginTop: 14, paddingLeft: 10, minWidth: 0, overflowWrap: 'anywhere', wordBreak: 'break-word' }}>
|
||||
{quote}
|
||||
@@ -192,7 +202,8 @@ const JuryCard: React.FC<{ member: JuryMember }> = ({ member }) => {
|
||||
const JuryChairCard: React.FC<{ member: JuryMember; badge: string }> = ({ member, badge }) => {
|
||||
const [hovered, setHovered] = useState(false);
|
||||
const isMobileCard = useIsMobile();
|
||||
const quote = fallbackText(member.quote, '');
|
||||
const bio = hasJuryText(member.bio) ? member.bio : '';
|
||||
const quote = hasJuryText(member.quote) ? member.quote : '';
|
||||
return (
|
||||
<div
|
||||
onMouseEnter={() => setHovered(true)}
|
||||
@@ -218,7 +229,9 @@ const JuryChairCard: React.FC<{ member: JuryMember; badge: string }> = ({ member
|
||||
<div style={{ fontFamily: FF, fontSize: isMobileCard ? 26 : 34, fontWeight: 900, color: '#fff', textTransform: 'uppercase', letterSpacing: '-0.02em', lineHeight: 1.05, marginBottom: 10 }}>{member.name}</div>
|
||||
<div style={{ fontFamily: FF, fontSize: 13, color: GOLD, textTransform: 'uppercase', letterSpacing: '0.18em', fontWeight: 700, marginBottom: 24 }}>{member.role}</div>
|
||||
<div style={{ width: 48, height: 2, background: GOLD, opacity: 0.5, marginBottom: 24 }} />
|
||||
<div style={{ fontFamily: FB, fontSize: isMobileCard ? 18 : 21, color: 'rgba(255,255,255,0.6)', lineHeight: 1.7, maxWidth: 620 }}>{member.bio}</div>
|
||||
{bio ? (
|
||||
<div style={{ fontFamily: FB, fontSize: isMobileCard ? 18 : 21, color: 'rgba(255,255,255,0.6)', lineHeight: 1.7, maxWidth: 620 }}>{bio}</div>
|
||||
) : null}
|
||||
{quote ? (
|
||||
<div style={{ borderLeft: `3px solid ${GOLD}`, color: 'rgba(255,255,255,0.78)', fontFamily: FB, fontSize: isMobileCard ? 17 : 19, fontStyle: 'italic', lineHeight: 1.65, marginTop: 24, maxWidth: 620, paddingLeft: 18 }}>
|
||||
{quote}
|
||||
@@ -301,7 +314,7 @@ function PartnerModal({ partner, copy, onClose }: { partner: Partner; copy: Part
|
||||
}}
|
||||
>
|
||||
{/* ── Hero ── */}
|
||||
<div style={{ position: 'relative', height: 220, flexShrink: 0, overflow: 'hidden' }}>
|
||||
<div style={{ position: 'relative', height: 220, flexShrink: 0, overflow: 'visible' }}>
|
||||
{/* Background */}
|
||||
{partner.heroImg
|
||||
? <Image unoptimized src={partner.heroImg} alt="" style={{ position: 'absolute', inset: 0, width: '100%', height: '100%', objectFit: 'cover' }} />
|
||||
@@ -481,10 +494,10 @@ const Netzwerk: React.FC = () => {
|
||||
const partnerModal = { ...netzwerkContent.partners.modal, ...((cms.partners as typeof netzwerkContent.partners | undefined)?.modal || {}) };
|
||||
const sponsoring = { ...netzwerkContent.sponsoring, ...(cms.sponsoring || {}) };
|
||||
const patronPeople = fallbackArray<PatronPerson>(patronage.people, netzwerkContent.patronage.people);
|
||||
const juryCollectionMembers = sortedJuryMembers(cmsJuryMembers.map(normalizeJuryMember).filter((member) => member.active !== false && member.name));
|
||||
const juryMembers = juryCollectionMembers.length
|
||||
const juryCollectionMembers = cmsJuryMembers.map(normalizeJuryMember).filter((member) => member.active !== false && member.name);
|
||||
const juryMembers = sortedJuryMembers(juryCollectionMembers.length
|
||||
? juryCollectionMembers
|
||||
: fallbackArray<JuryMember>(jury.members, netzwerkContent.jury.members);
|
||||
: fallbackArray<JuryMember>(jury.members, netzwerkContent.jury.members));
|
||||
const expertiseRows = fallbackArray<ExpertiseRow>(expertise.rows, netzwerkContent.expertise.rows);
|
||||
const partners = sortedPartners(cmsPartners)
|
||||
.filter((partner) => partner.active !== false)
|
||||
@@ -651,8 +664,8 @@ const Netzwerk: React.FC = () => {
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
{/* Left – Cream, stats */}
|
||||
<div style={{ background: CREAM, padding: '72px 64px', display: 'flex', flexDirection: 'column', justifyContent: 'center', position: 'relative', zIndex: 1 }}>
|
||||
{/* Left – White, stats */}
|
||||
<div style={{ background: '#fff', padding: '72px 64px', display: 'flex', flexDirection: 'column', justifyContent: 'center', position: 'relative', zIndex: 1 }}>
|
||||
<div>
|
||||
<div style={{ fontFamily: FF, fontSize: 'clamp(5rem, 8.5vw, 7.5rem)', fontWeight: 900, color: '#101828', letterSpacing: '-0.04em', lineHeight: 1 }}>{fallbackText(desktopStats[0]?.value, netzwerkContent.juryIntro.desktopStats[0].value)}</div>
|
||||
<div style={{ fontFamily: FF, fontSize: 11, textTransform: 'uppercase', letterSpacing: '0.15em', color: 'rgba(16,24,40,0.4)', marginTop: 4 }}>{fallbackText(desktopStats[0]?.label, netzwerkContent.juryIntro.desktopStats[0].label)}</div>
|
||||
@@ -742,7 +755,7 @@ const Netzwerk: React.FC = () => {
|
||||
|
||||
|
||||
{/* ── 6. PARTNER & SPONSOREN ───────────────────────────────────────────── */}
|
||||
<section id="partner" style={{ background: CREAM, overflow: 'hidden', position: 'relative', isolation: 'isolate' }}>
|
||||
<section id="partner" style={{ background: '#fff', overflow: 'hidden', position: 'relative', isolation: 'isolate' }}>
|
||||
<MunichSkylineBg />
|
||||
<div style={{ padding: isMobile ? '48px 24px 32px' : '80px 80px 56px', display: 'flex', flexDirection: isMobile ? 'column' : 'row', gap: isMobile ? 12 : 0, justifyContent: 'space-between', alignItems: isMobile ? 'flex-start' : 'flex-end', borderBottom: '1px solid rgba(3,9,58,0.1)' }}>
|
||||
<div>
|
||||
|
||||
@@ -6,10 +6,10 @@ import { useCmsCollection, useCmsRoute, type CmsRouteDoc } from '@/spa/cmsRoute'
|
||||
import { docImageUrl, mediaAlt, mediaUrl } from '@/spa/cmsMediaField';
|
||||
import Image from '@/spa/components/ui/UnoptimizedImage'
|
||||
import { pressIndexContent } from '@/spa/pressIndexContent';
|
||||
import { Link } from '@/spa/router';
|
||||
|
||||
const NAVY = '#111D55';
|
||||
const GOLD = '#EFBF04';
|
||||
const CREAM = '#E4E2E3';
|
||||
const FF = '"IBM Plex Sans", sans-serif';
|
||||
const FB = '"Inter", sans-serif';
|
||||
|
||||
@@ -44,6 +44,14 @@ const normalizeStatus = (status: unknown, labels: Record<string, string>, defaul
|
||||
return labels[value] || value
|
||||
}
|
||||
|
||||
const detailPath = (spaPath: unknown, slug: unknown, prefix: '/presse/events' | '/presse/blog') => {
|
||||
const path = fallbackText(spaPath, '')
|
||||
if (path.startsWith(`${prefix}/`)) return path
|
||||
|
||||
const pathSlug = fallbackText(slug, '').replace(/^\/+/, '')
|
||||
return pathSlug ? `${prefix}/${pathSlug}` : prefix
|
||||
}
|
||||
|
||||
const eventFromCms = (
|
||||
event: CmsRouteDoc,
|
||||
fallbackImage: string,
|
||||
@@ -57,7 +65,7 @@ const eventFromCms = (
|
||||
status: normalizeStatus(event.status, labels, fallbackText(eventsSection.defaultStatusLabel, pressIndexContent.events.defaultStatusLabel)),
|
||||
img: docImageUrl(event, fallbackImage),
|
||||
desc: String(event.description || event.meta?.description || ''),
|
||||
slug: String(event.spaPath || `/presse/events/${event.slug}`),
|
||||
slug: detailPath(event.spaPath, event.slug, '/presse/events'),
|
||||
})
|
||||
|
||||
const postFromCms = (post: CmsRouteDoc, fallbackImage: string, newsSection: NonNullable<PressIndexCms['news']>): PressNewsItem => ({
|
||||
@@ -65,7 +73,7 @@ const postFromCms = (post: CmsRouteDoc, fallbackImage: string, newsSection: NonN
|
||||
excerpt: String(post.excerpt || post.meta?.description || ''),
|
||||
cat: String(post.cat || fallbackText(newsSection.missingCategoryLabel, pressIndexContent.news.missingCategoryLabel)),
|
||||
img: docImageUrl(post, fallbackImage, 'heroImage'),
|
||||
slug: String(post.spaPath || `/presse/blog/${post.slug}`),
|
||||
slug: detailPath(post.spaPath, post.slug, '/presse/blog'),
|
||||
})
|
||||
|
||||
function Lines({ text }: { text: string }) {
|
||||
@@ -77,16 +85,20 @@ const fallbackCardImage = (item: PressEventItem | PressNewsItem) => mediaUrl(ite
|
||||
function NewsCard({ item, idx, isMobile, total }: { item: PressNewsItem; idx: number; isMobile: boolean; total: number }) {
|
||||
const [hovered, setHovered] = React.useState(false);
|
||||
return (
|
||||
<article
|
||||
<Link
|
||||
to={item.slug}
|
||||
aria-label={item.title}
|
||||
onMouseEnter={() => setHovered(true)}
|
||||
onMouseLeave={() => setHovered(false)}
|
||||
style={{
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
color: 'inherit',
|
||||
textDecoration: 'none',
|
||||
borderLeft: isMobile ? 'none' : (idx > 0 ? '1px solid rgba(255,255,255,0.07)' : 'none'),
|
||||
borderBottom: isMobile && idx < total - 1 ? '1px solid rgba(255,255,255,0.07)' : 'none',
|
||||
}}
|
||||
>
|
||||
<article style={{ display: 'flex', flexDirection: 'column', flex: 1 }}>
|
||||
{/* image */}
|
||||
<div style={{ position: 'relative', overflow: 'hidden', height: 220 }}>
|
||||
<Image unoptimized
|
||||
@@ -163,7 +175,8 @@ function NewsCard({ item, idx, isMobile, total }: { item: PressNewsItem; idx: nu
|
||||
{item.excerpt}
|
||||
</p>
|
||||
</div>
|
||||
</article>
|
||||
</article>
|
||||
</Link>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -284,7 +297,7 @@ const Press: React.FC = () => {
|
||||
</section>
|
||||
|
||||
{/* ── 2. EVENTS ───────────────────────────────────────────────────── */}
|
||||
<section id="events" style={{ background: CREAM, overflow: 'hidden', position: 'relative', isolation: 'isolate' }}>
|
||||
<section id="events" style={{ background: '#fff', overflow: 'hidden', position: 'relative', isolation: 'isolate' }}>
|
||||
<MunichSkylineBg />
|
||||
{/* Section header */}
|
||||
<div
|
||||
@@ -342,13 +355,17 @@ const Press: React.FC = () => {
|
||||
|
||||
{/* Event rows */}
|
||||
{combinedEvents.map((event, idx) => (
|
||||
<div
|
||||
key={idx}
|
||||
<Link
|
||||
key={event.slug || idx}
|
||||
to={event.slug}
|
||||
aria-label={event.title}
|
||||
style={{
|
||||
display: 'grid',
|
||||
gridTemplateColumns: isMobile ? '1fr' : '320px 1fr',
|
||||
borderBottom: '1px solid rgba(3,9,58,0.08)',
|
||||
background: 'transparent',
|
||||
color: 'inherit',
|
||||
textDecoration: 'none',
|
||||
}}
|
||||
>
|
||||
{/* Left – image cell */}
|
||||
@@ -374,7 +391,7 @@ const Press: React.FC = () => {
|
||||
<div style={{ width: 24, height: 1, background: 'rgba(239,191,4,0.4)', marginBottom: 14 }} />
|
||||
<p style={{ fontFamily: FB, fontSize: 18, color: 'rgba(16,24,40,0.5)', lineHeight: 1.7, flex: 1 }}>{event.desc}</p>
|
||||
</div>
|
||||
</div>
|
||||
</Link>
|
||||
))}
|
||||
</section>
|
||||
|
||||
@@ -437,13 +454,13 @@ const Press: React.FC = () => {
|
||||
{/* 3-column news grid */}
|
||||
<div style={{ display: 'grid', gridTemplateColumns: isMobile ? '1fr' : 'repeat(3, 1fr)' }}>
|
||||
{combinedNews.map((item, idx) => (
|
||||
<NewsCard key={idx} item={item} idx={idx} isMobile={isMobile} total={combinedNews.length} />
|
||||
<NewsCard key={item.slug || idx} item={item} idx={idx} isMobile={isMobile} total={combinedNews.length} />
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* ── 4. PRESSE-MATERIAL & AKKREDITIERUNG ─────────────────────────── */}
|
||||
<section id="downloads" style={{ background: CREAM, overflow: 'hidden', position: 'relative', isolation: 'isolate' }}>
|
||||
<section id="downloads" style={{ background: '#fff', overflow: 'hidden', position: 'relative', isolation: 'isolate' }}>
|
||||
<MunichSkylineBg />
|
||||
<div style={{ display: 'grid', gridTemplateColumns: '1fr', minHeight: isMobile ? 'auto' : 600 }}>
|
||||
|
||||
|
||||
Reference in New Issue
Block a user