Compare commits
3
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
0334252fed | ||
|
|
8236cce85c | ||
|
|
0459daa074 |
@@ -1,6 +1,6 @@
|
|||||||
- quote: "André verbindet etwas, das ich in dieser Form nur selten erlebe: tiefes technisches Verständnis für KI mit einem echten Gespür für Menschen und Organisationen. Er kennt die Möglichkeiten moderner KI, versteht aber gleichzeitig die Besonderheiten von Hochschulen. Dadurch gelingt es ihm, komplexe Technologien verständlich zu machen und Organisationen zu befähigen, eigene tragfähige Lösungen zu entwickeln. Dabei geht er nicht den einfachsten Weg, sondern sucht gemeinsam nach der bestmöglichen Lösung. Besonders schätze ich die Zusammenarbeit im Tandem: Die Kombination aus unserer internen Perspektive und seiner externen Expertise hat unsere KI-Reise entscheidend vorangebracht."
|
- quote: "André verbindet etwas, das ich in dieser Form nur selten erlebe: tiefes technisches Verständnis für KI mit einem echten Gespür für Menschen und Organisationen. Er kennt die Möglichkeiten moderner KI, versteht aber gleichzeitig die Besonderheiten von Hochschulen. Dadurch gelingt es ihm, komplexe Technologien verständlich zu machen und Organisationen zu befähigen, eigene tragfähige Lösungen zu entwickeln. Dabei geht er nicht den einfachsten Weg, sondern sucht gemeinsam nach der bestmöglichen Lösung. Besonders schätze ich die Zusammenarbeit im Tandem: Die Kombination aus unserer internen Perspektive und seiner externen Expertise hat unsere KI-Reise entscheidend vorangebracht."
|
||||||
name: "Maike Mach"
|
name: "Maike Mach"
|
||||||
role: "Stellvertretende Leitung Organisationsentwicklung, Universität Kassel"
|
role: "Stellv. Leiterin Personal- und Organisationsentwicklung, Universität Kassel"
|
||||||
|
|
||||||
- quote: "André bringt uns voran, begeistert Menschen, zeigt auf was sinnvoll ist und was nicht. Durch ihn sind wir Größenordnungen schneller und sicherer. Sein Team und er befähigen uns, Dinge umzusetzen, von denen wir vorher nicht geträumt hätten."
|
- quote: "André bringt uns voran, begeistert Menschen, zeigt auf was sinnvoll ist und was nicht. Durch ihn sind wir Größenordnungen schneller und sicherer. Sein Team und er befähigen uns, Dinge umzusetzen, von denen wir vorher nicht geträumt hätten."
|
||||||
name: "Kunde - Projektleitung Digitalisierung"
|
name: "Kunde - Projektleitung Digitalisierung"
|
||||||
|
|||||||
@@ -96,4 +96,37 @@ describe('backend mail safety', () => {
|
|||||||
expect(await reserveToken(token)).toMatchObject({ status: 'processing' })
|
expect(await reserveToken(token)).toMatchObject({ status: 'processing' })
|
||||||
await releaseToken(token)
|
await releaseToken(token)
|
||||||
})
|
})
|
||||||
|
|
||||||
|
it('rejects unknown resources before any notification is sent', async () => {
|
||||||
|
const response = await fetch(`${baseUrl}/api/resource-download`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'content-type': 'application/json' },
|
||||||
|
body: JSON.stringify({
|
||||||
|
name: 'Test',
|
||||||
|
email: 'test@example.invalid',
|
||||||
|
company: 'Synthetic Test',
|
||||||
|
resource_id: 'does-not-exist',
|
||||||
|
}),
|
||||||
|
})
|
||||||
|
|
||||||
|
expect(response.status).toBe(400)
|
||||||
|
expect((await response.json()).errors.resource_id).toBeTruthy()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('silently accepts resource honeypot submissions', async () => {
|
||||||
|
const response = await fetch(`${baseUrl}/api/resource-download`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'content-type': 'application/json' },
|
||||||
|
body: JSON.stringify({
|
||||||
|
name: 'Test',
|
||||||
|
email: 'test@example.invalid',
|
||||||
|
company: 'Synthetic Test',
|
||||||
|
resource_id: 'does-not-exist',
|
||||||
|
company_website: 'https://bot.example.invalid',
|
||||||
|
}),
|
||||||
|
})
|
||||||
|
|
||||||
|
expect(response.status).toBe(201)
|
||||||
|
expect((await response.json()).ok).toBe(true)
|
||||||
|
})
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -1,27 +1,19 @@
|
|||||||
import { Router } from 'express'
|
import { Router } from 'express'
|
||||||
import { readFileSync, writeFileSync, existsSync } from 'fs'
|
import { existsSync, readdirSync } from 'fs'
|
||||||
import { join, dirname } from 'path'
|
import { join, dirname } from 'path'
|
||||||
import { fileURLToPath } from 'url'
|
import { fileURLToPath } from 'url'
|
||||||
import { Mutex } from 'async-mutex'
|
|
||||||
import { resourceLimiter } from '../middleware/rateLimiter.js'
|
import { resourceLimiter } from '../middleware/rateLimiter.js'
|
||||||
import { antiSpam } from '../middleware/antiSpam.js'
|
import { antiSpam } from '../middleware/antiSpam.js'
|
||||||
|
import { sendStakeholderNotification, MailerNotReadyError } from '../services/mailer.js'
|
||||||
|
|
||||||
const __dirname = dirname(fileURLToPath(import.meta.url))
|
const __dirname = dirname(fileURLToPath(import.meta.url))
|
||||||
const LEADS_FILE = join(__dirname, '..', 'data', 'leads.json')
|
const RESOURCES_DIR = join(__dirname, '..', '..', 'public', 'resources')
|
||||||
const leadsMutex = new Mutex()
|
|
||||||
|
|
||||||
// Only alphanumeric + hyphen resource IDs are allowed. This prevents path
|
export function getAvailableResourceIds() {
|
||||||
// traversal (e.g. "../../etc/passwd") when building the download URL.
|
if (!existsSync(RESOURCES_DIR)) return []
|
||||||
const RESOURCE_ID_PATTERN = /^[a-z0-9]+(-[a-z0-9]+)*$/
|
return readdirSync(RESOURCES_DIR, { withFileTypes: true })
|
||||||
|
.filter(entry => entry.isFile() && entry.name.endsWith('.pdf') && entry.name !== 'speaker-cv-andre-knie.pdf')
|
||||||
function loadLeads() {
|
.map(entry => entry.name.slice(0, -4))
|
||||||
if (!existsSync(LEADS_FILE)) return []
|
|
||||||
try { return JSON.parse(readFileSync(LEADS_FILE, 'utf-8')) }
|
|
||||||
catch { return [] }
|
|
||||||
}
|
|
||||||
|
|
||||||
function saveLeads(leads) {
|
|
||||||
writeFileSync(LEADS_FILE, JSON.stringify(leads, null, 2), 'utf-8')
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const router = Router()
|
const router = Router()
|
||||||
@@ -34,26 +26,18 @@ router.post('/', resourceLimiter, antiSpam, async (req, res) => {
|
|||||||
if (!email || !/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email)) errors.email = 'Gültige E-Mail erforderlich.'
|
if (!email || !/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email)) errors.email = 'Gültige E-Mail erforderlich.'
|
||||||
if (!company || company.trim().length === 0) errors.company = 'Unternehmen ist erforderlich.'
|
if (!company || company.trim().length === 0) errors.company = 'Unternehmen ist erforderlich.'
|
||||||
if (company && company.length > 150) errors.company = 'Max. 150 Zeichen.'
|
if (company && company.length > 150) errors.company = 'Max. 150 Zeichen.'
|
||||||
if (!resource_id || !RESOURCE_ID_PATTERN.test(resource_id)) errors.resource_id = 'Ungültige Resource-ID.'
|
if (!resource_id || !getAvailableResourceIds().includes(resource_id)) {
|
||||||
|
errors.resource_id = 'Diese Ressource ist derzeit nicht verfügbar.'
|
||||||
if (Object.keys(errors).length > 0) {
|
|
||||||
return res.status(400).json({ errors })
|
|
||||||
}
|
}
|
||||||
|
|
||||||
await leadsMutex.runExclusive(async () => {
|
if (Object.keys(errors).length > 0) return res.status(400).json({ errors })
|
||||||
const leads = loadLeads()
|
|
||||||
leads.push({
|
|
||||||
name,
|
|
||||||
email,
|
|
||||||
company,
|
|
||||||
resource_id,
|
|
||||||
timestamp: new Date().toISOString(),
|
|
||||||
})
|
|
||||||
saveLeads(leads)
|
|
||||||
})
|
|
||||||
|
|
||||||
// Return download URL (resource files are in public/resources/)
|
try {
|
||||||
res.json({ ok: true, download_url: `/resources/${resource_id}.pdf` })
|
await sendStakeholderNotification('resource-download', { name, email, company, resource_id })
|
||||||
|
res.status(202).json({ ok: true, message: 'Danke! Wir prüfen die Anfrage und melden uns bei dir.' })
|
||||||
|
} catch (error) {
|
||||||
|
res.status(error instanceof MailerNotReadyError ? 503 : 502).json({ error: 'Die Anfrage konnte derzeit nicht übermittelt werden.' })
|
||||||
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
export default router
|
export default router
|
||||||
|
|||||||
@@ -59,20 +59,30 @@ export async function sendStakeholderNotification(type, data) {
|
|||||||
const subjects = {
|
const subjects = {
|
||||||
contact: `Neue Kontaktanfrage von ${data.name}`,
|
contact: `Neue Kontaktanfrage von ${data.name}`,
|
||||||
'talk-request': `Neue Vortragsanfrage von ${data.name}`,
|
'talk-request': `Neue Vortragsanfrage von ${data.name}`,
|
||||||
|
'resource-download': `Ressourcenanfrage von ${data.name}`,
|
||||||
}
|
}
|
||||||
|
|
||||||
const body = Object.entries(data)
|
const body = buildStakeholderMessage(type, data)
|
||||||
.map(([key, val]) => `${key}: ${val}`)
|
|
||||||
.join('\n')
|
|
||||||
|
|
||||||
await t.sendMail({
|
await t.sendMail({
|
||||||
from: SMTP_USER,
|
from: SMTP_USER,
|
||||||
to: STAKEHOLDER_EMAIL,
|
to: type === 'resource-download' ? 'kontakt@d-hive.de' : STAKEHOLDER_EMAIL,
|
||||||
subject: subjects[type] || `Neue Anfrage (${type})`,
|
subject: subjects[type] || `Neue Anfrage (${type})`,
|
||||||
text: body,
|
text: body,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function buildStakeholderMessage(type, data) {
|
||||||
|
const fields = Object.entries(data).map(([key, val]) => `${key}: ${val}`)
|
||||||
|
if (type !== 'resource-download') return fields.join('\n')
|
||||||
|
|
||||||
|
return [
|
||||||
|
'Bitte die angefragten Inhalte prüfen und bei positiver Prüfung ein aktuelles Profil von André verschicken.',
|
||||||
|
'',
|
||||||
|
...fields,
|
||||||
|
].join('\n')
|
||||||
|
}
|
||||||
|
|
||||||
export { STAKEHOLDER_EMAIL, BASE_URL }
|
export { STAKEHOLDER_EMAIL, BASE_URL }
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -1,5 +1,10 @@
|
|||||||
import { describe, expect, it } from 'vitest'
|
import { describe, expect, it } from 'vitest'
|
||||||
import { isMailerReady, MailerNotReadyError, sendConfirmationEmail } from './mailer.js'
|
import {
|
||||||
|
buildStakeholderMessage,
|
||||||
|
isMailerReady,
|
||||||
|
MailerNotReadyError,
|
||||||
|
sendConfirmationEmail,
|
||||||
|
} from './mailer.js'
|
||||||
|
|
||||||
describe('mailer readiness', () => {
|
describe('mailer readiness', () => {
|
||||||
it('reports missing SMTP configuration without exposing credentials', async () => {
|
it('reports missing SMTP configuration without exposing credentials', async () => {
|
||||||
@@ -10,3 +15,24 @@ describe('mailer readiness', () => {
|
|||||||
.rejects.toMatchObject({ code: 'MAILER_NOT_READY' })
|
.rejects.toMatchObject({ code: 'MAILER_NOT_READY' })
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
|
describe('stakeholder notification content', () => {
|
||||||
|
const data = {
|
||||||
|
name: 'Synthetic Test',
|
||||||
|
email: 'test@example.invalid',
|
||||||
|
}
|
||||||
|
|
||||||
|
it('adds the manual profile instruction to resource requests', () => {
|
||||||
|
const message = buildStakeholderMessage('resource-download', data)
|
||||||
|
|
||||||
|
expect(message).toContain('ein aktuelles Profil von André verschicken')
|
||||||
|
expect(message).toContain('name: Synthetic Test')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('does not add the resource instruction to contact notifications', () => {
|
||||||
|
const message = buildStakeholderMessage('contact', data)
|
||||||
|
|
||||||
|
expect(message).not.toContain('ein aktuelles Profil von André verschicken')
|
||||||
|
expect(message).toBe('name: Synthetic Test\nemail: test@example.invalid')
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|||||||
@@ -114,6 +114,23 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
@media (max-width: 600px) {
|
@media (max-width: 600px) {
|
||||||
.dock { gap: 6px; padding: 6px 8px; }
|
.dock-container {
|
||||||
.dock-item { width: 38px; height: 38px; }
|
bottom: max(12px, env(safe-area-inset-bottom));
|
||||||
|
max-width: calc(100vw - 24px);
|
||||||
|
}
|
||||||
|
|
||||||
|
.dock {
|
||||||
|
gap: 6px;
|
||||||
|
padding: 6px 8px;
|
||||||
|
max-width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dock-item-wrapper-secondary {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dock-item {
|
||||||
|
width: 44px;
|
||||||
|
height: 44px;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -10,22 +10,27 @@ export default function BottomDock() {
|
|||||||
{ label: 'Home', path: '/', icon: <Home size={20} /> },
|
{ label: 'Home', path: '/', icon: <Home size={20} /> },
|
||||||
{ label: 'Artikel', path: '/artikel', icon: <Newspaper size={20} /> },
|
{ label: 'Artikel', path: '/artikel', icon: <Newspaper size={20} /> },
|
||||||
{ label: 'Kniepunkt', path: '/kniepunkt', icon: <span style={{ fontWeight: 800, fontSize: '1.2rem', fontFamily: 'serif' }}>K</span> },
|
{ label: 'Kniepunkt', path: '/kniepunkt', icon: <span style={{ fontWeight: 800, fontSize: '1.2rem', fontFamily: 'serif' }}>K</span> },
|
||||||
{ label: 'Podcast', path: '/podcast', icon: <Mic size={20} /> },
|
{ label: 'Podcast', path: '/podcast', icon: <Mic size={20} />, secondary: true },
|
||||||
{ label: 'Speaking', path: '/speaking', icon: <MessageSquare size={20} /> },
|
{ label: 'Speaking', path: '/speaking', icon: <MessageSquare size={20} /> },
|
||||||
{ label: 'Beratung', path: '/consulting', icon: <Briefcase size={20} /> },
|
{ label: 'Beratung', path: '/consulting', icon: <Briefcase size={20} />, secondary: true },
|
||||||
{ label: 'Über mich', path: '/ueber-mich', icon: <User size={20} /> },
|
{ label: 'Über mich', path: '/ueber-mich', icon: <User size={20} />, secondary: true },
|
||||||
{ label: 'Kontakt', path: '/kontakt', icon: <Mail size={20} /> },
|
{ label: 'Kontakt', path: '/kontakt', icon: <Mail size={20} /> },
|
||||||
]
|
]
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="dock-container">
|
<div className="dock-container">
|
||||||
<nav className="dock">
|
<nav className="dock" aria-label="Schnellnavigation">
|
||||||
{items.map((item) => {
|
{items.map((item) => {
|
||||||
const isActive = location.pathname === item.path
|
const isActive = location.pathname === item.path
|
||||||
return (
|
return (
|
||||||
<div className="dock-item-wrapper" key={item.path}>
|
<div className={`dock-item-wrapper${item.secondary ? ' dock-item-wrapper-secondary' : ''}`} key={item.path}>
|
||||||
<span className="dock-tooltip">{item.label}</span>
|
<span className="dock-tooltip">{item.label}</span>
|
||||||
<Link to={item.path} className={`dock-item ${isActive ? 'active' : ''}`}>
|
<Link
|
||||||
|
to={item.path}
|
||||||
|
className={`dock-item ${isActive ? 'active' : ''}`}
|
||||||
|
aria-label={item.label}
|
||||||
|
aria-current={isActive ? 'page' : undefined}
|
||||||
|
>
|
||||||
{item.icon}
|
{item.icon}
|
||||||
</Link>
|
</Link>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -0,0 +1,30 @@
|
|||||||
|
import React from 'react'
|
||||||
|
import { describe, expect, it } from 'vitest'
|
||||||
|
import { render } from '@testing-library/react'
|
||||||
|
import { MemoryRouter } from 'react-router-dom'
|
||||||
|
import { readFileSync } from 'fs'
|
||||||
|
import { join } from 'path'
|
||||||
|
import BottomDock from './BottomDock'
|
||||||
|
|
||||||
|
describe('BottomDock', () => {
|
||||||
|
it('keeps secondary links available on desktop and marks them for mobile hiding', () => {
|
||||||
|
const { container } = render(
|
||||||
|
<MemoryRouter initialEntries={['/']}>
|
||||||
|
<BottomDock />
|
||||||
|
</MemoryRouter>,
|
||||||
|
)
|
||||||
|
|
||||||
|
expect(container.querySelectorAll('.dock-item')).toHaveLength(8)
|
||||||
|
expect(container.querySelectorAll('.dock-item-wrapper-secondary')).toHaveLength(3)
|
||||||
|
expect(container.querySelectorAll('.dock-item[aria-label]')).toHaveLength(8)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('reserves mobile space and keeps touch targets at least 44 pixels', () => {
|
||||||
|
const css = readFileSync(join(process.cwd(), 'src', 'components', 'BottomDock.css'), 'utf-8')
|
||||||
|
const mobileRule = css.match(/@media \(max-width: 600px\) \{([\s\S]*)\}\s*$/)
|
||||||
|
|
||||||
|
expect(mobileRule?.[1]).toContain('dock-item-wrapper-secondary')
|
||||||
|
expect(mobileRule?.[1]).toMatch(/width: 44px/)
|
||||||
|
expect(mobileRule?.[1]).toMatch(/height: 44px/)
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -42,6 +42,9 @@
|
|||||||
--radius-md: 16px;
|
--radius-md: 16px;
|
||||||
--radius-lg: 24px;
|
--radius-lg: 24px;
|
||||||
--radius-pill: 50px;
|
--radius-pill: 50px;
|
||||||
|
|
||||||
|
/* Space reserved for the fixed mobile navigation dock. */
|
||||||
|
--dock-content-space: 0px;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* ==========================================================================
|
/* ==========================================================================
|
||||||
@@ -65,6 +68,13 @@ body {
|
|||||||
font-weight: var(--font-weight-normal);
|
font-weight: var(--font-weight-normal);
|
||||||
line-height: 1.6;
|
line-height: 1.6;
|
||||||
overflow-x: hidden;
|
overflow-x: hidden;
|
||||||
|
padding-bottom: var(--dock-content-space);
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 600px) {
|
||||||
|
:root {
|
||||||
|
--dock-content-space: calc(88px + env(safe-area-inset-bottom));
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
h1, h2, h3, h4, h5, h6 {
|
h1, h2, h3, h4, h5, h6 {
|
||||||
|
|||||||
@@ -24,8 +24,8 @@ export default function Datenschutz() {
|
|||||||
<p><strong style={{ color: 'var(--text-primary)' }}>4. Newsletter (Double-Opt-In)</strong></p>
|
<p><strong style={{ color: 'var(--text-primary)' }}>4. Newsletter (Double-Opt-In)</strong></p>
|
||||||
<p>Für die Newsletter-Anmeldung wird Ihre E-Mail-Adresse bis zur Bestätigung temporär gespeichert. Nach erfolgreicher Bestätigung wird sie als aktives Abonnement gespeichert, bis Sie sich abmelden. Eine Abmeldung können Sie jederzeit per E-Mail an <a href="mailto:kontakt@d-hive.de">kontakt@d-hive.de</a> mit dem Hinweis „Newsletter abbestellen“ veranlassen.</p>
|
<p>Für die Newsletter-Anmeldung wird Ihre E-Mail-Adresse bis zur Bestätigung temporär gespeichert. Nach erfolgreicher Bestätigung wird sie als aktives Abonnement gespeichert, bis Sie sich abmelden. Eine Abmeldung können Sie jederzeit per E-Mail an <a href="mailto:kontakt@d-hive.de">kontakt@d-hive.de</a> mit dem Hinweis „Newsletter abbestellen“ veranlassen.</p>
|
||||||
|
|
||||||
<p><strong style={{ color: 'var(--text-primary)' }}>5. Ressourcen-Downloads</strong></p>
|
<p><strong style={{ color: 'var(--text-primary)' }}>5. Ressourcenanfragen</strong></p>
|
||||||
<p>Für einen Ressourcen-Download werden Name, E-Mail-Adresse, Unternehmen, angeforderte Ressource und Zeitpunkt gespeichert. Die Daten dienen der Bearbeitung der Download-Anfrage und der Beziehungspflege. Sie werden gelöscht, sobald der Zweck entfällt oder Sie die Löschung unter <a href="mailto:datenschutz@d-hive.de">datenschutz@d-hive.de</a> anfordern.</p>
|
<p>Für eine Ressourcenanfrage werden Name, E-Mail-Adresse, Unternehmen und die angeforderte Ressource ausschließlich an <a href="mailto:kontakt@d-hive.de">kontakt@d-hive.de</a> übermittelt. Es erfolgt kein automatischer Download und kein automatischer Versand. Die Anfrage dient der Prüfung, ob André ein aktuelles Profil verschickt. Eine lokale Lead-Datei wird dabei nicht angelegt.</p>
|
||||||
|
|
||||||
<p><strong style={{ color: 'var(--text-primary)' }}>6. Speaker-CV-Anfrage</strong></p>
|
<p><strong style={{ color: 'var(--text-primary)' }}>6. Speaker-CV-Anfrage</strong></p>
|
||||||
<p>Für die Speaker-CV-Anfrage werden Name, E-Mail-Adresse, Nachricht und Zeitpunkt gespeichert. Die Daten werden nach erfolgreichem Versand des CV zur Bearbeitung und Beziehungspflege gespeichert und gelöscht, sobald der Zweck entfällt oder Sie die Löschung unter <a href="mailto:datenschutz@d-hive.de">datenschutz@d-hive.de</a> anfordern.</p>
|
<p>Für die Speaker-CV-Anfrage werden Name, E-Mail-Adresse, Nachricht und Zeitpunkt gespeichert. Die Daten werden nach erfolgreichem Versand des CV zur Bearbeitung und Beziehungspflege gespeichert und gelöscht, sobald der Zweck entfällt oder Sie die Löschung unter <a href="mailto:datenschutz@d-hive.de">datenschutz@d-hive.de</a> anfordern.</p>
|
||||||
|
|||||||
@@ -12,6 +12,46 @@
|
|||||||
box-sizing: border-box;
|
box-sizing: border-box;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@media (max-width: 600px) {
|
||||||
|
.hero {
|
||||||
|
padding-top: 100px;
|
||||||
|
padding-bottom: calc(60px + var(--dock-content-space));
|
||||||
|
}
|
||||||
|
|
||||||
|
.hero-photo {
|
||||||
|
width: 140px;
|
||||||
|
height: 140px;
|
||||||
|
margin-bottom: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.hero h1 {
|
||||||
|
font-size: 2.25rem;
|
||||||
|
margin-bottom: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.hero-roles {
|
||||||
|
font-size: 1rem;
|
||||||
|
margin-bottom: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.hero-subtext {
|
||||||
|
font-size: 1rem;
|
||||||
|
line-height: 1.45;
|
||||||
|
margin-bottom: 24px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.hero-actions {
|
||||||
|
flex-wrap: nowrap;
|
||||||
|
gap: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.hero-actions .primary-button,
|
||||||
|
.hero-actions .secondary-button {
|
||||||
|
padding: 12px 18px;
|
||||||
|
font-size: 0.95rem;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
.hero-content {
|
.hero-content {
|
||||||
position: relative;
|
position: relative;
|
||||||
z-index: 10;
|
z-index: 10;
|
||||||
|
|||||||
@@ -1,29 +1,110 @@
|
|||||||
import React from 'react'
|
import React, { useState } from 'react'
|
||||||
import { useContent } from '../hooks/useContent'
|
import { useContent } from '../hooks/useContent'
|
||||||
|
import { useFormSubmit } from '../hooks/useFormSubmit'
|
||||||
|
import { validateResourceDownload } from '../utils/validation'
|
||||||
|
|
||||||
|
const inputStyle = {
|
||||||
|
width: '100%',
|
||||||
|
padding: '12px',
|
||||||
|
background: 'var(--bg-elevated)',
|
||||||
|
border: '1px solid var(--glass-border)',
|
||||||
|
borderRadius: 'var(--radius-sm)',
|
||||||
|
color: 'var(--text-primary)',
|
||||||
|
fontSize: '1rem',
|
||||||
|
}
|
||||||
|
|
||||||
|
function ResourceRequestForm({ resource, onCancel }) {
|
||||||
|
const resourceId = resource.id || resource.slug
|
||||||
|
const [form, setForm] = useState({ name: '', email: '', company: '', resource_id: resourceId, company_website: '' })
|
||||||
|
const { submit, loading, success, errors, serverError } = useFormSubmit('/api/resource-download', validateResourceDownload)
|
||||||
|
|
||||||
|
function updateField(event) {
|
||||||
|
setForm(prev => ({ ...prev, [event.target.name]: event.target.value }))
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleSubmit(event) {
|
||||||
|
event.preventDefault()
|
||||||
|
await submit(form)
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="glass-panel" style={{ padding: '24px', marginTop: '24px' }}>
|
||||||
|
{success ? (
|
||||||
|
<p style={{ margin: 0, color: 'var(--text-primary)' }}>
|
||||||
|
Danke! Wir prüfen die Anfrage und melden uns bei dir.
|
||||||
|
</p>
|
||||||
|
) : (
|
||||||
|
<form onSubmit={handleSubmit} noValidate>
|
||||||
|
<h2 style={{ fontSize: '1.25rem', marginBottom: '8px' }}>Material anfragen</h2>
|
||||||
|
<p style={{ color: 'var(--text-secondary)', marginTop: 0 }}>
|
||||||
|
Wir prüfen die Anfrage. Bei positiver Prüfung verschickt André ein aktuelles Profil persönlich.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<div aria-hidden="true" style={{ position: 'absolute', left: '-9999px', width: '1px', height: '1px', overflow: 'hidden' }}>
|
||||||
|
<label htmlFor="resource-company-website">Firmen-Website (bitte leer lassen)</label>
|
||||||
|
<input id="resource-company-website" name="company_website" tabIndex={-1} autoComplete="off" value={form.company_website} onChange={updateField} />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<input type="hidden" name="resource_id" value={resourceId} />
|
||||||
|
|
||||||
|
<label htmlFor="resource-name">Name</label>
|
||||||
|
<input id="resource-name" name="name" value={form.name} onChange={updateField} style={inputStyle} autoComplete="name" aria-invalid={Boolean(errors.name)} />
|
||||||
|
{errors.name && <p role="alert" style={{ color: '#ff4444', margin: '4px 0 12px' }}>{errors.name}</p>}
|
||||||
|
|
||||||
|
<label htmlFor="resource-email">E-Mail</label>
|
||||||
|
<input id="resource-email" name="email" type="email" value={form.email} onChange={updateField} style={inputStyle} autoComplete="email" aria-invalid={Boolean(errors.email)} />
|
||||||
|
{errors.email && <p role="alert" style={{ color: '#ff4444', margin: '4px 0 12px' }}>{errors.email}</p>}
|
||||||
|
|
||||||
|
<label htmlFor="resource-company">Unternehmen</label>
|
||||||
|
<input id="resource-company" name="company" value={form.company} onChange={updateField} style={inputStyle} autoComplete="organization" aria-invalid={Boolean(errors.company)} />
|
||||||
|
{errors.company && <p role="alert" style={{ color: '#ff4444', margin: '4px 0 12px' }}>{errors.company}</p>}
|
||||||
|
|
||||||
|
{serverError && <p role="alert" style={{ color: '#ff4444' }}>{serverError}</p>}
|
||||||
|
{errors.resource_id && <p role="alert" style={{ color: '#ff4444' }}>{errors.resource_id}</p>}
|
||||||
|
|
||||||
|
<div style={{ display: 'flex', gap: '12px', marginTop: '20px', flexWrap: 'wrap' }}>
|
||||||
|
<button type="submit" className="primary-button" disabled={loading}>
|
||||||
|
{loading ? 'Wird gesendet...' : 'Anfrage senden'}
|
||||||
|
</button>
|
||||||
|
<button type="button" className="secondary-button" onClick={onCancel}>Abbrechen</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
export default function Resources() {
|
export default function Resources() {
|
||||||
const { items } = useContent('resources', { pageSize: 20 })
|
const { items } = useContent('resources', { pageSize: 20 })
|
||||||
|
const [selectedResource, setSelectedResource] = useState(null)
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<main style={{ paddingTop: '120px', minHeight: '100vh' }}>
|
<main style={{ paddingTop: '120px', minHeight: '100vh' }}>
|
||||||
<div className="app-container">
|
<div className="app-container">
|
||||||
<div className="section-header">
|
<div className="section-header">
|
||||||
<h1 className="section-title"><span className="text-gradient">Ressourcen</span></h1>
|
<h1 className="section-title"><span className="text-gradient">Ressourcen</span></h1>
|
||||||
<p className="section-subtitle">Praktische Materialien zum Download.</p>
|
<p className="section-subtitle">Materialien auf Anfrage. Jede Anfrage wird zunächst geprüft.</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div style={{ display: 'grid', gap: '20px', maxWidth: '700px', margin: '0 auto' }}>
|
<div style={{ display: 'grid', gap: '20px', maxWidth: '700px', margin: '0 auto' }}>
|
||||||
{items.map(res => (
|
{items.map(resource => {
|
||||||
<div key={res.id || res.slug} className="glass-panel" style={{ padding: '24px', display: 'flex', justifyContent: 'space-between', alignItems: 'center', gap: '16px' }}>
|
const resourceId = resource.id || resource.slug
|
||||||
|
const selected = selectedResource === resourceId
|
||||||
|
return (
|
||||||
|
<div key={resourceId} className="glass-panel" style={{ padding: '24px' }}>
|
||||||
|
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', gap: '16px', flexWrap: 'wrap' }}>
|
||||||
<div>
|
<div>
|
||||||
<h3 style={{ marginBottom: '4px', fontSize: '1.1rem' }}>{res.title}</h3>
|
<h2 style={{ marginBottom: '4px', fontSize: '1.1rem' }}>{resource.title}</h2>
|
||||||
<p style={{ color: 'var(--text-secondary)', margin: 0, fontSize: '0.9rem' }}>{res.description}</p>
|
<p style={{ color: 'var(--text-secondary)', margin: 0, fontSize: '0.9rem' }}>{resource.description}</p>
|
||||||
</div>
|
</div>
|
||||||
<button className="primary-button" style={{ padding: '10px 20px', fontSize: '0.9rem', whiteSpace: 'nowrap' }}>
|
<button type="button" className="primary-button" onClick={() => setSelectedResource(selected ? null : resourceId)} style={{ padding: '10px 20px', fontSize: '0.9rem', whiteSpace: 'nowrap' }}>
|
||||||
Download
|
{selected ? 'Schließen' : 'Material anfragen'}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
))}
|
{selected && <ResourceRequestForm key={resourceId} resource={resource} onCancel={() => setSelectedResource(null)} />}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
})}
|
||||||
{items.length === 0 && <p style={{ textAlign: 'center', color: 'var(--text-secondary)' }}>Ressourcen werden vorbereitet.</p>}
|
{items.length === 0 && <p style={{ textAlign: 'center', color: 'var(--text-secondary)' }}>Ressourcen werden vorbereitet.</p>}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -41,6 +41,6 @@ export function validateResourceDownload(data) {
|
|||||||
if (!data.email || !validateEmail(data.email)) errors.email = 'Gültige E-Mail-Adresse erforderlich.'
|
if (!data.email || !validateEmail(data.email)) errors.email = 'Gültige E-Mail-Adresse erforderlich.'
|
||||||
if (!data.company || data.company.trim().length === 0) errors.company = 'Unternehmen ist erforderlich.'
|
if (!data.company || data.company.trim().length === 0) errors.company = 'Unternehmen ist erforderlich.'
|
||||||
if (data.company && data.company.length > 150) errors.company = 'Unternehmen darf maximal 150 Zeichen lang sein.'
|
if (data.company && data.company.length > 150) errors.company = 'Unternehmen darf maximal 150 Zeichen lang sein.'
|
||||||
if (!data.resource_id) errors.resource_id = 'Resource-ID fehlt.'
|
if (!data.resource_id || !/^[a-z0-9]+(?:-[a-z0-9]+)*$/.test(data.resource_id)) errors.resource_id = 'Resource-ID fehlt oder ist ungültig.'
|
||||||
return { valid: Object.keys(errors).length === 0, errors }
|
return { valid: Object.keys(errors).length === 0, errors }
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -125,4 +125,10 @@ describe('validateResourceDownload', () => {
|
|||||||
expect(result.valid).toBe(false)
|
expect(result.valid).toBe(false)
|
||||||
expect(result.errors.resource_id).toBeTruthy()
|
expect(result.errors.resource_id).toBeTruthy()
|
||||||
})
|
})
|
||||||
|
|
||||||
|
it('fails for a resource id that could escape the resource naming scheme', () => {
|
||||||
|
const result = validateResourceDownload({ ...valid, resource_id: '../profile' })
|
||||||
|
expect(result.valid).toBe(false)
|
||||||
|
expect(result.errors.resource_id).toBeTruthy()
|
||||||
|
})
|
||||||
})
|
})
|
||||||
|
|||||||
Reference in New Issue
Block a user