fix(andreknie.de): improve mobile dock and resource requests
This commit is contained in:
@@ -114,6 +114,23 @@
|
||||
}
|
||||
|
||||
@media (max-width: 600px) {
|
||||
.dock { gap: 6px; padding: 6px 8px; }
|
||||
.dock-item { width: 38px; height: 38px; }
|
||||
.dock-container {
|
||||
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: 'Artikel', path: '/artikel', icon: <Newspaper size={20} /> },
|
||||
{ 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: 'Beratung', path: '/consulting', icon: <Briefcase size={20} /> },
|
||||
{ label: 'Über mich', path: '/ueber-mich', icon: <User size={20} /> },
|
||||
{ label: 'Beratung', path: '/consulting', icon: <Briefcase size={20} />, secondary: true },
|
||||
{ label: 'Über mich', path: '/ueber-mich', icon: <User size={20} />, secondary: true },
|
||||
{ label: 'Kontakt', path: '/kontakt', icon: <Mail size={20} /> },
|
||||
]
|
||||
|
||||
return (
|
||||
<div className="dock-container">
|
||||
<nav className="dock">
|
||||
<nav className="dock" aria-label="Schnellnavigation">
|
||||
{items.map((item) => {
|
||||
const isActive = location.pathname === item.path
|
||||
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>
|
||||
<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}
|
||||
</Link>
|
||||
</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-lg: 24px;
|
||||
--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);
|
||||
line-height: 1.6;
|
||||
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 {
|
||||
|
||||
@@ -24,8 +24,8 @@ export default function Datenschutz() {
|
||||
<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><strong style={{ color: 'var(--text-primary)' }}>5. Ressourcen-Downloads</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><strong style={{ color: 'var(--text-primary)' }}>5. Ressourcenanfragen</strong></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>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,12 @@
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
@media (max-width: 600px) {
|
||||
.hero {
|
||||
padding-bottom: calc(60px + var(--dock-content-space));
|
||||
}
|
||||
}
|
||||
|
||||
.hero-content {
|
||||
position: relative;
|
||||
z-index: 10;
|
||||
|
||||
@@ -1,29 +1,110 @@
|
||||
import React from 'react'
|
||||
import React, { useState } from 'react'
|
||||
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() {
|
||||
const { items } = useContent('resources', { pageSize: 20 })
|
||||
const [selectedResource, setSelectedResource] = useState(null)
|
||||
|
||||
return (
|
||||
<main style={{ paddingTop: '120px', minHeight: '100vh' }}>
|
||||
<div className="app-container">
|
||||
<div className="section-header">
|
||||
<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 style={{ display: 'grid', gap: '20px', maxWidth: '700px', margin: '0 auto' }}>
|
||||
{items.map(res => (
|
||||
<div key={res.id || res.slug} className="glass-panel" style={{ padding: '24px', display: 'flex', justifyContent: 'space-between', alignItems: 'center', gap: '16px' }}>
|
||||
<div>
|
||||
<h3 style={{ marginBottom: '4px', fontSize: '1.1rem' }}>{res.title}</h3>
|
||||
<p style={{ color: 'var(--text-secondary)', margin: 0, fontSize: '0.9rem' }}>{res.description}</p>
|
||||
{items.map(resource => {
|
||||
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>
|
||||
<h2 style={{ marginBottom: '4px', fontSize: '1.1rem' }}>{resource.title}</h2>
|
||||
<p style={{ color: 'var(--text-secondary)', margin: 0, fontSize: '0.9rem' }}>{resource.description}</p>
|
||||
</div>
|
||||
<button type="button" className="primary-button" onClick={() => setSelectedResource(selected ? null : resourceId)} style={{ padding: '10px 20px', fontSize: '0.9rem', whiteSpace: 'nowrap' }}>
|
||||
{selected ? 'Schließen' : 'Material anfragen'}
|
||||
</button>
|
||||
</div>
|
||||
{selected && <ResourceRequestForm key={resourceId} resource={resource} onCancel={() => setSelectedResource(null)} />}
|
||||
</div>
|
||||
<button className="primary-button" style={{ padding: '10px 20px', fontSize: '0.9rem', whiteSpace: 'nowrap' }}>
|
||||
Download
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
)
|
||||
})}
|
||||
{items.length === 0 && <p style={{ textAlign: 'center', color: 'var(--text-secondary)' }}>Ressourcen werden vorbereitet.</p>}
|
||||
</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.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.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 }
|
||||
}
|
||||
|
||||
@@ -125,4 +125,10 @@ describe('validateResourceDownload', () => {
|
||||
expect(result.valid).toBe(false)
|
||||
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