feat: add QuickcheckPage component with form and styling
- Implemented QuickcheckPage component with a form for user input. - Added validation for email and consent checkboxes. - Integrated form submission to a specified endpoint. - Created SCSS styles for the QuickcheckPage layout and components.
This commit is contained in:
parent
2bdcb5303e
commit
47b23001ec
|
|
@ -1,7 +1,7 @@
|
||||||
import express from 'express'
|
import express from 'express'
|
||||||
import rateLimit from 'express-rate-limit'
|
import rateLimit from 'express-rate-limit'
|
||||||
import { body, validationResult } from 'express-validator'
|
import { body, validationResult } from 'express-validator'
|
||||||
import { sendAuditRequestEmail } from '../services/emailService.js'
|
import { sendAuditRequestEmail, sendQuickcheckRequestEmail } from '../services/emailService.js'
|
||||||
|
|
||||||
const router = express.Router()
|
const router = express.Router()
|
||||||
|
|
||||||
|
|
@ -19,6 +19,7 @@ const auditLimiter = rateLimit({
|
||||||
// Server-side validation is the real gate. The client-side checks exist only
|
// Server-side validation is the real gate. The client-side checks exist only
|
||||||
// so people are not punished with a round trip for a typo.
|
// so people are not punished with a round trip for a typo.
|
||||||
const validateAuditRequest = [
|
const validateAuditRequest = [
|
||||||
|
body('formType').optional().equals('audit'),
|
||||||
body('name').trim().isLength({ min: 1, max: 100 }).escape(),
|
body('name').trim().isLength({ min: 1, max: 100 }).escape(),
|
||||||
body('company').trim().isLength({ min: 1, max: 150 }).escape(),
|
body('company').trim().isLength({ min: 1, max: 150 }).escape(),
|
||||||
body('email').trim().isEmail().normalizeEmail().isLength({ max: 254 }),
|
body('email').trim().isEmail().normalizeEmail().isLength({ max: 254 }),
|
||||||
|
|
@ -37,7 +38,26 @@ const validateAuditRequest = [
|
||||||
body('website').optional().isEmpty(), // Honeypot
|
body('website').optional().isEmpty(), // Honeypot
|
||||||
]
|
]
|
||||||
|
|
||||||
router.post('/', auditLimiter, validateAuditRequest, async (req, res) => {
|
const validateQuickcheckRequest = [
|
||||||
|
body('formType').equals('quickcheck'),
|
||||||
|
body('name').optional().trim().isLength({ max: 100 }).escape(),
|
||||||
|
body('email').trim().isEmail().normalizeEmail().isLength({ max: 254 }),
|
||||||
|
body('businessConfirmation')
|
||||||
|
.equals('true')
|
||||||
|
.withMessage('Business (B2B) confirmation required'),
|
||||||
|
body('gdprConsent').equals('true').withMessage('GDPR consent required'),
|
||||||
|
body('website').optional().isEmpty(), // Honeypot
|
||||||
|
]
|
||||||
|
|
||||||
|
const validateRequest = (req, _res, next) => {
|
||||||
|
const validators = req.body.formType === 'quickcheck'
|
||||||
|
? validateQuickcheckRequest
|
||||||
|
: validateAuditRequest
|
||||||
|
|
||||||
|
Promise.all(validators.map((validator) => validator.run(req))).then(() => next())
|
||||||
|
}
|
||||||
|
|
||||||
|
router.post('/', auditLimiter, validateRequest, async (req, res) => {
|
||||||
try {
|
try {
|
||||||
const errors = validationResult(req)
|
const errors = validationResult(req)
|
||||||
if (!errors.isEmpty()) {
|
if (!errors.isEmpty()) {
|
||||||
|
|
@ -49,17 +69,7 @@ router.post('/', auditLimiter, validateAuditRequest, async (req, res) => {
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
const {
|
const { formType = 'audit', name, email, businessConfirmation, gdprConsent, website } = req.body
|
||||||
name,
|
|
||||||
company,
|
|
||||||
email,
|
|
||||||
url,
|
|
||||||
role,
|
|
||||||
motivation,
|
|
||||||
businessConfirmation,
|
|
||||||
gdprConsent,
|
|
||||||
website,
|
|
||||||
} = req.body
|
|
||||||
|
|
||||||
if (website) {
|
if (website) {
|
||||||
console.warn('Honeypot triggered, discarding submission.')
|
console.warn('Honeypot triggered, discarding submission.')
|
||||||
|
|
@ -83,6 +93,17 @@ router.post('/', auditLimiter, validateAuditRequest, async (req, res) => {
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (formType === 'quickcheck') {
|
||||||
|
await sendQuickcheckRequestEmail({ name: name || '', email })
|
||||||
|
|
||||||
|
return res.json({
|
||||||
|
success: true,
|
||||||
|
message: 'Thank you, your quick check request arrived.',
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
const { company, url, role, motivation } = req.body
|
||||||
|
|
||||||
await sendAuditRequestEmail({ name, company, email, url, role, motivation })
|
await sendAuditRequestEmail({ name, company, email, url, role, motivation })
|
||||||
|
|
||||||
res.json({
|
res.json({
|
||||||
|
|
|
||||||
|
|
@ -115,3 +115,42 @@ export async function sendAuditRequestEmail({
|
||||||
// personal data (Art. 5 GDPR, data minimisation).
|
// personal data (Art. 5 GDPR, data minimisation).
|
||||||
console.log('Audit request forwarded.')
|
console.log('Audit request forwarded.')
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export async function sendQuickcheckRequestEmail({ name, email }) {
|
||||||
|
const transporter = getTransporter()
|
||||||
|
const to = env('AUDIT_RECIPIENT')
|
||||||
|
const from = env('SMTP_FROM', env('SMTP_USER'))
|
||||||
|
|
||||||
|
if (!to) {
|
||||||
|
throw new Error('AUDIT_RECIPIENT is not configured.')
|
||||||
|
}
|
||||||
|
|
||||||
|
const safeName = singleLine(name || 'Nicht angegeben')
|
||||||
|
const safeEmail = singleLine(email)
|
||||||
|
|
||||||
|
await transporter.sendMail({
|
||||||
|
from,
|
||||||
|
to,
|
||||||
|
replyTo: safeEmail,
|
||||||
|
subject: `Schnellcheck-Anfrage: ${safeEmail}`,
|
||||||
|
text: [
|
||||||
|
'Neue Schnellcheck-Anfrage',
|
||||||
|
'',
|
||||||
|
`Name: ${safeName}`,
|
||||||
|
`E-Mail: ${safeEmail}`,
|
||||||
|
'B2B: bestätigt (§ 14 BGB)',
|
||||||
|
'Datenschutz: bestätigt',
|
||||||
|
].join('\n'),
|
||||||
|
html: `
|
||||||
|
<h2>Neue Schnellcheck-Anfrage</h2>
|
||||||
|
<dl>
|
||||||
|
<dt><strong>Name</strong></dt><dd>${escapeHtml(safeName)}</dd>
|
||||||
|
<dt><strong>E-Mail</strong></dt><dd>${escapeHtml(safeEmail)}</dd>
|
||||||
|
<dt><strong>B2B</strong></dt><dd>bestätigt (§ 14 BGB)</dd>
|
||||||
|
<dt><strong>Datenschutz</strong></dt><dd>bestätigt</dd>
|
||||||
|
</dl>
|
||||||
|
`,
|
||||||
|
})
|
||||||
|
|
||||||
|
console.log('Quickcheck request forwarded.')
|
||||||
|
}
|
||||||
|
|
|
||||||
10
prerender.js
10
prerender.js
|
|
@ -17,12 +17,10 @@ const DIST = join(__dirname, 'dist')
|
||||||
const PORT = 4173
|
const PORT = 4173
|
||||||
|
|
||||||
const ROUTES = [
|
const ROUTES = [
|
||||||
'/de/',
|
'/',
|
||||||
'/de/technical',
|
'/technical',
|
||||||
'/de/audit',
|
'/audit',
|
||||||
'/en/',
|
'/schnellcheck',
|
||||||
'/en/technical',
|
|
||||||
'/en/audit',
|
|
||||||
'/imprint',
|
'/imprint',
|
||||||
'/privacy-policy',
|
'/privacy-policy',
|
||||||
]
|
]
|
||||||
|
|
|
||||||
|
|
@ -1,63 +1,20 @@
|
||||||
<?xml version="1.0" encoding="UTF-8"?>
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9"
|
<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">
|
||||||
xmlns:xhtml="http://www.w3.org/1999/xhtml">
|
|
||||||
<!-- German landing -->
|
|
||||||
<url>
|
<url>
|
||||||
<loc>https://sascha-bach.de/de/</loc>
|
<loc>https://sascha-bach.de/</loc>
|
||||||
<xhtml:link rel="alternate" hreflang="de" href="https://sascha-bach.de/de/" />
|
<lastmod>2026-08-23</lastmod>
|
||||||
<xhtml:link rel="alternate" hreflang="en" href="https://sascha-bach.de/en/" />
|
|
||||||
<xhtml:link rel="alternate" hreflang="x-default" href="https://sascha-bach.de/de/" />
|
|
||||||
<lastmod>2025-06-28</lastmod>
|
|
||||||
<changefreq>monthly</changefreq>
|
<changefreq>monthly</changefreq>
|
||||||
<priority>1.0</priority>
|
<priority>1.0</priority>
|
||||||
</url>
|
</url>
|
||||||
<!-- English landing -->
|
|
||||||
<url>
|
<url>
|
||||||
<loc>https://sascha-bach.de/en/</loc>
|
<loc>https://sascha-bach.de/technical</loc>
|
||||||
<xhtml:link rel="alternate" hreflang="de" href="https://sascha-bach.de/de/" />
|
<lastmod>2026-08-23</lastmod>
|
||||||
<xhtml:link rel="alternate" hreflang="en" href="https://sascha-bach.de/en/" />
|
|
||||||
<xhtml:link rel="alternate" hreflang="x-default" href="https://sascha-bach.de/de/" />
|
|
||||||
<lastmod>2025-06-28</lastmod>
|
|
||||||
<changefreq>monthly</changefreq>
|
<changefreq>monthly</changefreq>
|
||||||
<priority>0.9</priority>
|
<priority>0.9</priority>
|
||||||
</url>
|
</url>
|
||||||
<!-- German technical portfolio -->
|
|
||||||
<url>
|
<url>
|
||||||
<loc>https://sascha-bach.de/de/technical</loc>
|
<loc>https://sascha-bach.de/audit</loc>
|
||||||
<xhtml:link rel="alternate" hreflang="de" href="https://sascha-bach.de/de/technical" />
|
<lastmod>2026-08-23</lastmod>
|
||||||
<xhtml:link rel="alternate" hreflang="en" href="https://sascha-bach.de/en/technical" />
|
|
||||||
<xhtml:link rel="alternate" hreflang="x-default" href="https://sascha-bach.de/de/technical" />
|
|
||||||
<lastmod>2025-06-28</lastmod>
|
|
||||||
<changefreq>monthly</changefreq>
|
|
||||||
<priority>0.8</priority>
|
|
||||||
</url>
|
|
||||||
<!-- English technical portfolio -->
|
|
||||||
<url>
|
|
||||||
<loc>https://sascha-bach.de/en/technical</loc>
|
|
||||||
<xhtml:link rel="alternate" hreflang="de" href="https://sascha-bach.de/de/technical" />
|
|
||||||
<xhtml:link rel="alternate" hreflang="en" href="https://sascha-bach.de/en/technical" />
|
|
||||||
<xhtml:link rel="alternate" hreflang="x-default" href="https://sascha-bach.de/de/technical" />
|
|
||||||
<lastmod>2025-06-28</lastmod>
|
|
||||||
<changefreq>monthly</changefreq>
|
|
||||||
<priority>0.7</priority>
|
|
||||||
</url>
|
|
||||||
<!-- German accessibility audit landing page -->
|
|
||||||
<url>
|
|
||||||
<loc>https://sascha-bach.de/de/audit</loc>
|
|
||||||
<xhtml:link rel="alternate" hreflang="de" href="https://sascha-bach.de/de/audit" />
|
|
||||||
<xhtml:link rel="alternate" hreflang="en" href="https://sascha-bach.de/en/audit" />
|
|
||||||
<xhtml:link rel="alternate" hreflang="x-default" href="https://sascha-bach.de/de/audit" />
|
|
||||||
<lastmod>2026-07-29</lastmod>
|
|
||||||
<changefreq>monthly</changefreq>
|
|
||||||
<priority>0.9</priority>
|
|
||||||
</url>
|
|
||||||
<!-- English accessibility audit landing page -->
|
|
||||||
<url>
|
|
||||||
<loc>https://sascha-bach.de/en/audit</loc>
|
|
||||||
<xhtml:link rel="alternate" hreflang="de" href="https://sascha-bach.de/de/audit" />
|
|
||||||
<xhtml:link rel="alternate" hreflang="en" href="https://sascha-bach.de/en/audit" />
|
|
||||||
<xhtml:link rel="alternate" hreflang="x-default" href="https://sascha-bach.de/de/audit" />
|
|
||||||
<lastmod>2026-07-29</lastmod>
|
|
||||||
<changefreq>monthly</changefreq>
|
<changefreq>monthly</changefreq>
|
||||||
<priority>0.8</priority>
|
<priority>0.8</priority>
|
||||||
</url>
|
</url>
|
||||||
|
|
|
||||||
|
|
@ -4,27 +4,32 @@ import '../scss/App.scss';
|
||||||
import Navbar from '../components/layout/Navigation';
|
import Navbar from '../components/layout/Navigation';
|
||||||
import Footer from '../components/layout/Footer';
|
import Footer from '../components/layout/Footer';
|
||||||
import BackToTopButton from '../components/BackToTopButton';
|
import BackToTopButton from '../components/BackToTopButton';
|
||||||
import { getLanguageFromPath, useLanguage } from '../contexts/LanguageContext';
|
import { useLanguage } from '../contexts/LanguageContext';
|
||||||
|
|
||||||
const HomePage = lazy(() => import('../pages/HomePage'));
|
const HomePage = lazy(() => import('../pages/HomePage'));
|
||||||
const LandingPage = lazy(() => import('../pages/LandingPage'));
|
const LandingPage = lazy(() => import('../pages/LandingPage'));
|
||||||
const AuditPage = lazy(() => import('../pages/AuditPage'));
|
const AuditPage = lazy(() => import('../pages/AuditPage'));
|
||||||
|
const QuickcheckPage = lazy(() => import('../pages/QuickcheckPage'));
|
||||||
const ImprintPage = lazy(() => import('../pages/ImprintPage'));
|
const ImprintPage = lazy(() => import('../pages/ImprintPage'));
|
||||||
const PrivacyPolicy = lazy(() => import('../pages/PrivacyPolicy'));
|
const PrivacyPolicy = lazy(() => import('../pages/PrivacyPolicy'));
|
||||||
|
|
||||||
/** Syncs the LanguageContext whenever the URL prefix (/de/ or /en/) changes. */
|
function LegacyLanguageRedirect() {
|
||||||
function LanguageSyncer() {
|
const { setLanguage } = useLanguage();
|
||||||
const { setLanguage, language } = useLanguage();
|
|
||||||
const location = useLocation();
|
const location = useLocation();
|
||||||
|
|
||||||
useEffect(() => {
|
const legacyLanguage = location.pathname.startsWith('/en') ? 'en' : 'de';
|
||||||
const urlLang = getLanguageFromPath(location.pathname);
|
const targetPathname = location.pathname.replace(/^\/(de|en)(?=\/|$)/, '') || '/';
|
||||||
if (urlLang && urlLang !== language) {
|
|
||||||
setLanguage(urlLang);
|
|
||||||
}
|
|
||||||
}, [location.pathname, language, setLanguage]);
|
|
||||||
|
|
||||||
return null;
|
useEffect(() => {
|
||||||
|
setLanguage(legacyLanguage);
|
||||||
|
}, [legacyLanguage, setLanguage]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Navigate
|
||||||
|
to={{ pathname: targetPathname, search: location.search, hash: location.hash }}
|
||||||
|
replace
|
||||||
|
/>
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function AppShell() {
|
function AppShell() {
|
||||||
|
|
@ -48,28 +53,24 @@ function AppShell() {
|
||||||
<a href="#main-content" className="skip-link">
|
<a href="#main-content" className="skip-link">
|
||||||
{texts.accessibility.skipLinks.skipToHero}
|
{texts.accessibility.skipLinks.skipToHero}
|
||||||
</a>
|
</a>
|
||||||
<LanguageSyncer />
|
|
||||||
<Navbar />
|
<Navbar />
|
||||||
<main id="main-content" className="app__main">
|
<main id="main-content" className="app__main">
|
||||||
<Suspense fallback={<p role="status" className="sr-only">Loading…</p>}>
|
<Suspense fallback={<p role="status" className="sr-only">Loading…</p>}>
|
||||||
<Routes>
|
<Routes>
|
||||||
{/* Root redirects → German (primary market) */}
|
{/* Legacy locale-prefixed URLs → clean URLs */}
|
||||||
<Route path="/" element={<Navigate to="/de/" replace />} />
|
<Route path="/de/*" element={<LegacyLanguageRedirect />} />
|
||||||
<Route path="/technical" element={<Navigate to="/de/technical" replace />} />
|
<Route path="/en/*" element={<LegacyLanguageRedirect />} />
|
||||||
|
|
||||||
{/* German routes */}
|
{/* Canonical, language-neutral routes */}
|
||||||
<Route path="/de/" element={<LandingPage />} />
|
<Route path="/" element={<LandingPage />} />
|
||||||
<Route path="/de/technical" element={<HomePage />} />
|
<Route path="/technical" element={<HomePage />} />
|
||||||
<Route path="/de/audit" element={<AuditPage />} />
|
<Route path="/audit" element={<AuditPage />} />
|
||||||
|
<Route path="/schnellcheck" element={<QuickcheckPage />} />
|
||||||
{/* English routes */}
|
|
||||||
<Route path="/en/" element={<LandingPage />} />
|
|
||||||
<Route path="/en/technical" element={<HomePage />} />
|
|
||||||
<Route path="/en/audit" element={<AuditPage />} />
|
|
||||||
|
|
||||||
{/* Shared pages (noindex, language-independent) */}
|
{/* Shared pages (noindex, language-independent) */}
|
||||||
<Route path="/imprint" element={<ImprintPage />} />
|
<Route path="/imprint" element={<ImprintPage />} />
|
||||||
<Route path="/privacy-policy" element={<PrivacyPolicy />} />
|
<Route path="/privacy-policy" element={<PrivacyPolicy />} />
|
||||||
|
<Route path="*" element={<Navigate to="/" replace />} />
|
||||||
</Routes>
|
</Routes>
|
||||||
</Suspense>
|
</Suspense>
|
||||||
</main>
|
</main>
|
||||||
|
|
|
||||||
|
|
@ -1,9 +1,9 @@
|
||||||
import { LanguageProvider, getLanguageFromPath } from '../contexts/LanguageContext';
|
import { LanguageProvider, detectInitialLanguage } from '../contexts/LanguageContext';
|
||||||
import { ThemeProvider } from '../contexts/ThemeContext';
|
import { ThemeProvider } from '../contexts/ThemeContext';
|
||||||
import AppRouter from './AppRouter';
|
import AppRouter from './AppRouter';
|
||||||
|
|
||||||
function PortfolioApp() {
|
function PortfolioApp() {
|
||||||
const initialLanguage = getLanguageFromPath(globalThis.location.pathname) ?? 'de';
|
const initialLanguage = detectInitialLanguage();
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<LanguageProvider initialLanguage={initialLanguage}>
|
<LanguageProvider initialLanguage={initialLanguage}>
|
||||||
|
|
|
||||||
|
|
@ -8,6 +8,8 @@ import { ThemeProvider } from '../../contexts/ThemeContext';
|
||||||
import LandingPage from '../../pages/LandingPage';
|
import LandingPage from '../../pages/LandingPage';
|
||||||
import HomePage from '../../pages/HomePage';
|
import HomePage from '../../pages/HomePage';
|
||||||
import AuditPage from '../../pages/AuditPage';
|
import AuditPage from '../../pages/AuditPage';
|
||||||
|
import ImprintPage from '../../pages/ImprintPage';
|
||||||
|
import PrivacyPolicy from '../../pages/PrivacyPolicy';
|
||||||
import Footer from '../../components/layout/Footer';
|
import Footer from '../../components/layout/Footer';
|
||||||
|
|
||||||
function renderPage(children: ReactNode) {
|
function renderPage(children: ReactNode) {
|
||||||
|
|
@ -36,10 +38,30 @@ describe('accessibility smoke test', () => {
|
||||||
expect(await axe(container)).toHaveNoViolations();
|
expect(await axe(container)).toHaveNoViolations();
|
||||||
});
|
});
|
||||||
|
|
||||||
it('Footer keeps the active language for the portfolio link', () => {
|
it('Footer links to the clean technical portfolio URL', () => {
|
||||||
renderPage(<Footer />);
|
renderPage(<Footer />);
|
||||||
|
|
||||||
const link = screen.getByRole('link', { name: 'Technisches Portfolio' });
|
const link = screen.getByRole('link', { name: 'Technisches Portfolio' });
|
||||||
expect(link).toHaveAttribute('href', '/de/technical');
|
expect(link).toHaveAttribute('href', '/technical');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('Footer links to imprint and privacy policy separately', () => {
|
||||||
|
renderPage(<Footer />);
|
||||||
|
|
||||||
|
expect(screen.getByRole('link', { name: 'Impressum' })).toHaveAttribute('href', '/imprint');
|
||||||
|
expect(screen.getByRole('link', { name: 'Datenschutz' })).toHaveAttribute('href', '/privacy-policy');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('renders imprint and privacy policy as separate page contents', () => {
|
||||||
|
const { unmount } = renderPage(<ImprintPage />);
|
||||||
|
|
||||||
|
expect(screen.getByRole('heading', { name: 'Impressum' })).toBeInTheDocument();
|
||||||
|
expect(screen.queryByRole('heading', { name: 'Datenschutzerklärung' })).not.toBeInTheDocument();
|
||||||
|
|
||||||
|
unmount();
|
||||||
|
renderPage(<PrivacyPolicy />);
|
||||||
|
|
||||||
|
expect(screen.getByRole('heading', { name: 'Datenschutzerklärung' })).toBeInTheDocument();
|
||||||
|
expect(screen.queryByRole('heading', { name: 'Impressum' })).not.toBeInTheDocument();
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
|
||||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
|
After Width: | Height: | Size: 114 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 1.9 MiB |
Binary file not shown.
|
After Width: | Height: | Size: 150 KiB |
|
|
@ -1,26 +1,15 @@
|
||||||
import { useNavigate, useLocation } from 'react-router-dom';
|
|
||||||
import { useLanguage } from '../contexts/LanguageContext';
|
import { useLanguage } from '../contexts/LanguageContext';
|
||||||
import type { Language } from '../data/types';
|
import type { Language } from '../data/types';
|
||||||
import '../scss/language-toggle.scss';
|
import '../scss/language-toggle.scss';
|
||||||
import { useScreenReaderAnnouncements } from '../hooks/useScreenReaderAnnouncements';
|
import { useScreenReaderAnnouncements } from '../hooks/useScreenReaderAnnouncements';
|
||||||
|
|
||||||
/** Swap /de/ ↔ /en/ prefix (or /de/technical ↔ /en/technical) in the pathname. */
|
|
||||||
function getEquivalentPath(pathname: string, targetLang: Language): string {
|
|
||||||
if (pathname.startsWith('/de')) return pathname.replace(/^\/de/, `/${targetLang}`);
|
|
||||||
if (pathname.startsWith('/en')) return pathname.replace(/^\/en/, `/${targetLang}`);
|
|
||||||
return `/${targetLang}/`;
|
|
||||||
}
|
|
||||||
|
|
||||||
export default function LanguageToggle() {
|
export default function LanguageToggle() {
|
||||||
const { language } = useLanguage();
|
const { language, setLanguage } = useLanguage();
|
||||||
const { announce } = useScreenReaderAnnouncements();
|
const { announce } = useScreenReaderAnnouncements();
|
||||||
const navigate = useNavigate();
|
|
||||||
const location = useLocation();
|
|
||||||
|
|
||||||
const handleLanguageChange = (newLang: Language) => {
|
const handleLanguageChange = (newLang: Language) => {
|
||||||
if (newLang !== language) {
|
if (newLang !== language) {
|
||||||
const targetPath = getEquivalentPath(location.pathname, newLang);
|
setLanguage(newLang);
|
||||||
navigate(targetPath);
|
|
||||||
announce(`Language changed to ${newLang === 'en' ? 'English' : 'Deutsch'}`);
|
announce(`Language changed to ${newLang === 'en' ? 'English' : 'Deutsch'}`);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
|
||||||
|
|
@ -3,11 +3,10 @@ import { useScrollReveal } from '../../hooks/useScrollReveal';
|
||||||
import { useLanguage } from '../../contexts/LanguageContext';
|
import { useLanguage } from '../../contexts/LanguageContext';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Promotes the audit landing page from the main landing page. Lives in the
|
* Promotes the two free accessibility offers from the main landing page.
|
||||||
* landing folder because it belongs to that page, not to /audit itself.
|
|
||||||
*/
|
*/
|
||||||
export default function AuditTeaserSection() {
|
export default function AuditTeaserSection() {
|
||||||
const { texts, language } = useLanguage();
|
const { texts } = useLanguage();
|
||||||
const t = texts.audit.teaser;
|
const t = texts.audit.teaser;
|
||||||
const sectionRef = useScrollReveal();
|
const sectionRef = useScrollReveal();
|
||||||
|
|
||||||
|
|
@ -22,14 +21,30 @@ export default function AuditTeaserSection() {
|
||||||
<h2 id="audit-teaser-title" className="audit-teaser-section__title reveal-item">
|
<h2 id="audit-teaser-title" className="audit-teaser-section__title reveal-item">
|
||||||
{t.title}
|
{t.title}
|
||||||
</h2>
|
</h2>
|
||||||
<p className="audit-teaser-section__text reveal-item">{t.text}</p>
|
<div className="audit-teaser-section__offers reveal-item">
|
||||||
|
<article className="audit-teaser-section__card">
|
||||||
|
<h3 className="audit-teaser-section__card-title">{t.auditTitle}</h3>
|
||||||
|
<p className="audit-teaser-section__text">{t.auditText}</p>
|
||||||
<Link
|
<Link
|
||||||
to={`/${language}/audit`}
|
to="/audit"
|
||||||
className="audit-teaser-section__cta reveal-item"
|
className="audit-teaser-section__cta"
|
||||||
onClick={() => window.scrollTo({ top: 0, behavior: 'smooth' })}
|
onClick={() => window.scrollTo({ top: 0, behavior: 'smooth' })}
|
||||||
>
|
>
|
||||||
{t.ctaText}
|
{t.auditCtaText}
|
||||||
</Link>
|
</Link>
|
||||||
|
</article>
|
||||||
|
<article className="audit-teaser-section__card">
|
||||||
|
<h3 className="audit-teaser-section__card-title">{t.quickcheckTitle}</h3>
|
||||||
|
<p className="audit-teaser-section__text">{t.quickcheckText}</p>
|
||||||
|
<Link
|
||||||
|
to="/schnellcheck"
|
||||||
|
className="audit-teaser-section__cta"
|
||||||
|
onClick={() => window.scrollTo({ top: 0, behavior: 'smooth' })}
|
||||||
|
>
|
||||||
|
{t.quickcheckCtaText}
|
||||||
|
</Link>
|
||||||
|
</article>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
);
|
);
|
||||||
|
|
|
||||||
|
|
@ -1,13 +1,13 @@
|
||||||
import { Globe, Linkedin, Mail } from 'lucide-react';
|
import { Globe, Mail } from 'lucide-react';
|
||||||
import { Link, useLocation } from 'react-router-dom';
|
import { Link, useLocation } from 'react-router-dom';
|
||||||
import { personalConfig, createEmailLink } from '../../config/personal';
|
import { personalConfig, createEmailLink } from '../../config/personal';
|
||||||
import { useLanguage } from '../../contexts/LanguageContext';
|
import { useLanguage } from '../../contexts/LanguageContext';
|
||||||
|
|
||||||
export default function Footer() {
|
export default function Footer() {
|
||||||
const { texts, language } = useLanguage();
|
const { texts } = useLanguage();
|
||||||
const location = useLocation();
|
const location = useLocation();
|
||||||
const isTechnicalPage = location.pathname.includes('/technical', 0);
|
const isTechnicalPage = location.pathname.includes('/technical', 0);
|
||||||
const technicalLinkTarget = `/${language}/technical`;
|
const technicalLinkTarget = '/technical';
|
||||||
// Email obfuscation function using config
|
// Email obfuscation function using config
|
||||||
const handleEmailClick = (e: React.MouseEvent) => {
|
const handleEmailClick = (e: React.MouseEvent) => {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
|
|
@ -24,15 +24,25 @@ export default function Footer() {
|
||||||
<Link to="/imprint" className="footer__link">
|
<Link to="/imprint" className="footer__link">
|
||||||
{texts.footer.imprintText}
|
{texts.footer.imprintText}
|
||||||
</Link>
|
</Link>
|
||||||
|
<Link to="/privacy-policy" className="footer__link">
|
||||||
|
{texts.footer.privacyPolicyText}
|
||||||
|
</Link>
|
||||||
<Link
|
<Link
|
||||||
to={`/${language}/audit`}
|
to="/audit"
|
||||||
className="footer__link"
|
className="footer__link"
|
||||||
onClick={() => window.scrollTo({ top: 0, behavior: 'smooth' })}
|
onClick={() => window.scrollTo({ top: 0, behavior: 'smooth' })}
|
||||||
>
|
>
|
||||||
{texts.footer.auditLinkText}
|
{texts.footer.auditLinkText}
|
||||||
</Link>
|
</Link>
|
||||||
<Link
|
<Link
|
||||||
to={isTechnicalPage ? `/${language}/` : technicalLinkTarget}
|
to="/schnellcheck"
|
||||||
|
className="footer__link"
|
||||||
|
onClick={() => window.scrollTo({ top: 0, behavior: 'smooth' })}
|
||||||
|
>
|
||||||
|
{texts.footer.quickcheckLinkText}
|
||||||
|
</Link>
|
||||||
|
<Link
|
||||||
|
to={isTechnicalPage ? '/' : technicalLinkTarget}
|
||||||
className="footer__link"
|
className="footer__link"
|
||||||
onClick={() => window.scrollTo({ top: 0, behavior: 'smooth' })}
|
onClick={() => window.scrollTo({ top: 0, behavior: 'smooth' })}
|
||||||
>
|
>
|
||||||
|
|
@ -48,6 +58,7 @@ export default function Footer() {
|
||||||
className="footer__social-button"
|
className="footer__social-button"
|
||||||
onClick={() => window.open(personalConfig.social.git.url, '_blank')}
|
onClick={() => window.open(personalConfig.social.git.url, '_blank')}
|
||||||
aria-label={texts.footer.gitAriaLabel}
|
aria-label={texts.footer.gitAriaLabel}
|
||||||
|
type="button"
|
||||||
>
|
>
|
||||||
<Globe className="footer__social-icon" />
|
<Globe className="footer__social-icon" />
|
||||||
</button>
|
</button>
|
||||||
|
|
@ -56,14 +67,29 @@ export default function Footer() {
|
||||||
className="footer__social-button"
|
className="footer__social-button"
|
||||||
onClick={() => window.open(personalConfig.social.linkedin.url, '_blank')}
|
onClick={() => window.open(personalConfig.social.linkedin.url, '_blank')}
|
||||||
aria-label={texts.footer.linkedinAriaLabel}
|
aria-label={texts.footer.linkedinAriaLabel}
|
||||||
|
type="button"
|
||||||
>
|
>
|
||||||
<Linkedin className="footer__social-icon" />
|
<svg
|
||||||
|
className="footer__social-icon"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
fill="none"
|
||||||
|
stroke="currentColor"
|
||||||
|
strokeWidth="2"
|
||||||
|
strokeLinecap="round"
|
||||||
|
strokeLinejoin="round"
|
||||||
|
aria-hidden="true"
|
||||||
|
>
|
||||||
|
<path d="M16 8a6 6 0 0 1 6 6v7h-4v-7a2 2 0 0 0-2-2 2 2 0 0 0-2 2v7h-4v-7a6 6 0 0 1 6-6z" />
|
||||||
|
<rect x="2" y="9" width="4" height="12" />
|
||||||
|
<circle cx="4" cy="4" r="2" />
|
||||||
|
</svg>
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
<button
|
<button
|
||||||
className="footer__social-button"
|
className="footer__social-button"
|
||||||
onClick={handleEmailClick}
|
onClick={handleEmailClick}
|
||||||
aria-label={texts.footer.emailAriaLabel}
|
aria-label={texts.footer.emailAriaLabel}
|
||||||
|
type="button"
|
||||||
>
|
>
|
||||||
<Mail className="footer__social-icon" />
|
<Mail className="footer__social-icon" />
|
||||||
</button>
|
</button>
|
||||||
|
|
|
||||||
|
|
@ -14,17 +14,18 @@ export default function Navbar() {
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
const { texts } = useLanguage();
|
const { texts } = useLanguage();
|
||||||
|
|
||||||
const isLandingPage = location.pathname === '/de/' || location.pathname === '/en/' || location.pathname === '/';
|
const isLandingPage = location.pathname === '/';
|
||||||
// The audit page is a lead-magnet landing page: every extra nav target is an
|
const technicalPath = '/technical';
|
||||||
// exit. It also has no scroll sections, so the section buttons would only
|
let lastMainPage = '/';
|
||||||
// navigate away to /de/technical#… - see scrollUtils.isScrollablePath.
|
|
||||||
const isAuditPage = location.pathname === '/de/audit' || location.pathname === '/en/audit';
|
try {
|
||||||
const technicalPath = location.pathname.startsWith('/en') ? '/en/technical' : '/de/technical';
|
lastMainPage = sessionStorage.getItem('portfolio:last-main-page') || '/';
|
||||||
|
} catch {
|
||||||
|
// Use the landing page as the default when session storage is unavailable.
|
||||||
|
}
|
||||||
|
|
||||||
let menuItems = texts.navigation.menuItems;
|
let menuItems = texts.navigation.menuItems;
|
||||||
if (isAuditPage) {
|
if (isLandingPage || (location.pathname !== technicalPath && lastMainPage === '/')) {
|
||||||
menuItems = [];
|
|
||||||
} else if (isLandingPage) {
|
|
||||||
menuItems = texts.navigation.landingMenuItems;
|
menuItems = texts.navigation.landingMenuItems;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -33,6 +34,14 @@ export default function Navbar() {
|
||||||
|
|
||||||
// Track active section based on scroll position
|
// Track active section based on scroll position
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
if (location.pathname === '/' || location.pathname === technicalPath) {
|
||||||
|
try {
|
||||||
|
sessionStorage.setItem('portfolio:last-main-page', location.pathname);
|
||||||
|
} catch {
|
||||||
|
// Ignore unavailable session storage and use the landing-page fallback.
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
if (!isSectionTrackingPage) {
|
if (!isSectionTrackingPage) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
@ -68,11 +77,13 @@ export default function Navbar() {
|
||||||
return (
|
return (
|
||||||
<div className={`navbar${isLandingPage ? ' navbar--landing' : ''}`}>
|
<div className={`navbar${isLandingPage ? ' navbar--landing' : ''}`}>
|
||||||
<Link
|
<Link
|
||||||
to={isLandingPage ? location.pathname : technicalPath}
|
to="/"
|
||||||
className="navbar__name"
|
className="navbar__name"
|
||||||
onClick={(e) => {
|
onClick={(e) => {
|
||||||
|
if (isLandingPage) {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
window.scrollTo({ top: 0, behavior: 'smooth' });
|
window.scrollTo({ top: 0, behavior: 'smooth' });
|
||||||
|
}
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<img src={logoUrl} alt={texts.navigation.logoAlt} className="navbar__logo" />
|
<img src={logoUrl} alt={texts.navigation.logoAlt} className="navbar__logo" />
|
||||||
|
|
@ -81,6 +92,7 @@ export default function Navbar() {
|
||||||
{menuItems.map((item) => (
|
{menuItems.map((item) => (
|
||||||
<button
|
<button
|
||||||
key={item.label}
|
key={item.label}
|
||||||
|
type="button"
|
||||||
onClick={() => handleNavigation(item.section)}
|
onClick={() => handleNavigation(item.section)}
|
||||||
className={`navbar__container__button ${displayedActiveSection === item.section.toLowerCase() ? 'navbar__container__button--active' : ''}`}
|
className={`navbar__container__button ${displayedActiveSection === item.section.toLowerCase() ? 'navbar__container__button--active' : ''}`}
|
||||||
>
|
>
|
||||||
|
|
|
||||||
|
|
@ -12,7 +12,7 @@ interface ProjectsSectionProps {
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function ProjectsSection(props: ProjectsSectionProps = {}) {
|
export default function ProjectsSection(props: ProjectsSectionProps = {}) {
|
||||||
const { texts: allTexts } = useLanguage();
|
const { language, texts: allTexts } = useLanguage();
|
||||||
const texts = allTexts.projects;
|
const texts = allTexts.projects;
|
||||||
const accessibilityTexts = allTexts.accessibility;
|
const accessibilityTexts = allTexts.accessibility;
|
||||||
const localizedProjectsData: Project[] = texts.projectItems.map((item) => ({
|
const localizedProjectsData: Project[] = texts.projectItems.map((item) => ({
|
||||||
|
|
@ -31,9 +31,8 @@ export default function ProjectsSection(props: ProjectsSectionProps = {}) {
|
||||||
title = texts.title,
|
title = texts.title,
|
||||||
subtitle = texts.subtitle,
|
subtitle = texts.subtitle,
|
||||||
projects = [...localizedProjectsData].sort((a, b) => {
|
projects = [...localizedProjectsData].sort((a, b) => {
|
||||||
const yearA = Number.parseInt(a.year || '0');
|
const displayOrder = [5, 8, 6, 4, 3, 1, 2, 7];
|
||||||
const yearB = Number.parseInt(b.year || '0');
|
return displayOrder.indexOf(a.id) - displayOrder.indexOf(b.id);
|
||||||
return yearB - yearA; // Descending order (newest first)
|
|
||||||
})
|
})
|
||||||
} = props;
|
} = props;
|
||||||
|
|
||||||
|
|
@ -118,7 +117,9 @@ export default function ProjectsSection(props: ProjectsSectionProps = {}) {
|
||||||
<div className="projects-section__image-container">
|
<div className="projects-section__image-container">
|
||||||
<img
|
<img
|
||||||
src={project.image || "/api/placeholder/400/200"}
|
src={project.image || "/api/placeholder/400/200"}
|
||||||
alt={`${project.title} project screenshot`}
|
alt={language === 'de'
|
||||||
|
? `Screenshot der Website ${project.title}`
|
||||||
|
: `${project.title} website screenshot`}
|
||||||
className="projects-section__image"
|
className="projects-section__image"
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -138,8 +138,12 @@ export const audit = {
|
||||||
},
|
},
|
||||||
|
|
||||||
teaser: {
|
teaser: {
|
||||||
title: 'Erst mal sehen, wo du stehst?',
|
title: 'Kostenlose Angebote',
|
||||||
text: 'Ich prüfe eine Seite deiner Website nach WCAG und schicke dir einen kostenfreien Kurzreport – plus 15 Minuten Auswertung. Für Unternehmen.',
|
auditTitle: 'Erst mal sehen, wo du stehst?',
|
||||||
ctaText: 'Zum kostenfreien Audit',
|
auditText: 'Im kostenlosen One-Page Audit schauen wir uns eine Seite deiner Wahl gemeinsam an und besprechen die wichtigsten nächsten Schritte.',
|
||||||
|
auditCtaText: 'Zum kostenlosen Audit',
|
||||||
|
quickcheckTitle: 'Barrierefreiheits-Schnellcheck',
|
||||||
|
quickcheckText: 'Lade eine Checkliste mit 10 Punkten und eine Vorlage für deine Erklärung zur Barrierefreiheit herunter.',
|
||||||
|
quickcheckCtaText: 'Schnellcheck herunterladen',
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
|
||||||
|
|
@ -1,9 +1,11 @@
|
||||||
export const footer = {
|
export const footer = {
|
||||||
imprintText: 'Impressum und Datenschutz',
|
imprintText: 'Impressum',
|
||||||
|
privacyPolicyText: 'Datenschutz',
|
||||||
copyrightText: 'Alle Rechte vorbehalten.',
|
copyrightText: 'Alle Rechte vorbehalten.',
|
||||||
technicalLinkText: 'Technisches Portfolio',
|
technicalLinkText: 'Technisches Portfolio',
|
||||||
landingLinkText: 'Landing Page',
|
landingLinkText: 'Landing Page',
|
||||||
auditLinkText: 'Kostenfreies Audit',
|
auditLinkText: 'Kostenfreies Audit',
|
||||||
|
quickcheckLinkText: 'Kostenloser Schnellcheck',
|
||||||
gitAriaLabel: 'Git-Profil',
|
gitAriaLabel: 'Git-Profil',
|
||||||
linkedinAriaLabel: 'LinkedIn-Profil',
|
linkedinAriaLabel: 'LinkedIn-Profil',
|
||||||
emailAriaLabel: 'E-Mail senden',
|
emailAriaLabel: 'E-Mail senden',
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
export const imprint = {
|
export const imprint = {
|
||||||
title: 'Impressum und Datenschutz',
|
title: 'Impressum',
|
||||||
subtitle: 'Rechtliche Informationen',
|
subtitle: 'Rechtliche Informationen zum Anbieter',
|
||||||
companyInfoTitle: 'Angaben gemäß § 5 DDG',
|
companyInfoTitle: 'Angaben gemäß § 5 DDG',
|
||||||
umsatzsteuerID: 'Umsatzsteuer-ID: DE316934637',
|
umsatzsteuerID: 'Umsatzsteuer-ID: DE316934637',
|
||||||
vatID: 'VAT ID: DE316934637',
|
vatID: 'VAT ID: DE316934637',
|
||||||
|
|
@ -91,6 +91,25 @@ export const imprint = {
|
||||||
],
|
],
|
||||||
retentionText:
|
retentionText:
|
||||||
'Sofern wir (bestimmte Teile) Ihre(r) Daten jedoch noch für andere Zwecke vorhalten müssen, weil dies etwa steuerliche Aufbewahrungsfristen (in der Regel 6 Jahre für Geschäftskorrespondenz bzw. 10 Jahre für Buchungsbelege) oder die Geltendmachung, Ausübung oder Verteidigung von Rechtsansprüchen aus vertraglichen Beziehungen (bis zu vier Jahren) erforderlich machen oder die Daten zum Schutz der Rechte einer anderen natürlichen oder juristischen Person gebraucht werden, löschen wir (den Teil) Ihre(r) Daten erst nach Ablauf dieser Fristen. Bis zum Ablauf dieser Fristen beschränken wir die Verarbeitung dieser Daten jedoch auf diese Zwecke (Erfüllung der Aufbewahrungspflichten).',
|
'Sofern wir (bestimmte Teile) Ihre(r) Daten jedoch noch für andere Zwecke vorhalten müssen, weil dies etwa steuerliche Aufbewahrungsfristen (in der Regel 6 Jahre für Geschäftskorrespondenz bzw. 10 Jahre für Buchungsbelege) oder die Geltendmachung, Ausübung oder Verteidigung von Rechtsansprüchen aus vertraglichen Beziehungen (bis zu vier Jahren) erforderlich machen oder die Daten zum Schutz der Rechte einer anderen natürlichen oder juristischen Person gebraucht werden, löschen wir (den Teil) Ihre(r) Daten erst nach Ablauf dieser Fristen. Bis zum Ablauf dieser Fristen beschränken wir die Verarbeitung dieser Daten jedoch auf diese Zwecke (Erfüllung der Aufbewahrungspflichten).',
|
||||||
|
quickcheckFormTitle: 'Formular „Barrierefreiheits-Schnellcheck“',
|
||||||
|
quickcheckFormText:
|
||||||
|
'Wenn Sie das Schnellcheck-Formular absenden, werden die von Ihnen eingegebenen Daten an unseren eigenen Server übermittelt und von dort per E-Mail an uns weitergeleitet. Es sind keine externen Formulardienstleister beteiligt. Der Schnellcheck ist ein Selbsttest und kein vollständiges Audit oder Rechtsgutachten.',
|
||||||
|
quickcheckFormDataTitle: 'Verarbeitete Daten:',
|
||||||
|
quickcheckFormDataList: [
|
||||||
|
'Name (optional)',
|
||||||
|
'E-Mail-Adresse',
|
||||||
|
'Bestätigung, dass Sie als Unternehmer, Gewerbetreibender oder Freiberufler anfragen (§ 14 BGB)',
|
||||||
|
'Ihre Einwilligung zur Verarbeitung der Angaben',
|
||||||
|
],
|
||||||
|
quickcheckFormPurposeLabel: 'Zweck:',
|
||||||
|
quickcheckFormPurpose:
|
||||||
|
'Bearbeitung der Schnellcheck-Anfrage und Kontaktaufnahme zur Bereitstellung des Downloads.',
|
||||||
|
quickcheckFormLegalBasisLabel: 'Rechtsgrundlage:',
|
||||||
|
quickcheckFormLegalBasis:
|
||||||
|
'Ihre Einwilligung nach Art. 6 Abs. 1 lit. a DSGVO, die Sie beim Absenden des Formulars erteilen. Sie können Ihre Einwilligung jederzeit formlos widerrufen.',
|
||||||
|
quickcheckFormRetentionLabel: 'Speicherdauer:',
|
||||||
|
quickcheckFormRetention:
|
||||||
|
'Wir löschen Ihre Angaben, sobald die Anfrage abschließend bearbeitet ist und keine gesetzlichen Aufbewahrungspflichten entgegenstehen – spätestens zwölf Monate nach dem letzten Kontakt.',
|
||||||
webhostingTitle: 'Webhosting',
|
webhostingTitle: 'Webhosting',
|
||||||
webhostingText:
|
webhostingText:
|
||||||
'Wir bedienen uns zum Vorhalten unserer Internetseiten eines Anbieters, auf dessen Server unsere Internetseiten gespeichert und für den Abruf im Internet verfügbar gemacht werden (Hosting). Hierbei können von dem Anbieter all diejenigen über den von Ihnen genutzten Browser übertragenen Daten verarbeitet werden, die bei der Nutzung unserer Internetseiten anfallen. Hierzu gehören insbesondere Ihre IP-Adresse, die der Anbieter benötigt, um unser Online-Angebot an den von Ihnen genutzten Browser ausliefern zu können sowie sämtliche von Ihnen über unsere Internetseite getätigten Eingaben. Daneben kann der von uns genutzte Anbieter',
|
'Wir bedienen uns zum Vorhalten unserer Internetseiten eines Anbieters, auf dessen Server unsere Internetseiten gespeichert und für den Abruf im Internet verfügbar gemacht werden (Hosting). Hierbei können von dem Anbieter all diejenigen über den von Ihnen genutzten Browser übertragenen Daten verarbeitet werden, die bei der Nutzung unserer Internetseiten anfallen. Hierzu gehören insbesondere Ihre IP-Adresse, die der Anbieter benötigt, um unser Online-Angebot an den von Ihnen genutzten Browser ausliefern zu können sowie sämtliche von Ihnen über unsere Internetseite getätigten Eingaben. Daneben kann der von uns genutzte Anbieter',
|
||||||
|
|
|
||||||
|
|
@ -5,6 +5,7 @@ export const navigation = {
|
||||||
{ section: 'Skills', label: 'Fähigkeiten' },
|
{ section: 'Skills', label: 'Fähigkeiten' },
|
||||||
{ section: 'Certifications', label: 'Zertifikate' },
|
{ section: 'Certifications', label: 'Zertifikate' },
|
||||||
{ section: 'Projects', label: 'Projekte' },
|
{ section: 'Projects', label: 'Projekte' },
|
||||||
|
{ section: 'Audit-Teaser', label: 'Kostenlose Angebote' },
|
||||||
{ section: 'Contact', label: 'Kontakt' },
|
{ section: 'Contact', label: 'Kontakt' },
|
||||||
],
|
],
|
||||||
landingMenuItems: [
|
landingMenuItems: [
|
||||||
|
|
@ -14,6 +15,7 @@ export const navigation = {
|
||||||
{ section: 'Processes', label: 'Wie ich arbeite' },
|
{ section: 'Processes', label: 'Wie ich arbeite' },
|
||||||
{ section: 'Projects', label: 'Projekte' },
|
{ section: 'Projects', label: 'Projekte' },
|
||||||
{ section: 'References', label: 'Referenzen' },
|
{ section: 'References', label: 'Referenzen' },
|
||||||
|
{ section: 'Audit-Teaser', label: 'Kostenlose Angebote' },
|
||||||
{ section: 'Contact', label: 'Kontakt' },
|
{ section: 'Contact', label: 'Kontakt' },
|
||||||
],
|
],
|
||||||
mobileMenuAriaLabel: 'Menü öffnen',
|
mobileMenuAriaLabel: 'Menü öffnen',
|
||||||
|
|
|
||||||
|
|
@ -3,7 +3,7 @@ export const privacyPolicy = {
|
||||||
lastUpdated: 'Zuletzt aktualisiert: November 2025',
|
lastUpdated: 'Zuletzt aktualisiert: November 2025',
|
||||||
introTitle: 'Überblick',
|
introTitle: 'Überblick',
|
||||||
introText:
|
introText:
|
||||||
'Diese Website ist eine statische Portfolio-Website. Es werden keine Cookies, Tracking- oder Analyse-Tools eingesetzt, und es sind keine Dienste Dritter eingebunden – auch die Schriftarten werden von unserem eigenen Server ausgeliefert. Personenbezogene Daten werden nur dann verarbeitet, wenn Sie das Formular auf der Seite „Kostenfreies Barrierefreiheits-Audit nach WCAG“ absenden.',
|
'Diese Website ist eine statische Portfolio-Website. Es werden keine Cookies, Tracking- oder Analyse-Tools eingesetzt, und es sind keine Dienste Dritter eingebunden – auch die Schriftarten werden von unserem eigenen Server ausgeliefert. Personenbezogene Daten werden verarbeitet, wenn Sie das Formular auf der Seite „Kostenfreies Barrierefreiheits-Audit nach WCAG“ oder den kostenlosen Barrierefreiheits-Schnellcheck absenden.',
|
||||||
dataCollectionTitle: 'Datenerhebung',
|
dataCollectionTitle: 'Datenerhebung',
|
||||||
dataCollectionText:
|
dataCollectionText:
|
||||||
'Ohne Ihr Zutun erhebt diese Website keine personenbezogenen Daten. Wenn Sie auf Kontaktlinks klicken (E-Mail, LinkedIn, Git), werden Sie zu externen Diensten weitergeleitet, die eigene Datenschutzrichtlinien haben:',
|
'Ohne Ihr Zutun erhebt diese Website keine personenbezogenen Daten. Wenn Sie auf Kontaktlinks klicken (E-Mail, LinkedIn, Git), werden Sie zu externen Diensten weitergeleitet, die eigene Datenschutzrichtlinien haben:',
|
||||||
|
|
@ -28,6 +28,25 @@ export const privacyPolicy = {
|
||||||
auditFormPurposeLabel: 'Zweck:',
|
auditFormPurposeLabel: 'Zweck:',
|
||||||
auditFormPurpose:
|
auditFormPurpose:
|
||||||
'Bearbeitung Ihrer Anfrage, Erstellung des Audit-Reports und Kontaktaufnahme zur Terminvereinbarung.',
|
'Bearbeitung Ihrer Anfrage, Erstellung des Audit-Reports und Kontaktaufnahme zur Terminvereinbarung.',
|
||||||
|
quickcheckFormTitle: 'Formular „Barrierefreiheits-Schnellcheck“',
|
||||||
|
quickcheckFormText:
|
||||||
|
'Wenn Sie das Schnellcheck-Formular absenden, werden die von Ihnen eingegebenen Daten an unseren eigenen Server übermittelt und von dort per E-Mail an uns weitergeleitet. Es sind keine externen Formulardienstleister beteiligt. Der Schnellcheck ist ein Selbsttest und kein vollständiges Audit oder Rechtsgutachten.',
|
||||||
|
quickcheckFormDataTitle: 'Verarbeitete Daten:',
|
||||||
|
quickcheckFormDataList: [
|
||||||
|
'Name (optional)',
|
||||||
|
'E-Mail-Adresse',
|
||||||
|
'Bestätigung, dass Sie als Unternehmer, Gewerbetreibender oder Freiberufler anfragen (§ 14 BGB)',
|
||||||
|
'Ihre Einwilligung zur Verarbeitung der Angaben',
|
||||||
|
],
|
||||||
|
quickcheckFormPurposeLabel: 'Zweck:',
|
||||||
|
quickcheckFormPurpose:
|
||||||
|
'Bearbeitung der Schnellcheck-Anfrage und Kontaktaufnahme zur Bereitstellung des Downloads.',
|
||||||
|
quickcheckFormLegalBasisLabel: 'Rechtsgrundlage:',
|
||||||
|
quickcheckFormLegalBasis:
|
||||||
|
'Ihre Einwilligung nach Art. 6 Abs. 1 lit. a DSGVO, die Sie beim Absenden des Formulars erteilen. Sie können Ihre Einwilligung jederzeit formlos widerrufen.',
|
||||||
|
quickcheckFormRetentionLabel: 'Speicherdauer:',
|
||||||
|
quickcheckFormRetention:
|
||||||
|
'Wir löschen Ihre Angaben, sobald die Anfrage abschließend bearbeitet ist und keine gesetzlichen Aufbewahrungspflichten entgegenstehen – spätestens zwölf Monate nach dem letzten Kontakt.',
|
||||||
auditFormLegalBasisLabel: 'Rechtsgrundlage:',
|
auditFormLegalBasisLabel: 'Rechtsgrundlage:',
|
||||||
auditFormLegalBasis:
|
auditFormLegalBasis:
|
||||||
'Art. 6 Abs. 1 lit. b DSGVO (Durchführung vorvertraglicher Maßnahmen auf Ihre Anfrage hin) sowie Ihre Einwilligung nach Art. 6 Abs. 1 lit. a DSGVO, die Sie beim Absenden erteilen. Ihre Einwilligung können Sie jederzeit formlos widerrufen.',
|
'Art. 6 Abs. 1 lit. b DSGVO (Durchführung vorvertraglicher Maßnahmen auf Ihre Anfrage hin) sowie Ihre Einwilligung nach Art. 6 Abs. 1 lit. a DSGVO, die Sie beim Absenden erteilen. Ihre Einwilligung können Sie jederzeit formlos widerrufen.',
|
||||||
|
|
|
||||||
|
|
@ -2,13 +2,16 @@ import portfolioImage from '@/assets/portfolio.PNG';
|
||||||
import dancaAlegriaImage from '@/assets/Danca-Alegria.png';
|
import dancaAlegriaImage from '@/assets/Danca-Alegria.png';
|
||||||
import lukasImage from '@/assets/lukas.png';
|
import lukasImage from '@/assets/lukas.png';
|
||||||
import a11yhubImage from '@/assets/a11yhub.png';
|
import a11yhubImage from '@/assets/a11yhub.png';
|
||||||
|
import arcumNovaImage from '@/assets/arcum-nova.png';
|
||||||
|
import gxplexImage from '@/assets/gxplex.png';
|
||||||
|
import aovmImage from '@/assets/aovm.png';
|
||||||
import placeholderImage from '@/assets/og-image.png';
|
import placeholderImage from '@/assets/og-image.png';
|
||||||
|
|
||||||
export const projects = {
|
export const projects = {
|
||||||
title: 'Ausgewählte Projekte',
|
title: 'Ausgewählte Projekte',
|
||||||
subtitle: 'Eine Übersicht meiner Arbeiten und persönlichen Projekte',
|
subtitle: 'Eine Übersicht meiner Arbeiten und persönlichen Projekte',
|
||||||
codeButtonText: 'Code',
|
codeButtonText: 'Code',
|
||||||
liveButtonText: 'Live',
|
liveButtonText: 'Website öffnen',
|
||||||
projectItems: [
|
projectItems: [
|
||||||
{
|
{
|
||||||
id: 1,
|
id: 1,
|
||||||
|
|
@ -53,21 +56,33 @@ export const projects = {
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: 5,
|
id: 5,
|
||||||
title: 'Barrierefreiheits-Audit für einen Unternehmenskunden',
|
title: 'arcum-nova.de',
|
||||||
description:
|
description:
|
||||||
'Vollständige BITV 2.0 / EN 301 549 Prüfung (115 Kriterien) nach WCAG 2.2 Level AA über 21 Seiten, mit priorisiertem Befundbericht (Critical/High/Medium/Low), konkreten Lösungsvorschlägen und Re-Audit-Fahrplan.',
|
'Umsetzung von WCAG 2.2 auf einer bestehenden WordPress-Seite mit Avada / Fusion Builder für eine mobile Fahrradwerkstatt im Havelland. Dazu gehörten unter anderem das Entfernen eines unerwünschten seitenweiten Bildrahmens und die konforme Anpassung der Kontraste aktiver Menüzustände.',
|
||||||
image: placeholderImage,
|
image: arcumNovaImage,
|
||||||
technologies: ['WCAG 2.2', 'BITV 2.0', 'EN 301 549', 'axe DevTools', 'WAVE'],
|
technologies: ['WCAG 2.2', 'WordPress', 'Avada', 'Barrierefreiheit'],
|
||||||
year: '2026',
|
year: '2026',
|
||||||
|
live: 'https://arcum-nova.de',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 8,
|
||||||
|
title: 'aovm.de',
|
||||||
|
description:
|
||||||
|
'Laufende technische Wartung und inhaltliche Pflege einer WordPress-Seite mit eigenem Theme nach Kundenvorgaben. Der Leistungsumfang umfasste Updates, Backups und Inhaltsänderungen. Die Seite basiert auf WordPress; im Projektkontext ist außerdem Flatsome dokumentiert. Das Projekt wurde im August 2026 beendet.',
|
||||||
|
image: aovmImage,
|
||||||
|
technologies: ['WordPress', 'Wartung', 'Seitenpflege'],
|
||||||
|
year: '2026',
|
||||||
|
live: 'https://aovm.de',
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: 6,
|
id: 6,
|
||||||
title: 'Individuelle MediaWiki-Plattform (GxPlex)',
|
title: 'gxplex.org',
|
||||||
description:
|
description:
|
||||||
'Aufbau einer maßgeschneiderten MediaWiki-Instanz inkl. Installation, MySQL-Datenbank, SSL und automatischer Backups; Einrichtung rollenbasierter Rechte (Admin, Mod, Verifiziert, User), FlaggedRevisions-Workflow für die redaktionelle Prüfung sowie Kommentar-/Bewertungs-Extensions.',
|
'Aufbau einer englischsprachigen MediaWiki-Plattform mit Semantic MediaWiki, Skin Cavendish und Bewertungsfunktion. Die kollaborative Datenbank bündelt Gesetze, Verordnungen und Leitlinien der klinischen Forschung. Sie umfasst einen Dokumentindex, eine Modulübersicht, ein Glossar, Suche sowie eine Mitgliedschaft mit Beitragsfunktion. Über 1.100 Referenzen sind nach Land, Anwendungsbereich, Dokumenttyp und Thema getaggt.',
|
||||||
image: placeholderImage,
|
image: gxplexImage,
|
||||||
technologies: ['MediaWiki', 'MySQL', 'PHP', 'SSL'],
|
technologies: ['MediaWiki 1.43.8', 'Semantic MediaWiki', 'Cavendish', 'Bewertungsfunktion'],
|
||||||
year: '2026',
|
year: '2026',
|
||||||
|
live: 'https://gxplex.org',
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: 7,
|
id: 7,
|
||||||
|
|
|
||||||
|
|
@ -20,10 +20,12 @@ export interface TextConfig {
|
||||||
// Footer
|
// Footer
|
||||||
footer: {
|
footer: {
|
||||||
imprintText: string;
|
imprintText: string;
|
||||||
|
privacyPolicyText: string;
|
||||||
copyrightText: string;
|
copyrightText: string;
|
||||||
technicalLinkText: string;
|
technicalLinkText: string;
|
||||||
landingLinkText: string;
|
landingLinkText: string;
|
||||||
auditLinkText: string;
|
auditLinkText: string;
|
||||||
|
quickcheckLinkText: string;
|
||||||
gitAriaLabel: string;
|
gitAriaLabel: string;
|
||||||
linkedinAriaLabel: string;
|
linkedinAriaLabel: string;
|
||||||
emailAriaLabel: string;
|
emailAriaLabel: string;
|
||||||
|
|
@ -236,6 +238,16 @@ export interface TextConfig {
|
||||||
dataDeletionIntro: string;
|
dataDeletionIntro: string;
|
||||||
dataDeletionReasons: string[];
|
dataDeletionReasons: string[];
|
||||||
retentionText: string;
|
retentionText: string;
|
||||||
|
quickcheckFormTitle: string;
|
||||||
|
quickcheckFormText: string;
|
||||||
|
quickcheckFormDataTitle: string;
|
||||||
|
quickcheckFormDataList: string[];
|
||||||
|
quickcheckFormPurposeLabel: string;
|
||||||
|
quickcheckFormPurpose: string;
|
||||||
|
quickcheckFormLegalBasisLabel: string;
|
||||||
|
quickcheckFormLegalBasis: string;
|
||||||
|
quickcheckFormRetentionLabel: string;
|
||||||
|
quickcheckFormRetention: string;
|
||||||
webhostingTitle: string;
|
webhostingTitle: string;
|
||||||
webhostingText: string;
|
webhostingText: string;
|
||||||
webhostingDataTypes: string[];
|
webhostingDataTypes: string[];
|
||||||
|
|
@ -309,6 +321,16 @@ export interface TextConfig {
|
||||||
auditFormDataList: string[];
|
auditFormDataList: string[];
|
||||||
auditFormPurposeLabel: string;
|
auditFormPurposeLabel: string;
|
||||||
auditFormPurpose: string;
|
auditFormPurpose: string;
|
||||||
|
quickcheckFormTitle: string;
|
||||||
|
quickcheckFormText: string;
|
||||||
|
quickcheckFormDataTitle: string;
|
||||||
|
quickcheckFormDataList: string[];
|
||||||
|
quickcheckFormPurposeLabel: string;
|
||||||
|
quickcheckFormPurpose: string;
|
||||||
|
quickcheckFormLegalBasisLabel: string;
|
||||||
|
quickcheckFormLegalBasis: string;
|
||||||
|
quickcheckFormRetentionLabel: string;
|
||||||
|
quickcheckFormRetention: string;
|
||||||
auditFormLegalBasisLabel: string;
|
auditFormLegalBasisLabel: string;
|
||||||
auditFormLegalBasis: string;
|
auditFormLegalBasis: string;
|
||||||
auditFormRetentionLabel: string;
|
auditFormRetentionLabel: string;
|
||||||
|
|
@ -402,8 +424,12 @@ export interface TextConfig {
|
||||||
};
|
};
|
||||||
teaser: {
|
teaser: {
|
||||||
title: string;
|
title: string;
|
||||||
text: string;
|
auditTitle: string;
|
||||||
ctaText: string;
|
auditText: string;
|
||||||
|
auditCtaText: string;
|
||||||
|
quickcheckTitle: string;
|
||||||
|
quickcheckText: string;
|
||||||
|
quickcheckCtaText: string;
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -138,8 +138,12 @@ export const audit = {
|
||||||
},
|
},
|
||||||
|
|
||||||
teaser: {
|
teaser: {
|
||||||
title: 'Want to see where you stand first?',
|
title: 'Free resources',
|
||||||
text: 'I review one page of your website against WCAG and send you a free short report – plus a 15 minute walkthrough. For businesses.',
|
auditTitle: 'Want to see where you stand first?',
|
||||||
ctaText: 'Go to the free audit',
|
auditText: 'In the free one-page audit, we look at one page of your choice together and discuss the most important next steps.',
|
||||||
|
auditCtaText: 'Go to the free audit',
|
||||||
|
quickcheckTitle: 'Accessibility quick check',
|
||||||
|
quickcheckText: 'Download a 10-point checklist and a template for your accessibility statement.',
|
||||||
|
quickcheckCtaText: 'Download the quick check',
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
|
||||||
|
|
@ -1,9 +1,11 @@
|
||||||
export const footer = {
|
export const footer = {
|
||||||
imprintText: 'Impressum und Datenschutz',
|
imprintText: 'Imprint',
|
||||||
|
privacyPolicyText: 'Privacy Policy',
|
||||||
copyrightText: 'All rights reserved.',
|
copyrightText: 'All rights reserved.',
|
||||||
technicalLinkText: 'Technical Portfolio',
|
technicalLinkText: 'Technical Portfolio',
|
||||||
landingLinkText: 'Landing Page',
|
landingLinkText: 'Landing Page',
|
||||||
auditLinkText: 'Free audit',
|
auditLinkText: 'Free audit',
|
||||||
|
quickcheckLinkText: 'Free quick check',
|
||||||
gitAriaLabel: 'Git Profile',
|
gitAriaLabel: 'Git Profile',
|
||||||
linkedinAriaLabel: 'LinkedIn Profile',
|
linkedinAriaLabel: 'LinkedIn Profile',
|
||||||
emailAriaLabel: 'Send Email',
|
emailAriaLabel: 'Send Email',
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,5 @@
|
||||||
export const imprint = {
|
export const imprint = {
|
||||||
title: 'Imprint and Privacy Policy',
|
title: 'Imprint',
|
||||||
subtitle: 'Legal Information',
|
subtitle: 'Legal Information',
|
||||||
companyInfoTitle: 'Information pursuant to § 5 DDG',
|
companyInfoTitle: 'Information pursuant to § 5 DDG',
|
||||||
umsatzsteuerID: 'Umsatzsteuer-ID: DE316934637',
|
umsatzsteuerID: 'Umsatzsteuer-ID: DE316934637',
|
||||||
|
|
@ -91,6 +91,25 @@ export const imprint = {
|
||||||
],
|
],
|
||||||
retentionText:
|
retentionText:
|
||||||
'However, if we (certain parts of) your data still need to be retained for other purposes, such as statutory retention periods (generally 6 years for business correspondence or 10 years for accounting records), the assertion, exercise, or defense of legal claims arising from contractual relationships (up to four years), or if the data is needed to protect the rights of another natural or legal person, we will only delete (the respective part of) your data after the expiry of these periods. Until these periods expire, we will restrict the processing of this data to these purposes (fulfillment of retention obligations).',
|
'However, if we (certain parts of) your data still need to be retained for other purposes, such as statutory retention periods (generally 6 years for business correspondence or 10 years for accounting records), the assertion, exercise, or defense of legal claims arising from contractual relationships (up to four years), or if the data is needed to protect the rights of another natural or legal person, we will only delete (the respective part of) your data after the expiry of these periods. Until these periods expire, we will restrict the processing of this data to these purposes (fulfillment of retention obligations).',
|
||||||
|
quickcheckFormTitle: 'The "Accessibility quick check" form',
|
||||||
|
quickcheckFormText:
|
||||||
|
'When you submit the quick check form, the data you enter is transmitted to our own server and forwarded from there to us by email. No external form provider is involved. The quick check is a self-test, not a complete audit or legal opinion.',
|
||||||
|
quickcheckFormDataTitle: 'Data processed:',
|
||||||
|
quickcheckFormDataList: [
|
||||||
|
'Name (optional)',
|
||||||
|
'Email address',
|
||||||
|
'Confirmation that you are asking as an entrepreneur, trader or freelancer (§ 14 BGB)',
|
||||||
|
'Your consent to processing the information',
|
||||||
|
],
|
||||||
|
quickcheckFormPurposeLabel: 'Purpose:',
|
||||||
|
quickcheckFormPurpose:
|
||||||
|
'Handling the quick check request and contacting you to provide the download.',
|
||||||
|
quickcheckFormLegalBasisLabel: 'Legal basis:',
|
||||||
|
quickcheckFormLegalBasis:
|
||||||
|
'Your consent under Art. 6(1)(a) GDPR, given when submitting the form. You may withdraw your consent at any time, informally.',
|
||||||
|
quickcheckFormRetentionLabel: 'Retention period:',
|
||||||
|
quickcheckFormRetention:
|
||||||
|
'We delete your details once the request has been dealt with conclusively and no statutory retention obligation applies - at the latest twelve months after the last contact.',
|
||||||
webhostingTitle: 'Web Hosting',
|
webhostingTitle: 'Web Hosting',
|
||||||
webhostingText:
|
webhostingText:
|
||||||
'We use a provider to host our websites, on whose servers our websites are stored and made available for retrieval on the internet (hosting). In doing so, the provider may process all data transmitted by you via the browser during use of our websites. This includes in particular your IP address, which the provider needs to deliver our online offering to the browser you are using, as well as all inputs made by you on our website. In addition, the provider we use may collect',
|
'We use a provider to host our websites, on whose servers our websites are stored and made available for retrieval on the internet (hosting). In doing so, the provider may process all data transmitted by you via the browser during use of our websites. This includes in particular your IP address, which the provider needs to deliver our online offering to the browser you are using, as well as all inputs made by you on our website. In addition, the provider we use may collect',
|
||||||
|
|
|
||||||
|
|
@ -5,6 +5,7 @@ export const navigation = {
|
||||||
{ section: 'Skills', label: 'Skills' },
|
{ section: 'Skills', label: 'Skills' },
|
||||||
{ section: 'Certifications', label: 'Certifications' },
|
{ section: 'Certifications', label: 'Certifications' },
|
||||||
{ section: 'Projects', label: 'Projects' },
|
{ section: 'Projects', label: 'Projects' },
|
||||||
|
{ section: 'Audit-Teaser', label: 'Free resources' },
|
||||||
{ section: 'Contact', label: 'Contact' },
|
{ section: 'Contact', label: 'Contact' },
|
||||||
],
|
],
|
||||||
landingMenuItems: [
|
landingMenuItems: [
|
||||||
|
|
@ -14,6 +15,7 @@ export const navigation = {
|
||||||
{ section: 'Processes', label: 'How I Work' },
|
{ section: 'Processes', label: 'How I Work' },
|
||||||
{ section: 'Projects', label: 'Projects' },
|
{ section: 'Projects', label: 'Projects' },
|
||||||
{ section: 'References', label: 'References' },
|
{ section: 'References', label: 'References' },
|
||||||
|
{ section: 'Audit-Teaser', label: 'Free resources' },
|
||||||
{ section: 'Contact', label: 'Contact' },
|
{ section: 'Contact', label: 'Contact' },
|
||||||
],
|
],
|
||||||
mobileMenuAriaLabel: 'Open menu',
|
mobileMenuAriaLabel: 'Open menu',
|
||||||
|
|
|
||||||
|
|
@ -3,7 +3,7 @@ export const privacyPolicy = {
|
||||||
lastUpdated: 'Last updated: November 2025',
|
lastUpdated: 'Last updated: November 2025',
|
||||||
introTitle: 'Overview',
|
introTitle: 'Overview',
|
||||||
introText:
|
introText:
|
||||||
'This website is a static portfolio website. No cookies, tracking or analytics tools are used, and no third-party services are embedded - the fonts are served from our own server as well. Personal data is only processed if you submit the form on the "Free accessibility audit to WCAG" page.',
|
'This website is a static portfolio website. No cookies, tracking or analytics tools are used, and no third-party services are embedded - the fonts are served from our own server as well. Personal data is processed if you submit the form on the "Free accessibility audit to WCAG" page or the free accessibility quick check.',
|
||||||
dataCollectionTitle: 'Data Collection',
|
dataCollectionTitle: 'Data Collection',
|
||||||
dataCollectionText:
|
dataCollectionText:
|
||||||
'Unless you act, this website collects no personal data. When you click on contact links (email, LinkedIn, Git), you will be redirected to external services that have their own privacy policies:',
|
'Unless you act, this website collects no personal data. When you click on contact links (email, LinkedIn, Git), you will be redirected to external services that have their own privacy policies:',
|
||||||
|
|
@ -28,6 +28,25 @@ export const privacyPolicy = {
|
||||||
auditFormPurposeLabel: 'Purpose:',
|
auditFormPurposeLabel: 'Purpose:',
|
||||||
auditFormPurpose:
|
auditFormPurpose:
|
||||||
'Handling your request, producing the audit report and contacting you to arrange the call.',
|
'Handling your request, producing the audit report and contacting you to arrange the call.',
|
||||||
|
quickcheckFormTitle: 'The "Accessibility quick check" form',
|
||||||
|
quickcheckFormText:
|
||||||
|
'When you submit the quick check form, the data you enter is transmitted to our own server and forwarded from there to us by email. No external form provider is involved. The quick check is a self-test, not a complete audit or legal opinion.',
|
||||||
|
quickcheckFormDataTitle: 'Data processed:',
|
||||||
|
quickcheckFormDataList: [
|
||||||
|
'Name (optional)',
|
||||||
|
'Email address',
|
||||||
|
'Confirmation that you are asking as an entrepreneur, trader or freelancer (§ 14 BGB)',
|
||||||
|
'Your consent to processing the information',
|
||||||
|
],
|
||||||
|
quickcheckFormPurposeLabel: 'Purpose:',
|
||||||
|
quickcheckFormPurpose:
|
||||||
|
'Handling the quick check request and contacting you to provide the download.',
|
||||||
|
quickcheckFormLegalBasisLabel: 'Legal basis:',
|
||||||
|
quickcheckFormLegalBasis:
|
||||||
|
'Your consent under Art. 6(1)(a) GDPR, given when submitting the form. You may withdraw your consent at any time, informally.',
|
||||||
|
quickcheckFormRetentionLabel: 'Retention period:',
|
||||||
|
quickcheckFormRetention:
|
||||||
|
'We delete your details once the request has been dealt with conclusively and no statutory retention obligation applies - at the latest twelve months after the last contact.',
|
||||||
auditFormLegalBasisLabel: 'Legal basis:',
|
auditFormLegalBasisLabel: 'Legal basis:',
|
||||||
auditFormLegalBasis:
|
auditFormLegalBasis:
|
||||||
'Art. 6(1)(b) GDPR (steps taken at your request prior to entering into a contract) together with your consent under Art. 6(1)(a) GDPR, which you give on submitting the form. You may withdraw that consent at any time, informally.',
|
'Art. 6(1)(b) GDPR (steps taken at your request prior to entering into a contract) together with your consent under Art. 6(1)(a) GDPR, which you give on submitting the form. You may withdraw that consent at any time, informally.',
|
||||||
|
|
|
||||||
|
|
@ -2,13 +2,16 @@ import portfolioImage from '@/assets/portfolio.PNG';
|
||||||
import dancaAlegriaImage from '@/assets/Danca-Alegria.png';
|
import dancaAlegriaImage from '@/assets/Danca-Alegria.png';
|
||||||
import lukasImage from '@/assets/lukas.png';
|
import lukasImage from '@/assets/lukas.png';
|
||||||
import a11yhubImage from '@/assets/a11yhub.png';
|
import a11yhubImage from '@/assets/a11yhub.png';
|
||||||
|
import arcumNovaImage from '@/assets/arcum-nova.png';
|
||||||
|
import gxplexImage from '@/assets/gxplex.png';
|
||||||
|
import aovmImage from '@/assets/aovm.png';
|
||||||
import placeholderImage from '@/assets/og-image.png';
|
import placeholderImage from '@/assets/og-image.png';
|
||||||
|
|
||||||
export const projects = {
|
export const projects = {
|
||||||
title: 'Featured Projects',
|
title: 'Featured Projects',
|
||||||
subtitle: 'A showcase of my work and personal projects',
|
subtitle: 'A showcase of my work and personal projects',
|
||||||
codeButtonText: 'Code',
|
codeButtonText: 'Code',
|
||||||
liveButtonText: 'Live',
|
liveButtonText: 'Open website',
|
||||||
projectItems: [
|
projectItems: [
|
||||||
{
|
{
|
||||||
id: 1,
|
id: 1,
|
||||||
|
|
@ -53,21 +56,33 @@ export const projects = {
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: 5,
|
id: 5,
|
||||||
title: 'Accessibility Audit for a Corporate Client',
|
title: 'arcum-nova.de',
|
||||||
description:
|
description:
|
||||||
'Full BITV 2.0 / EN 301 549 conformance audit (115 criteria) against WCAG 2.2 Level AA across 21 pages, delivering a prioritized findings report (Critical/High/Medium/Low) with concrete remediation guidance and a re-audit roadmap.',
|
'Implementation of WCAG 2.2 on an existing WordPress website using Avada / Fusion Builder for a mobile bicycle workshop in Havelland. The work included removing an unwanted site-wide image frame and making the contrast of active menu states conformant.',
|
||||||
image: placeholderImage,
|
image: arcumNovaImage,
|
||||||
technologies: ['WCAG 2.2', 'BITV 2.0', 'EN 301 549', 'axe DevTools', 'WAVE'],
|
technologies: ['WCAG 2.2', 'WordPress', 'Avada', 'Accessibility'],
|
||||||
year: '2026',
|
year: '2026',
|
||||||
|
live: 'https://arcum-nova.de',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 8,
|
||||||
|
title: 'aovm.de',
|
||||||
|
description:
|
||||||
|
'Ongoing technical maintenance and content updates for a WordPress website with a custom theme, following client requirements. The work included updates, backups, and content changes. The site uses WordPress; Flatsome is also documented in the project context. The project ended in August 2026.',
|
||||||
|
image: aovmImage,
|
||||||
|
technologies: ['WordPress', 'Maintenance', 'Content updates'],
|
||||||
|
year: '2026',
|
||||||
|
live: 'https://aovm.de',
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: 6,
|
id: 6,
|
||||||
title: 'Custom MediaWiki Platform (GxPlex)',
|
title: 'gxplex.org',
|
||||||
description:
|
description:
|
||||||
'Set up a tailored MediaWiki instance including installation, MySQL database, SSL, and automated backups; configured role-based permissions (Admin, Mod, Verified, User), a FlaggedRevisions editorial review workflow, and comment/rating extensions.',
|
'Built an English-language MediaWiki platform with Semantic MediaWiki, the Cavendish skin, and a rating function. The collaborative database brings together laws, regulations, and guidelines relevant to clinical research. It includes a document index, module overview, glossary, search, and membership with contribution functionality. More than 1,100 references are tagged by country, application area, document type, and topic.',
|
||||||
image: placeholderImage,
|
image: gxplexImage,
|
||||||
technologies: ['MediaWiki', 'MySQL', 'PHP', 'SSL'],
|
technologies: ['MediaWiki 1.43.8', 'Semantic MediaWiki', 'Cavendish', 'Rating function'],
|
||||||
year: '2026',
|
year: '2026',
|
||||||
|
live: 'https://gxplex.org',
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: 7,
|
id: 7,
|
||||||
|
|
|
||||||
|
|
@ -7,7 +7,7 @@ export const personalConfig = {
|
||||||
email: {
|
email: {
|
||||||
user: 'freelancer',
|
user: 'freelancer',
|
||||||
domain: 'sascha-bach.de',
|
domain: 'sascha-bach.de',
|
||||||
full: 'freelancer [at] sascha-bach.de',
|
full: 'freelancer@sascha-bach.de',
|
||||||
},
|
},
|
||||||
|
|
||||||
// Booking
|
// Booking
|
||||||
|
|
|
||||||
|
|
@ -3,6 +3,8 @@ import { en, type TextConfig } from '../config/locales/en';
|
||||||
import { de } from '../config/locales/de';
|
import { de } from '../config/locales/de';
|
||||||
import type { Language } from '../data/types';
|
import type { Language } from '../data/types';
|
||||||
|
|
||||||
|
const LANGUAGE_STORAGE_KEY = 'preferred-language';
|
||||||
|
|
||||||
interface LanguageContextType {
|
interface LanguageContextType {
|
||||||
language: Language;
|
language: Language;
|
||||||
setLanguage: (lang: Language) => void;
|
setLanguage: (lang: Language) => void;
|
||||||
|
|
@ -11,25 +13,44 @@ interface LanguageContextType {
|
||||||
|
|
||||||
const LanguageContext = createContext<LanguageContextType | undefined>(undefined);
|
const LanguageContext = createContext<LanguageContextType | undefined>(undefined);
|
||||||
|
|
||||||
/** Derive the language from the URL pathname prefix (/en/… or /de/…). */
|
function normalizeLanguage(value: string | null): Language | null {
|
||||||
export function getLanguageFromPath(pathname: string): Language | null {
|
if (value === 'de' || value === 'en') return value;
|
||||||
if (pathname.startsWith('/en')) return 'en';
|
|
||||||
if (pathname.startsWith('/de')) return 'de';
|
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function LanguageProvider({ initialLanguage = 'de', children }: Readonly<{ initialLanguage?: Language; children: ReactNode; }>) {
|
function getStoredLanguage(): Language | null {
|
||||||
const [language, setLanguage] = useState<Language>(initialLanguage);
|
try {
|
||||||
|
return normalizeLanguage(globalThis.localStorage?.getItem(LANGUAGE_STORAGE_KEY) ?? null);
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function detectBrowserLanguage(): Language {
|
||||||
|
const browserLanguage = globalThis.navigator?.language?.toLowerCase() ?? '';
|
||||||
|
return browserLanguage.startsWith('en') ? 'en' : 'de';
|
||||||
|
}
|
||||||
|
|
||||||
|
export function detectInitialLanguage(): Language {
|
||||||
|
return getStoredLanguage() ?? detectBrowserLanguage();
|
||||||
|
}
|
||||||
|
|
||||||
|
export function LanguageProvider({ initialLanguage, children }: Readonly<{ initialLanguage?: Language; children: ReactNode; }>) {
|
||||||
|
const [language, setLanguage] = useState<Language>(initialLanguage ?? detectInitialLanguage());
|
||||||
|
|
||||||
const texts = language === 'de' ? de : en;
|
const texts = language === 'de' ? de : en;
|
||||||
|
|
||||||
const handleSetLanguage = (lang: Language) => {
|
const handleSetLanguage = (lang: Language) => {
|
||||||
setLanguage(lang);
|
setLanguage(lang);
|
||||||
document.documentElement.lang = lang;
|
|
||||||
};
|
};
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
document.documentElement.lang = language;
|
document.documentElement.lang = language;
|
||||||
|
try {
|
||||||
|
globalThis.localStorage?.setItem(LANGUAGE_STORAGE_KEY, language);
|
||||||
|
} catch {
|
||||||
|
// Ignore storage errors (private mode, denied storage, etc.).
|
||||||
|
}
|
||||||
}, [language]);
|
}, [language]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
|
|
||||||
|
|
@ -8,7 +8,7 @@ import { PageSeo } from '../components/PageSeo';
|
||||||
import { useLanguage } from '../contexts/LanguageContext';
|
import { useLanguage } from '../contexts/LanguageContext';
|
||||||
|
|
||||||
export default function AuditPage() {
|
export default function AuditPage() {
|
||||||
const { language, texts } = useLanguage();
|
const { texts } = useLanguage();
|
||||||
const t = texts.audit;
|
const t = texts.audit;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
|
@ -16,7 +16,7 @@ export default function AuditPage() {
|
||||||
<PageSeo
|
<PageSeo
|
||||||
title={t.seoTitle}
|
title={t.seoTitle}
|
||||||
description={t.seoDescription}
|
description={t.seoDescription}
|
||||||
canonical={`/${language}/audit`}
|
canonical="/audit"
|
||||||
/>
|
/>
|
||||||
<AuditHeroSection />
|
<AuditHeroSection />
|
||||||
<AuditProcessSection />
|
<AuditProcessSection />
|
||||||
|
|
|
||||||
|
|
@ -4,6 +4,7 @@ import ServicesSection from '../components/sections/ServicesSection';
|
||||||
import SkillsSection from '../components/sections/SkillsSection';
|
import SkillsSection from '../components/sections/SkillsSection';
|
||||||
import CertificationsSection from '../components/sections/CertificationsSection';
|
import CertificationsSection from '../components/sections/CertificationsSection';
|
||||||
import ProjectsSection from '../components/sections/ProjectsSection';
|
import ProjectsSection from '../components/sections/ProjectsSection';
|
||||||
|
import AuditTeaserSection from '../components/landing/AuditTeaserSection';
|
||||||
import ContactSection from '../components/sections/ContactSection';
|
import ContactSection from '../components/sections/ContactSection';
|
||||||
import { PageSeo } from '../components/PageSeo';
|
import { PageSeo } from '../components/PageSeo';
|
||||||
import { useLanguage } from '../contexts/LanguageContext';
|
import { useLanguage } from '../contexts/LanguageContext';
|
||||||
|
|
@ -20,7 +21,7 @@ export default function HomePage() {
|
||||||
description={language === 'de'
|
description={language === 'de'
|
||||||
? 'Das technische Portfolio von Sascha Bach: Services, Fähigkeiten, Zertifizierungen und Projekte in React, TypeScript und barrierefreier Webentwicklung.'
|
? 'Das technische Portfolio von Sascha Bach: Services, Fähigkeiten, Zertifizierungen und Projekte in React, TypeScript und barrierefreier Webentwicklung.'
|
||||||
: 'Explore the technical portfolio of Sascha Bach: services, skills, certifications, and projects in React, TypeScript, and accessible web development.'}
|
: 'Explore the technical portfolio of Sascha Bach: services, skills, certifications, and projects in React, TypeScript, and accessible web development.'}
|
||||||
canonical={`/${language}/technical`}
|
canonical="/technical"
|
||||||
/>
|
/>
|
||||||
<HeroSection />
|
<HeroSection />
|
||||||
<AboutSection />
|
<AboutSection />
|
||||||
|
|
@ -28,6 +29,7 @@ export default function HomePage() {
|
||||||
<SkillsSection />
|
<SkillsSection />
|
||||||
<CertificationsSection />
|
<CertificationsSection />
|
||||||
<ProjectsSection />
|
<ProjectsSection />
|
||||||
|
<AuditTeaserSection />
|
||||||
<ContactSection />
|
<ContactSection />
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
|
|
|
||||||
|
|
@ -82,170 +82,6 @@ export default function ImprintPage() {
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="imprint-page__section">
|
|
||||||
<h2 className="imprint-page__section-title">{texts.privacyTitle}</h2>
|
|
||||||
<div className="imprint-page__info">
|
|
||||||
<p>{texts.privacyText}</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="imprint-page__section">
|
|
||||||
<h2 className="imprint-page__section-title">{texts.detailedPrivacyPolicy.title}</h2>
|
|
||||||
<div className="imprint-page__privacy-content">
|
|
||||||
|
|
||||||
{/* Introduction */}
|
|
||||||
<div className="imprint-page__privacy-section">
|
|
||||||
<p className="imprint-page__privacy-text">{texts.detailedPrivacyPolicy.introduction}</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Responsible for Data Processing */}
|
|
||||||
<div className="imprint-page__privacy-section">
|
|
||||||
<h3 className="imprint-page__privacy-subtitle">{texts.detailedPrivacyPolicy.responsibleTitle}</h3>
|
|
||||||
<p className="imprint-page__privacy-text">{texts.detailedPrivacyPolicy.responsibleText}</p>
|
|
||||||
<div className="imprint-page__contact-info">
|
|
||||||
{texts.detailedPrivacyPolicy.responsibleContact.split('\n').map((line: string) => (
|
|
||||||
<p key={line.trim() || 'empty-line'} className="imprint-page__contact-line">{line}</p>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Data Protection Officer */}
|
|
||||||
<div className="imprint-page__privacy-section">
|
|
||||||
<h3 className="imprint-page__privacy-subtitle">{texts.detailedPrivacyPolicy.dataProtectionOfficerTitle}</h3>
|
|
||||||
<p className="imprint-page__privacy-text">{texts.detailedPrivacyPolicy.dataProtectionOfficerText}</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Rights under GDPR */}
|
|
||||||
<div className="imprint-page__privacy-section">
|
|
||||||
<h3 className="imprint-page__privacy-subtitle">{texts.detailedPrivacyPolicy.rightsTitle}</h3>
|
|
||||||
<p className="imprint-page__privacy-text">{texts.detailedPrivacyPolicy.rightsIntro}</p>
|
|
||||||
<ul className="imprint-page__rights-list">
|
|
||||||
{texts.detailedPrivacyPolicy.rights.map((right: { title: string; text: string; }) => (
|
|
||||||
<li key={right.title} className="imprint-page__rights-item">
|
|
||||||
<strong>{right.title}:</strong> {right.text}
|
|
||||||
</li>
|
|
||||||
))}
|
|
||||||
</ul>
|
|
||||||
|
|
||||||
<h4 className="imprint-page__privacy-subsubtitle">{texts.detailedPrivacyPolicy.revocationTitle}</h4>
|
|
||||||
<p className="imprint-page__privacy-text">{texts.detailedPrivacyPolicy.revocationText}</p>
|
|
||||||
|
|
||||||
<h4 className="imprint-page__privacy-subsubtitle">{texts.detailedPrivacyPolicy.objectionTitle}</h4>
|
|
||||||
<p className="imprint-page__privacy-text">{texts.detailedPrivacyPolicy.objectionText}</p>
|
|
||||||
<p className="imprint-page__privacy-highlight">{texts.detailedPrivacyPolicy.objectionHighlight}</p>
|
|
||||||
<p className="imprint-page__privacy-text">{texts.detailedPrivacyPolicy.objectionContact}</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Data Deletion */}
|
|
||||||
<div className="imprint-page__privacy-section">
|
|
||||||
<h3 className="imprint-page__privacy-subtitle">{texts.detailedPrivacyPolicy.dataDeletionTitle}</h3>
|
|
||||||
<p className="imprint-page__privacy-text">{texts.detailedPrivacyPolicy.dataDeletionIntro}</p>
|
|
||||||
<ul className="imprint-page__deletion-list">
|
|
||||||
{texts.detailedPrivacyPolicy.dataDeletionReasons.map((reason: string) => (
|
|
||||||
<li key={reason.substring(0, 50)} className="imprint-page__deletion-item">{reason}</li>
|
|
||||||
))}
|
|
||||||
</ul>
|
|
||||||
<p className="imprint-page__privacy-text">{texts.detailedPrivacyPolicy.retentionText}</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Webhosting */}
|
|
||||||
<div className="imprint-page__privacy-section">
|
|
||||||
<h3 className="imprint-page__privacy-subtitle">{texts.detailedPrivacyPolicy.webhostingTitle}</h3>
|
|
||||||
<p className="imprint-page__privacy-text">
|
|
||||||
{texts.detailedPrivacyPolicy.webhostingText}
|
|
||||||
</p>
|
|
||||||
<ul className="imprint-page__webhosting-list">
|
|
||||||
{texts.detailedPrivacyPolicy.webhostingDataTypes.map((dataType: string) => (
|
|
||||||
<li key={dataType} className="imprint-page__webhosting-item">{dataType}</li>
|
|
||||||
))}
|
|
||||||
</ul>
|
|
||||||
<p className="imprint-page__privacy-text">{texts.detailedPrivacyPolicy.webhostingPurpose}</p>
|
|
||||||
|
|
||||||
<div className="imprint-page__data-categories">
|
|
||||||
<p className="imprint-page__category-title">
|
|
||||||
<strong>{texts.detailedPrivacyPolicy.webhostingDataCategories.affectedData}</strong>
|
|
||||||
</p>
|
|
||||||
<ul className="imprint-page__category-list">
|
|
||||||
{texts.detailedPrivacyPolicy.webhostingDataCategories.affectedDataList.map((item: string) => (
|
|
||||||
<li key={item}>{item}</li>
|
|
||||||
))}
|
|
||||||
</ul>
|
|
||||||
<p><strong>{texts.detailedPrivacyPolicy.webhostingDataCategories.affectedPersons}</strong></p>
|
|
||||||
<p><strong>{texts.detailedPrivacyPolicy.webhostingDataCategories.processingPurpose}</strong></p>
|
|
||||||
<p><strong>{texts.detailedPrivacyPolicy.webhostingDataCategories.legalBasis}</strong></p>
|
|
||||||
<p><strong>{texts.detailedPrivacyPolicy.webhostingDataCategories.provider}</strong></p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="imprint-page__hosting-provider">
|
|
||||||
<p><strong>{texts.detailedPrivacyPolicy.hostingProvider.name}</strong></p>
|
|
||||||
<p>{texts.detailedPrivacyPolicy.hostingProvider.address}</p>
|
|
||||||
<p>
|
|
||||||
{texts.detailedPrivacyPolicy.hostingProvider.privacyPolicyLabel}{' '}
|
|
||||||
<a
|
|
||||||
href={texts.detailedPrivacyPolicy.hostingProvider.website}
|
|
||||||
target="_blank"
|
|
||||||
rel="noopener noreferrer"
|
|
||||||
className="imprint-page__external-link"
|
|
||||||
>
|
|
||||||
{texts.detailedPrivacyPolicy.hostingProvider.website}
|
|
||||||
</a>
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Contact */}
|
|
||||||
<div className="imprint-page__privacy-section">
|
|
||||||
<h3 className="imprint-page__privacy-subtitle">{texts.detailedPrivacyPolicy.contactTitle}</h3>
|
|
||||||
<p className="imprint-page__privacy-text">{texts.detailedPrivacyPolicy.contactText}</p>
|
|
||||||
|
|
||||||
<div className="imprint-page__data-categories">
|
|
||||||
<p className="imprint-page__category-title"><strong>{texts.detailedPrivacyPolicy.contactDataLabels.affectedData}</strong></p>
|
|
||||||
<ul className="imprint-page__category-list">
|
|
||||||
{texts.detailedPrivacyPolicy.contactDataCategories.affectedData.map((item: string) => (
|
|
||||||
<li key={item}>{item}</li>
|
|
||||||
))}
|
|
||||||
</ul>
|
|
||||||
<p><strong>{texts.detailedPrivacyPolicy.contactDataLabels.affectedPersons} </strong>{texts.detailedPrivacyPolicy.contactDataCategories.affectedPersons}</p>
|
|
||||||
<p><strong>{texts.detailedPrivacyPolicy.contactDataLabels.processingPurpose} </strong>{texts.detailedPrivacyPolicy.contactDataCategories.processingPurpose}</p>
|
|
||||||
<p><strong>{texts.detailedPrivacyPolicy.contactDataLabels.legalBasis} </strong>{texts.detailedPrivacyPolicy.contactDataCategories.legalBasis}</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Online Appointment Booking (Nextcloud) */}
|
|
||||||
<div className="imprint-page__privacy-section">
|
|
||||||
<h3 className="imprint-page__privacy-subtitle">{texts.detailedPrivacyPolicy.onlineAppointmentTitle}</h3>
|
|
||||||
<p className="imprint-page__privacy-text">{texts.detailedPrivacyPolicy.onlineAppointmentIntro}</p>
|
|
||||||
<p className="imprint-page__privacy-text">{texts.detailedPrivacyPolicy.onlineAppointmentProvider}</p>
|
|
||||||
<p className="imprint-page__privacy-text">{texts.detailedPrivacyPolicy.onlineAppointmentDataTitle}</p>
|
|
||||||
<ul className="imprint-page__category-list">
|
|
||||||
{texts.detailedPrivacyPolicy.onlineAppointmentData.map((item: string) => (
|
|
||||||
<li key={item}>{item}</li>
|
|
||||||
))}
|
|
||||||
</ul>
|
|
||||||
<p className="imprint-page__privacy-text">{texts.detailedPrivacyPolicy.onlineAppointmentPurpose}</p>
|
|
||||||
<p className="imprint-page__privacy-text">{texts.detailedPrivacyPolicy.onlineAppointmentLegalBasis}</p>
|
|
||||||
<p className="imprint-page__privacy-text">{texts.detailedPrivacyPolicy.onlineAppointmentRetention}</p>
|
|
||||||
<p className="imprint-page__privacy-text">{texts.detailedPrivacyPolicy.onlineAppointmentThirdParty}</p>
|
|
||||||
<p className="imprint-page__privacy-text">{texts.detailedPrivacyPolicy.onlineAppointmentServerLocation}</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Security Measures */}
|
|
||||||
<div className="imprint-page__privacy-section">
|
|
||||||
<h3 className="imprint-page__privacy-subtitle">{texts.detailedPrivacyPolicy.securityTitle}</h3>
|
|
||||||
<p className="imprint-page__privacy-text">{texts.detailedPrivacyPolicy.securityText}</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Changes to Privacy Policy */}
|
|
||||||
<div className="imprint-page__privacy-section">
|
|
||||||
<h3 className="imprint-page__privacy-subtitle">{texts.detailedPrivacyPolicy.changesTitle}</h3>
|
|
||||||
<p className="imprint-page__privacy-text">{texts.detailedPrivacyPolicy.changesText}</p>
|
|
||||||
<p className="imprint-page__disclaimer">
|
|
||||||
<strong>{texts.detailedPrivacyPolicy.disclaimer}</strong>
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|
|
||||||
|
|
@ -24,7 +24,7 @@ export default function LandingPage() {
|
||||||
description={language === 'de'
|
description={language === 'de'
|
||||||
? 'Freelance Softwareentwickler in Deutschland spezialisiert auf barrierefreie Webentwicklung. Ich baue inklusive Websites konform zum BFSG mit React, TypeScript und modernen Frameworks.'
|
? 'Freelance Softwareentwickler in Deutschland spezialisiert auf barrierefreie Webentwicklung. Ich baue inklusive Websites konform zum BFSG mit React, TypeScript und modernen Frameworks.'
|
||||||
: 'Freelance software developer in Germany specializing in accessible web development. Building inclusive websites compliant with the Accessibility Act.'}
|
: 'Freelance software developer in Germany specializing in accessible web development. Building inclusive websites compliant with the Accessibility Act.'}
|
||||||
canonical={`/${language}/`}
|
canonical="/"
|
||||||
/>
|
/>
|
||||||
<HeroSection
|
<HeroSection
|
||||||
title={t.title}
|
title={t.title}
|
||||||
|
|
|
||||||
|
|
@ -3,65 +3,143 @@ import { PageSeo } from '../components/PageSeo';
|
||||||
|
|
||||||
export default function PrivacyPolicy() {
|
export default function PrivacyPolicy() {
|
||||||
const { texts: allTexts } = useLanguage();
|
const { texts: allTexts } = useLanguage();
|
||||||
const texts = allTexts.privacyPolicy;
|
const texts = allTexts.imprint;
|
||||||
|
const privacy = texts.detailedPrivacyPolicy;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="privacy-policy" style={{ maxWidth: '800px', margin: '0 auto', padding: '2rem' }}>
|
<section className="imprint-page">
|
||||||
<PageSeo
|
<PageSeo
|
||||||
title="Privacy Policy | Sascha Bach"
|
title="Privacy Policy | Sascha Bach"
|
||||||
description="Privacy policy for the website of Sascha Bach, freelance software developer."
|
description="Privacy policy for the website of Sascha Bach, freelance software developer."
|
||||||
canonical="/privacy-policy"
|
canonical="/privacy-policy"
|
||||||
noIndex
|
noIndex
|
||||||
/>
|
/>
|
||||||
<h1>{texts.title}</h1>
|
<div className="imprint-page__container">
|
||||||
<p><strong>{texts.lastUpdated}</strong></p>
|
<div className="imprint-page__header">
|
||||||
|
<h1 className="imprint-page__title">{privacy.title}</h1>
|
||||||
|
</div>
|
||||||
|
|
||||||
<h2>{texts.introTitle}</h2>
|
<div className="imprint-page__content">
|
||||||
<p>{texts.introText}</p>
|
<div className="imprint-page__section">
|
||||||
|
<div className="imprint-page__privacy-content">
|
||||||
|
<p className="imprint-page__privacy-text">{privacy.introduction}</p>
|
||||||
|
|
||||||
<h2>{texts.dataCollectionTitle}</h2>
|
<div className="imprint-page__privacy-section">
|
||||||
<p>{texts.dataCollectionText}</p>
|
<h2 className="imprint-page__privacy-subtitle">{privacy.responsibleTitle}</h2>
|
||||||
<ul>
|
<p className="imprint-page__privacy-text">{privacy.responsibleText}</p>
|
||||||
{texts.dataCollectionList.map((item, index) => (
|
<div className="imprint-page__contact-info">
|
||||||
<li key={index}>{item}</li>
|
{privacy.responsibleContact.split('\n').map((line) => (
|
||||||
|
<p key={line} className="imprint-page__contact-line">{line}</p>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="imprint-page__privacy-section">
|
||||||
|
<h2 className="imprint-page__privacy-subtitle">{privacy.dataProtectionOfficerTitle}</h2>
|
||||||
|
<p className="imprint-page__privacy-text">{privacy.dataProtectionOfficerText}</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="imprint-page__privacy-section">
|
||||||
|
<h2 className="imprint-page__privacy-subtitle">{privacy.rightsTitle}</h2>
|
||||||
|
<p className="imprint-page__privacy-text">{privacy.rightsIntro}</p>
|
||||||
|
<ul className="imprint-page__rights-list">
|
||||||
|
{privacy.rights.map((right) => (
|
||||||
|
<li key={right.title} className="imprint-page__rights-item">
|
||||||
|
<strong>{right.title}:</strong> {right.text}
|
||||||
|
</li>
|
||||||
))}
|
))}
|
||||||
</ul>
|
</ul>
|
||||||
|
<h3 className="imprint-page__privacy-subsubtitle">{privacy.revocationTitle}</h3>
|
||||||
|
<p className="imprint-page__privacy-text">{privacy.revocationText}</p>
|
||||||
|
<h3 className="imprint-page__privacy-subsubtitle">{privacy.objectionTitle}</h3>
|
||||||
|
<p className="imprint-page__privacy-text">{privacy.objectionText}</p>
|
||||||
|
<p className="imprint-page__privacy-highlight">{privacy.objectionHighlight}</p>
|
||||||
|
<p className="imprint-page__privacy-text">{privacy.objectionContact}</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
<h2>{texts.auditFormTitle}</h2>
|
<div className="imprint-page__privacy-section">
|
||||||
<p>{texts.auditFormText}</p>
|
<h2 className="imprint-page__privacy-subtitle">{privacy.dataDeletionTitle}</h2>
|
||||||
<p><strong>{texts.auditFormDataTitle}</strong></p>
|
<p className="imprint-page__privacy-text">{privacy.dataDeletionIntro}</p>
|
||||||
<ul>
|
<ul className="imprint-page__deletion-list">
|
||||||
{texts.auditFormDataList.map((item) => (
|
{privacy.dataDeletionReasons.map((reason) => <li key={reason}>{reason}</li>)}
|
||||||
<li key={item}>{item}</li>
|
|
||||||
))}
|
|
||||||
</ul>
|
</ul>
|
||||||
<p><strong>{texts.auditFormPurposeLabel}</strong> {texts.auditFormPurpose}</p>
|
<p className="imprint-page__privacy-text">{privacy.retentionText}</p>
|
||||||
<p><strong>{texts.auditFormLegalBasisLabel}</strong> {texts.auditFormLegalBasis}</p>
|
</div>
|
||||||
<p><strong>{texts.auditFormRetentionLabel}</strong> {texts.auditFormRetention}</p>
|
|
||||||
<p><strong>{texts.auditFormSecurityLabel}</strong> {texts.auditFormSecurity}</p>
|
|
||||||
|
|
||||||
<h2>{texts.webhostingTitle}</h2>
|
<div className="imprint-page__privacy-section">
|
||||||
<p>{texts.webhostingText}</p>
|
<h2 className="imprint-page__privacy-subtitle">{privacy.webhostingTitle}</h2>
|
||||||
|
<p className="imprint-page__privacy-text">{privacy.webhostingText}</p>
|
||||||
|
<ul className="imprint-page__webhosting-list">
|
||||||
|
{privacy.webhostingDataTypes.map((item) => <li key={item}>{item}</li>)}
|
||||||
|
</ul>
|
||||||
|
<p className="imprint-page__privacy-text">{privacy.webhostingPurpose}</p>
|
||||||
|
<div className="imprint-page__hosting-provider">
|
||||||
|
<p><strong>{privacy.hostingProvider.name}</strong></p>
|
||||||
|
<p>{privacy.hostingProvider.address}</p>
|
||||||
<p>
|
<p>
|
||||||
<strong>{texts.webhostingProvider}</strong><br />
|
{privacy.hostingProvider.privacyPolicyLabel}{' '}
|
||||||
{texts.webhostingProviderName}<br />
|
<a href={privacy.hostingProvider.website} target="_blank" rel="noopener noreferrer">
|
||||||
{texts.webhostingProviderAddress}<br />
|
{privacy.hostingProvider.website}
|
||||||
<a href={texts.webhostingProviderWebsite} target="_blank" rel="noopener noreferrer">
|
|
||||||
{texts.webhostingProviderWebsite}
|
|
||||||
</a>
|
</a>
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
<h2>{texts.rightsTitle}</h2>
|
|
||||||
<p>{texts.rightsText}</p>
|
|
||||||
<ul>
|
|
||||||
{texts.rightsList.map((right, index) => (
|
|
||||||
<li key={index}>{right}</li>
|
|
||||||
))}
|
|
||||||
</ul>
|
|
||||||
|
|
||||||
<h2>{texts.contactTitle}</h2>
|
|
||||||
<p>{texts.contactText}</p>
|
|
||||||
<p><strong>Email:</strong> {texts.contactEmail}</p>
|
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="imprint-page__privacy-section">
|
||||||
|
<h2 className="imprint-page__privacy-subtitle">{privacy.contactTitle}</h2>
|
||||||
|
<p className="imprint-page__privacy-text">{privacy.contactText}</p>
|
||||||
|
<div className="imprint-page__data-categories">
|
||||||
|
<p><strong>{privacy.contactDataLabels.affectedData}</strong></p>
|
||||||
|
<ul className="imprint-page__category-list">
|
||||||
|
{privacy.contactDataCategories.affectedData.map((item) => <li key={item}>{item}</li>)}
|
||||||
|
</ul>
|
||||||
|
<p><strong>{privacy.contactDataLabels.affectedPersons} </strong>{privacy.contactDataCategories.affectedPersons}</p>
|
||||||
|
<p><strong>{privacy.contactDataLabels.processingPurpose} </strong>{privacy.contactDataCategories.processingPurpose}</p>
|
||||||
|
<p><strong>{privacy.contactDataLabels.legalBasis} </strong>{privacy.contactDataCategories.legalBasis}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="imprint-page__privacy-section">
|
||||||
|
<h2 className="imprint-page__privacy-subtitle">{privacy.quickcheckFormTitle}</h2>
|
||||||
|
<p className="imprint-page__privacy-text">{privacy.quickcheckFormText}</p>
|
||||||
|
<p className="imprint-page__privacy-text"><strong>{privacy.quickcheckFormDataTitle}</strong></p>
|
||||||
|
<ul className="imprint-page__category-list">
|
||||||
|
{privacy.quickcheckFormDataList.map((item) => <li key={item}>{item}</li>)}
|
||||||
|
</ul>
|
||||||
|
<p className="imprint-page__privacy-text"><strong>{privacy.quickcheckFormPurposeLabel}</strong> {privacy.quickcheckFormPurpose}</p>
|
||||||
|
<p className="imprint-page__privacy-text"><strong>{privacy.quickcheckFormLegalBasisLabel}</strong> {privacy.quickcheckFormLegalBasis}</p>
|
||||||
|
<p className="imprint-page__privacy-text"><strong>{privacy.quickcheckFormRetentionLabel}</strong> {privacy.quickcheckFormRetention}</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="imprint-page__privacy-section">
|
||||||
|
<h2 className="imprint-page__privacy-subtitle">{privacy.onlineAppointmentTitle}</h2>
|
||||||
|
<p className="imprint-page__privacy-text">{privacy.onlineAppointmentIntro}</p>
|
||||||
|
<p className="imprint-page__privacy-text">{privacy.onlineAppointmentProvider}</p>
|
||||||
|
<p className="imprint-page__privacy-text">{privacy.onlineAppointmentDataTitle}</p>
|
||||||
|
<ul className="imprint-page__category-list">
|
||||||
|
{privacy.onlineAppointmentData.map((item) => <li key={item}>{item}</li>)}
|
||||||
|
</ul>
|
||||||
|
<p className="imprint-page__privacy-text">{privacy.onlineAppointmentPurpose}</p>
|
||||||
|
<p className="imprint-page__privacy-text">{privacy.onlineAppointmentLegalBasis}</p>
|
||||||
|
<p className="imprint-page__privacy-text">{privacy.onlineAppointmentRetention}</p>
|
||||||
|
<p className="imprint-page__privacy-text">{privacy.onlineAppointmentThirdParty}</p>
|
||||||
|
<p className="imprint-page__privacy-text">{privacy.onlineAppointmentServerLocation}</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="imprint-page__privacy-section">
|
||||||
|
<h2 className="imprint-page__privacy-subtitle">{privacy.securityTitle}</h2>
|
||||||
|
<p className="imprint-page__privacy-text">{privacy.securityText}</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="imprint-page__privacy-section">
|
||||||
|
<h2 className="imprint-page__privacy-subtitle">{privacy.changesTitle}</h2>
|
||||||
|
<p className="imprint-page__privacy-text">{privacy.changesText}</p>
|
||||||
|
<p className="imprint-page__disclaimer"><strong>{privacy.disclaimer}</strong></p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
@ -0,0 +1,203 @@
|
||||||
|
import { useId, useState, type FormEvent } from 'react';
|
||||||
|
import { PageSeo } from '../components/PageSeo';
|
||||||
|
|
||||||
|
function QuickcheckForm() {
|
||||||
|
const [status, setStatus] = useState<'idle' | 'submitting' | 'success' | 'error'>('idle');
|
||||||
|
const [errorMessage, setErrorMessage] = useState('');
|
||||||
|
const fieldPrefix = useId();
|
||||||
|
|
||||||
|
async function handleSubmit(event: FormEvent<HTMLFormElement>) {
|
||||||
|
event.preventDefault();
|
||||||
|
const form = event.currentTarget;
|
||||||
|
const formData = new FormData(form);
|
||||||
|
const email = formData.get('email');
|
||||||
|
const name = formData.get('name');
|
||||||
|
const emailField = form.elements.namedItem('email');
|
||||||
|
|
||||||
|
if (
|
||||||
|
typeof email !== 'string' ||
|
||||||
|
!email.trim() ||
|
||||||
|
!(emailField instanceof HTMLInputElement) ||
|
||||||
|
!emailField.checkValidity() ||
|
||||||
|
formData.get('businessConfirmation') !== 'true' ||
|
||||||
|
formData.get('gdprConsent') !== 'true'
|
||||||
|
) {
|
||||||
|
setErrorMessage('Bitte bestätige die Zielgruppe und die Verarbeitung deiner Daten.');
|
||||||
|
setStatus('error');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const endpoint = import.meta.env.VITE_AUDIT_ENDPOINT;
|
||||||
|
if (!endpoint) {
|
||||||
|
setErrorMessage('Der Versand ist derzeit nicht konfiguriert.');
|
||||||
|
setStatus('error');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setStatus('submitting');
|
||||||
|
setErrorMessage('');
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await fetch(endpoint, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({
|
||||||
|
formType: 'quickcheck',
|
||||||
|
name: typeof name === 'string' ? name.trim() : '',
|
||||||
|
email: email.trim(),
|
||||||
|
businessConfirmation: 'true',
|
||||||
|
gdprConsent: 'true',
|
||||||
|
website: '',
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error('Quickcheck request failed');
|
||||||
|
}
|
||||||
|
|
||||||
|
form.reset();
|
||||||
|
setStatus('success');
|
||||||
|
} catch {
|
||||||
|
setErrorMessage('Die Anfrage konnte nicht gesendet werden. Bitte versuche es später erneut.');
|
||||||
|
setStatus('error');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<form className="quickcheck-page__form" onSubmit={handleSubmit} noValidate>
|
||||||
|
<div className="quickcheck-page__field">
|
||||||
|
<label htmlFor={`${fieldPrefix}-name`}>Dein Name (optional)</label>
|
||||||
|
<input id={`${fieldPrefix}-name`} name="name" type="text" autoComplete="name" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="quickcheck-page__field">
|
||||||
|
<label htmlFor={`${fieldPrefix}-email`}>E-Mail-Adresse <span aria-hidden="true">*</span></label>
|
||||||
|
<input
|
||||||
|
id={`${fieldPrefix}-email`}
|
||||||
|
name="email"
|
||||||
|
type="email"
|
||||||
|
autoComplete="email"
|
||||||
|
required
|
||||||
|
aria-describedby={`${fieldPrefix}-email-hint`}
|
||||||
|
/>
|
||||||
|
<p id={`${fieldPrefix}-email-hint`} className="quickcheck-page__hint">
|
||||||
|
Du erhältst den Download-Link per E-Mail.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<label className="quickcheck-page__checkbox-label">
|
||||||
|
<input name="businessConfirmation" value="true" type="checkbox" required />
|
||||||
|
<span>Ich bestätige, dass ich als Unternehmer, Gewerbetreibender oder Freiberufler anfrage.</span>
|
||||||
|
</label>
|
||||||
|
|
||||||
|
<label className="quickcheck-page__checkbox-label">
|
||||||
|
<input name="gdprConsent" value="true" type="checkbox" required />
|
||||||
|
<span>Ich willige ein, dass meine Angaben zur Bearbeitung der Anfrage verarbeitet werden. <a href="/privacy-policy">Datenschutzerklärung</a></span>
|
||||||
|
</label>
|
||||||
|
|
||||||
|
<button className="quickcheck-page__submit" type="submit" disabled={status === 'submitting'}>
|
||||||
|
Jetzt kostenlos herunterladen
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<div className="quickcheck-page__status" role="status" aria-live="polite" aria-atomic="true">
|
||||||
|
{status === 'submitting' && <p>Deine Anfrage wird gesendet.</p>}
|
||||||
|
{status === 'success' && (
|
||||||
|
<p>
|
||||||
|
Deine Anfrage wurde übermittelt. Du erhältst den Download-Link per E-Mail.
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
{status === 'error' && <p role="alert">{errorMessage}</p>}
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function QuickcheckPage() {
|
||||||
|
return (
|
||||||
|
<article className="quickcheck-page">
|
||||||
|
<PageSeo
|
||||||
|
title="Barrierefreiheits-Schnellcheck: 10 Punkte | Sascha Bach"
|
||||||
|
description="Kostenloser Barrierefreiheits-Schnellcheck mit 10 Punkten nach WCAG 2.2 und Vorlage für deine Erklärung zur Barrierefreiheit."
|
||||||
|
canonical="/schnellcheck"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<header className="quickcheck-page__hero">
|
||||||
|
<div className="quickcheck-page__container">
|
||||||
|
<div className="quickcheck-page__hero-copy">
|
||||||
|
<p className="quickcheck-page__kicker">Kostenloser Download</p>
|
||||||
|
<h1>Barrierefreiheits-Schnellcheck: 10 Punkte, die über BFSG-Konformität entscheiden</h1>
|
||||||
|
<p className="quickcheck-page__subline">
|
||||||
|
Eine Checkliste plus Vorlage für die Barrierefreiheitserklärung – für alle, die wissen wollen, wo sie stehen, bevor sie investieren.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<section className="quickcheck-page__section" aria-labelledby="quickcheck-includes-title">
|
||||||
|
<div className="quickcheck-page__container">
|
||||||
|
<h2 id="quickcheck-includes-title">Das ist enthalten</h2>
|
||||||
|
<div className="quickcheck-page__features">
|
||||||
|
<article className="quickcheck-page__feature">
|
||||||
|
<h3>10-Punkte-Checkliste</h3>
|
||||||
|
<p>Nach WCAG 2.2, in Klartext statt Normjargon.</p>
|
||||||
|
</article>
|
||||||
|
<article className="quickcheck-page__feature">
|
||||||
|
<h3>Kostenlose Test-Tools</h3>
|
||||||
|
<p>Anleitungen für axe, WAVE, Kontrast-Checker und Tastatur-Test.</p>
|
||||||
|
</article>
|
||||||
|
<article className="quickcheck-page__feature">
|
||||||
|
<h3>Vorlage für deine Erklärung</h3>
|
||||||
|
<p>Eine Word-Vorlage mit allen Pflichtangaben nach BFSG.</p>
|
||||||
|
</article>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section className="quickcheck-page__section quickcheck-page__section--soft" aria-labelledby="quickcheck-limits-title">
|
||||||
|
<div className="quickcheck-page__container quickcheck-page__limits">
|
||||||
|
<h2 id="quickcheck-limits-title">Was der Schnellcheck nicht ist</h2>
|
||||||
|
<p className="quickcheck-page__limits-intro">Damit du die Ergebnisse richtig einordnen kannst:</p>
|
||||||
|
<ul className="quickcheck-page__limits-list">
|
||||||
|
<li>Der Schnellcheck ist ein Selbsttest. Er ersetzt keine Prüfung durch eine Fachperson.</li>
|
||||||
|
<li>Er betrachtet einzelne Prüfpunkte und ersetzt keine vollständige Prüfung der gesamten Website.</li>
|
||||||
|
<li>Er ist kein Rechtsgutachten und keine verbindliche Aussage zur BFSG-Konformität.</li>
|
||||||
|
<li>Er ist keine Umsetzung. Die gefundenen Punkte musst du selbst, mit deiner Agentur oder mit einer beauftragten Fachperson bearbeiten.</li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section className="quickcheck-page__section quickcheck-page__section--soft" aria-labelledby="quickcheck-audience-title">
|
||||||
|
<div className="quickcheck-page__container">
|
||||||
|
<h2 id="quickcheck-audience-title">Für wen?</h2>
|
||||||
|
<p>
|
||||||
|
Für Website-Verantwortliche im Mittelstand, die den aktuellen Stand ihrer Website besser einschätzen und die nächsten Schritte sachlich priorisieren möchten.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section className="quickcheck-page__section" aria-labelledby="quickcheck-audit-title">
|
||||||
|
<div className="quickcheck-page__container">
|
||||||
|
<div className="quickcheck-page__notice">
|
||||||
|
<h2 id="quickcheck-audit-title">Mehrere Haken offen?</h2>
|
||||||
|
<p>Im kostenlosen One-Page Audit schauen wir uns eine Seite deiner Wahl gemeinsam an.</p>
|
||||||
|
<a className="quickcheck-page__text-link" href="/audit">Zum kostenlosen One-Page Audit</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<p className="quickcheck-page__b2b-notice">
|
||||||
|
Dieses Angebot richtet sich ausschließlich an Unternehmer, Gewerbetreibende und Freiberufler im Sinne des § 14 BGB. Kein Verkauf/Abgabe an Verbraucher im Sinne des § 13 BGB.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<section className="quickcheck-page__section quickcheck-page__download" aria-labelledby="quickcheck-repeat-title">
|
||||||
|
<div className="quickcheck-page__container quickcheck-page__download-grid">
|
||||||
|
<div>
|
||||||
|
<h2 id="quickcheck-repeat-title">Schnellcheck herunterladen</h2>
|
||||||
|
<p>Trage deine E-Mail-Adresse ein. Der echte Versand wird nach Anschluss des Formular-Endpoints aktiviert.</p>
|
||||||
|
</div>
|
||||||
|
<QuickcheckForm />
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
</article>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -60,7 +60,8 @@
|
||||||
font-size: 0.875rem;
|
font-size: 0.875rem;
|
||||||
text-align: center;
|
text-align: center;
|
||||||
margin: 0;
|
margin: 0;
|
||||||
opacity: 0.7;
|
flex-basis: 100%;
|
||||||
|
order: 99;
|
||||||
}
|
}
|
||||||
|
|
||||||
&__social {
|
&__social {
|
||||||
|
|
|
||||||
|
|
@ -11,5 +11,6 @@
|
||||||
@forward 'certifications-section';
|
@forward 'certifications-section';
|
||||||
@forward 'projects-section';
|
@forward 'projects-section';
|
||||||
@forward 'contact-section';
|
@forward 'contact-section';
|
||||||
|
@forward 'quickcheck-page';
|
||||||
@forward 'imprint-page';
|
@forward 'imprint-page';
|
||||||
@forward 'audit-page';
|
@forward 'audit-page';
|
||||||
|
|
|
||||||
|
|
@ -104,7 +104,12 @@
|
||||||
}
|
}
|
||||||
|
|
||||||
&__cta {
|
&__cta {
|
||||||
display: inline-block;
|
display: inline-flex;
|
||||||
|
width: 100%;
|
||||||
|
min-height: 3.5rem;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
margin-top: auto;
|
||||||
padding: var(--space-4) var(--space-8);
|
padding: var(--space-4) var(--space-8);
|
||||||
border-radius: var(--radius-lg);
|
border-radius: var(--radius-lg);
|
||||||
background: var(--contact-button-bg);
|
background: var(--contact-button-bg);
|
||||||
|
|
@ -439,6 +444,37 @@
|
||||||
align-items: center;
|
align-items: center;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
&__offers {
|
||||||
|
display: grid;
|
||||||
|
width: 100%;
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
gap: var(--space-5);
|
||||||
|
|
||||||
|
@media (min-width: 768px) {
|
||||||
|
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
&__card {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
gap: var(--space-4);
|
||||||
|
height: 100%;
|
||||||
|
padding: var(--space-6);
|
||||||
|
background: var(--card-glass-bg);
|
||||||
|
border: 1px solid var(--card-glass-border);
|
||||||
|
border-radius: var(--radius-lg);
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
&__card-title {
|
||||||
|
color: var(--color-primary);
|
||||||
|
font-family: var(--font-headline);
|
||||||
|
font-size: var(--font-size-xl);
|
||||||
|
line-height: var(--leading-tight);
|
||||||
|
}
|
||||||
|
|
||||||
&__title {
|
&__title {
|
||||||
@include audit-title;
|
@include audit-title;
|
||||||
}
|
}
|
||||||
|
|
@ -448,6 +484,7 @@
|
||||||
line-height: var(--leading-relaxed);
|
line-height: var(--leading-relaxed);
|
||||||
color: var(--color-text);
|
color: var(--color-text);
|
||||||
max-width: 34rem;
|
max-width: 34rem;
|
||||||
|
flex: 1;
|
||||||
}
|
}
|
||||||
|
|
||||||
&__cta {
|
&__cta {
|
||||||
|
|
@ -460,14 +497,10 @@
|
||||||
font-size: var(--font-size-lg);
|
font-size: var(--font-size-lg);
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
text-decoration: none;
|
text-decoration: none;
|
||||||
box-shadow: var(--shadow-md);
|
transition: background var(--transition-base);
|
||||||
transition:
|
|
||||||
background var(--transition-base),
|
|
||||||
transform var(--transition-base);
|
|
||||||
|
|
||||||
&:hover {
|
&:hover {
|
||||||
background: var(--contact-button-hover-bg);
|
background: var(--contact-button-hover-bg);
|
||||||
transform: translateY(-2px);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,301 @@
|
||||||
|
@use '../globals';
|
||||||
|
|
||||||
|
.quickcheck-page {
|
||||||
|
--nachtblau: #042c53;
|
||||||
|
--sekundaer: #0c447c;
|
||||||
|
--akzent-text: #412402;
|
||||||
|
--akzent-deko: #ef9f27;
|
||||||
|
--hintergrund: #f1efe8;
|
||||||
|
--quickcheck-surface: #fffdf8;
|
||||||
|
--quickcheck-muted: #573d25;
|
||||||
|
background: var(--hintergrund);
|
||||||
|
color: var(--akzent-text);
|
||||||
|
font-family: var(--font-body);
|
||||||
|
|
||||||
|
&__container {
|
||||||
|
width: min(100% - 2rem, 68.75rem);
|
||||||
|
margin-inline: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
&__hero {
|
||||||
|
background: var(--hintergrund);
|
||||||
|
padding-block: clamp(3rem, 8vw, 7rem);
|
||||||
|
}
|
||||||
|
|
||||||
|
&__download-grid {
|
||||||
|
display: grid;
|
||||||
|
gap: 2.5rem;
|
||||||
|
align-items: start;
|
||||||
|
}
|
||||||
|
|
||||||
|
&__hero-copy {
|
||||||
|
max-width: 62rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
&__kicker {
|
||||||
|
margin-bottom: 1rem;
|
||||||
|
color: var(--sekundaer);
|
||||||
|
font-family: var(--font-headline);
|
||||||
|
font-size: 0.875rem;
|
||||||
|
font-weight: 600;
|
||||||
|
letter-spacing: 0.08em;
|
||||||
|
text-transform: uppercase;
|
||||||
|
}
|
||||||
|
|
||||||
|
h1,
|
||||||
|
h2,
|
||||||
|
h3 {
|
||||||
|
color: var(--nachtblau);
|
||||||
|
font-family: var(--font-headline);
|
||||||
|
font-weight: 600;
|
||||||
|
line-height: 1.2;
|
||||||
|
}
|
||||||
|
|
||||||
|
h1 {
|
||||||
|
max-width: 58rem;
|
||||||
|
font-size: clamp(2rem, 5vw, 4rem);
|
||||||
|
}
|
||||||
|
|
||||||
|
h2 {
|
||||||
|
font-size: clamp(1.65rem, 3vw, 2.5rem);
|
||||||
|
}
|
||||||
|
|
||||||
|
h3 {
|
||||||
|
font-size: 1.25rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
p {
|
||||||
|
line-height: 1.65;
|
||||||
|
}
|
||||||
|
|
||||||
|
&__subline {
|
||||||
|
max-width: 60rem;
|
||||||
|
margin-top: 1.5rem;
|
||||||
|
color: var(--quickcheck-muted);
|
||||||
|
font-size: clamp(1.1rem, 2vw, 1.35rem);
|
||||||
|
}
|
||||||
|
|
||||||
|
&__feature,
|
||||||
|
&__notice {
|
||||||
|
background: var(--quickcheck-surface);
|
||||||
|
border: 2px solid var(--nachtblau);
|
||||||
|
}
|
||||||
|
|
||||||
|
&__form {
|
||||||
|
display: grid;
|
||||||
|
gap: 1.25rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
&__field {
|
||||||
|
display: grid;
|
||||||
|
gap: 0.45rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
&__field label {
|
||||||
|
color: var(--nachtblau);
|
||||||
|
font-family: var(--font-headline);
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
&__field input {
|
||||||
|
min-height: 3rem;
|
||||||
|
padding: 0.7rem 0.8rem;
|
||||||
|
border: 2px solid var(--nachtblau);
|
||||||
|
border-radius: 0;
|
||||||
|
background: #fff;
|
||||||
|
color: var(--akzent-text);
|
||||||
|
font: inherit;
|
||||||
|
}
|
||||||
|
|
||||||
|
&__field input:focus-visible,
|
||||||
|
&__submit:focus-visible,
|
||||||
|
&__text-link:focus-visible,
|
||||||
|
&__notice a:focus-visible {
|
||||||
|
outline: 4px solid var(--akzent-deko);
|
||||||
|
outline-offset: 3px;
|
||||||
|
}
|
||||||
|
|
||||||
|
&__hint {
|
||||||
|
color: var(--quickcheck-muted);
|
||||||
|
font-size: 0.9rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
&__checkbox-label {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: auto 1fr;
|
||||||
|
gap: 0.75rem;
|
||||||
|
align-items: start;
|
||||||
|
color: var(--quickcheck-muted);
|
||||||
|
font-size: 0.95rem;
|
||||||
|
line-height: 1.5;
|
||||||
|
}
|
||||||
|
|
||||||
|
&__checkbox-label input {
|
||||||
|
width: 1.25rem;
|
||||||
|
height: 1.25rem;
|
||||||
|
margin-top: 0.1rem;
|
||||||
|
accent-color: var(--nachtblau);
|
||||||
|
}
|
||||||
|
|
||||||
|
&__checkbox-label a {
|
||||||
|
color: var(--nachtblau);
|
||||||
|
font-weight: 600;
|
||||||
|
text-decoration: underline;
|
||||||
|
text-underline-offset: 0.2em;
|
||||||
|
}
|
||||||
|
|
||||||
|
&__submit {
|
||||||
|
min-height: 3.25rem;
|
||||||
|
padding: 0.75rem 1rem;
|
||||||
|
border: 2px solid var(--nachtblau);
|
||||||
|
border-radius: 0;
|
||||||
|
background: var(--nachtblau);
|
||||||
|
color: #f8f5ed;
|
||||||
|
cursor: pointer;
|
||||||
|
font: 600 1rem var(--font-headline);
|
||||||
|
}
|
||||||
|
|
||||||
|
&__submit:disabled {
|
||||||
|
cursor: wait;
|
||||||
|
opacity: 0.7;
|
||||||
|
}
|
||||||
|
|
||||||
|
&__submit:hover {
|
||||||
|
background: var(--sekundaer);
|
||||||
|
}
|
||||||
|
|
||||||
|
&__status {
|
||||||
|
min-height: 1.5rem;
|
||||||
|
color: var(--nachtblau);
|
||||||
|
font-weight: 500;
|
||||||
|
}
|
||||||
|
|
||||||
|
&__status a,
|
||||||
|
&__text-link,
|
||||||
|
&__notice a {
|
||||||
|
color: var(--nachtblau);
|
||||||
|
font-weight: 600;
|
||||||
|
text-decoration-thickness: 0.12em;
|
||||||
|
text-underline-offset: 0.2em;
|
||||||
|
}
|
||||||
|
|
||||||
|
&__section {
|
||||||
|
padding-block: clamp(3rem, 7vw, 6rem);
|
||||||
|
}
|
||||||
|
|
||||||
|
&__section--soft,
|
||||||
|
&__download {
|
||||||
|
background: var(--quickcheck-surface);
|
||||||
|
}
|
||||||
|
|
||||||
|
&__features {
|
||||||
|
display: grid;
|
||||||
|
gap: 1rem;
|
||||||
|
margin-top: 2rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
&__feature {
|
||||||
|
padding: 1.5rem;
|
||||||
|
text-align: left;
|
||||||
|
}
|
||||||
|
|
||||||
|
&__feature h3 {
|
||||||
|
margin-bottom: 0.75rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
&__limits {
|
||||||
|
max-width: 54rem;
|
||||||
|
padding: clamp(1.5rem, 4vw, 2.5rem);
|
||||||
|
background: var(--quickcheck-surface);
|
||||||
|
border: 2px solid var(--nachtblau);
|
||||||
|
text-align: left;
|
||||||
|
}
|
||||||
|
|
||||||
|
&__limits-intro {
|
||||||
|
margin-top: 1rem;
|
||||||
|
color: var(--quickcheck-muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
&__limits-list {
|
||||||
|
display: grid;
|
||||||
|
gap: 0.9rem;
|
||||||
|
margin-top: 1.25rem;
|
||||||
|
padding-left: 1.25rem;
|
||||||
|
color: var(--quickcheck-muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
&__notice {
|
||||||
|
max-width: 48rem;
|
||||||
|
padding: 1.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
&__notice h2 {
|
||||||
|
font-size: 1.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
&__notice p {
|
||||||
|
margin-block: 0.75rem 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
&__b2b-notice {
|
||||||
|
width: min(100% - 2rem, 54rem);
|
||||||
|
margin: 0 auto;
|
||||||
|
padding: 1rem;
|
||||||
|
background: var(--quickcheck-surface);
|
||||||
|
border: 2px solid var(--sekundaer);
|
||||||
|
color: var(--quickcheck-muted);
|
||||||
|
font-size: 0.95rem;
|
||||||
|
line-height: 1.55;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (min-width: 48rem) {
|
||||||
|
&__download-grid {
|
||||||
|
grid-template-columns: minmax(0, 1.15fr) minmax(20rem, 0.85fr);
|
||||||
|
align-items: start;
|
||||||
|
}
|
||||||
|
|
||||||
|
&__features {
|
||||||
|
grid-template-columns: repeat(3, 1fr);
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (prefers-reduced-motion: reduce) {
|
||||||
|
&__submit {
|
||||||
|
transition: none;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
[data-theme='dark'] .quickcheck-page {
|
||||||
|
--hintergrund: #021829;
|
||||||
|
--quickcheck-surface: #042c53;
|
||||||
|
--quickcheck-muted: #e6f1fb;
|
||||||
|
color: #e6f1fb;
|
||||||
|
|
||||||
|
h1,
|
||||||
|
h2,
|
||||||
|
h3,
|
||||||
|
&__field label,
|
||||||
|
&__status a,
|
||||||
|
&__text-link,
|
||||||
|
&__notice a {
|
||||||
|
color: #e6f1fb;
|
||||||
|
}
|
||||||
|
|
||||||
|
&__field input {
|
||||||
|
background: #e6f1fb;
|
||||||
|
color: #021829;
|
||||||
|
}
|
||||||
|
|
||||||
|
&__submit {
|
||||||
|
background: var(--akzent-deko);
|
||||||
|
border-color: var(--akzent-deko);
|
||||||
|
color: #042c53;
|
||||||
|
}
|
||||||
|
|
||||||
|
&__submit:hover {
|
||||||
|
background: #fac775;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -2,23 +2,22 @@ import type { NavigateFunction } from 'react-router-dom';
|
||||||
|
|
||||||
// Offset to account for sticky navbar height (65px) plus some padding
|
// Offset to account for sticky navbar height (65px) plus some padding
|
||||||
const SCROLL_OFFSET = 65;
|
const SCROLL_OFFSET = 65;
|
||||||
|
const TECHNICAL_PATH = '/technical';
|
||||||
|
const LANDING_PATH = '/';
|
||||||
|
const LAST_MAIN_PAGE_KEY = 'portfolio:last-main-page';
|
||||||
|
|
||||||
function isScrollablePath(pathname: string): boolean {
|
function isScrollablePath(pathname: string): boolean {
|
||||||
return (
|
return pathname === LANDING_PATH || pathname === TECHNICAL_PATH;
|
||||||
pathname === '/de/' ||
|
|
||||||
pathname === '/en/' ||
|
|
||||||
pathname === '/de/technical' ||
|
|
||||||
pathname === '/en/technical' ||
|
|
||||||
// legacy fallbacks
|
|
||||||
pathname === '/' ||
|
|
||||||
pathname === '/technical'
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Returns /de/technical or /en/technical depending on the current path prefix. */
|
function getSectionTargetPath(): string {
|
||||||
function getTechnicalPath(currentPath: string): string {
|
try {
|
||||||
if (currentPath.startsWith('/en')) return '/en/technical';
|
return sessionStorage.getItem(LAST_MAIN_PAGE_KEY) === TECHNICAL_PATH
|
||||||
return '/de/technical';
|
? TECHNICAL_PATH
|
||||||
|
: LANDING_PATH;
|
||||||
|
} catch {
|
||||||
|
return LANDING_PATH;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export function scrollToSection(
|
export function scrollToSection(
|
||||||
|
|
@ -38,9 +37,9 @@ export function scrollToSection(
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
} else if (navigate) {
|
} else if (navigate) {
|
||||||
navigate(`${getTechnicalPath(currentPath)}#${sectionId}`);
|
navigate(`${getSectionTargetPath()}#${sectionId}`);
|
||||||
} else {
|
} else {
|
||||||
globalThis.location.href = `${getTechnicalPath(currentPath)}#${sectionId}`;
|
globalThis.location.href = `${getSectionTargetPath()}#${sectionId}`;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue