Compare commits
No commits in common. "ed129ee56d4647631968242c0655d7ea9c924f3f" and "2bdcb5303e261a77342d0f596516d7cc01f9facb" have entirely different histories.
ed129ee56d
...
2bdcb5303e
|
|
@ -1,10 +1,7 @@
|
|||
import express from 'express'
|
||||
import rateLimit from 'express-rate-limit'
|
||||
import { body, validationResult } from 'express-validator'
|
||||
import {
|
||||
sendAuditRequestEmail,
|
||||
sendQuickcheckRequestEmail,
|
||||
} from '../services/emailService.js'
|
||||
import { sendAuditRequestEmail } from '../services/emailService.js'
|
||||
|
||||
const router = express.Router()
|
||||
|
||||
|
|
@ -22,7 +19,6 @@ const auditLimiter = rateLimit({
|
|||
// 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.
|
||||
const validateAuditRequest = [
|
||||
body('formType').optional().equals('audit'),
|
||||
body('name').trim().isLength({ min: 1, max: 100 }).escape(),
|
||||
body('company').trim().isLength({ min: 1, max: 150 }).escape(),
|
||||
body('email').trim().isEmail().normalizeEmail().isLength({ max: 254 }),
|
||||
|
|
@ -41,29 +37,7 @@ const validateAuditRequest = [
|
|||
body('website').optional().isEmpty(), // Honeypot
|
||||
]
|
||||
|
||||
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) => {
|
||||
router.post('/', auditLimiter, validateAuditRequest, async (req, res) => {
|
||||
try {
|
||||
const errors = validationResult(req)
|
||||
if (!errors.isEmpty()) {
|
||||
|
|
@ -76,9 +50,12 @@ router.post('/', auditLimiter, validateRequest, async (req, res) => {
|
|||
}
|
||||
|
||||
const {
|
||||
formType = 'audit',
|
||||
name,
|
||||
company,
|
||||
email,
|
||||
url,
|
||||
role,
|
||||
motivation,
|
||||
businessConfirmation,
|
||||
gdprConsent,
|
||||
website,
|
||||
|
|
@ -106,17 +83,6 @@ router.post('/', auditLimiter, validateRequest, 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 })
|
||||
|
||||
res.json({
|
||||
|
|
|
|||
|
|
@ -35,9 +35,7 @@ function getTransporter() {
|
|||
* when it is used in Subject or Reply-To.
|
||||
*/
|
||||
function singleLine(value) {
|
||||
return String(value)
|
||||
.replace(/[\r\n]+/g, ' ')
|
||||
.trim()
|
||||
return String(value).replace(/[\r\n]+/g, ' ').trim()
|
||||
}
|
||||
|
||||
/** Minimal HTML escaping - the payload is attacker-controlled by definition. */
|
||||
|
|
@ -117,42 +115,3 @@ export async function sendAuditRequestEmail({
|
|||
// personal data (Art. 5 GDPR, data minimisation).
|
||||
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,10 +17,12 @@ const DIST = join(__dirname, 'dist')
|
|||
const PORT = 4173
|
||||
|
||||
const ROUTES = [
|
||||
'/',
|
||||
'/technical',
|
||||
'/audit',
|
||||
'/schnellcheck',
|
||||
'/de/',
|
||||
'/de/technical',
|
||||
'/de/audit',
|
||||
'/en/',
|
||||
'/en/technical',
|
||||
'/en/audit',
|
||||
'/imprint',
|
||||
'/privacy-policy',
|
||||
]
|
||||
|
|
|
|||
|
|
@ -1,20 +1,63 @@
|
|||
<?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>
|
||||
<loc>https://sascha-bach.de/</loc>
|
||||
<lastmod>2026-08-23</lastmod>
|
||||
<loc>https://sascha-bach.de/de/</loc>
|
||||
<xhtml:link rel="alternate" hreflang="de" href="https://sascha-bach.de/de/" />
|
||||
<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>
|
||||
<priority>1.0</priority>
|
||||
</url>
|
||||
<!-- English landing -->
|
||||
<url>
|
||||
<loc>https://sascha-bach.de/technical</loc>
|
||||
<lastmod>2026-08-23</lastmod>
|
||||
<loc>https://sascha-bach.de/en/</loc>
|
||||
<xhtml:link rel="alternate" hreflang="de" href="https://sascha-bach.de/de/" />
|
||||
<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>
|
||||
<priority>0.9</priority>
|
||||
</url>
|
||||
<!-- German technical portfolio -->
|
||||
<url>
|
||||
<loc>https://sascha-bach.de/audit</loc>
|
||||
<lastmod>2026-08-23</lastmod>
|
||||
<loc>https://sascha-bach.de/de/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.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>
|
||||
<priority>0.8</priority>
|
||||
</url>
|
||||
|
|
|
|||
|
|
@ -4,32 +4,27 @@ import '../scss/App.scss';
|
|||
import Navbar from '../components/layout/Navigation';
|
||||
import Footer from '../components/layout/Footer';
|
||||
import BackToTopButton from '../components/BackToTopButton';
|
||||
import { useLanguage } from '../contexts/LanguageContext';
|
||||
import { getLanguageFromPath, useLanguage } from '../contexts/LanguageContext';
|
||||
|
||||
const HomePage = lazy(() => import('../pages/HomePage'));
|
||||
const LandingPage = lazy(() => import('../pages/LandingPage'));
|
||||
const AuditPage = lazy(() => import('../pages/AuditPage'));
|
||||
const QuickcheckPage = lazy(() => import('../pages/QuickcheckPage'));
|
||||
const ImprintPage = lazy(() => import('../pages/ImprintPage'));
|
||||
const PrivacyPolicy = lazy(() => import('../pages/PrivacyPolicy'));
|
||||
|
||||
function LegacyLanguageRedirect() {
|
||||
const { setLanguage } = useLanguage();
|
||||
/** Syncs the LanguageContext whenever the URL prefix (/de/ or /en/) changes. */
|
||||
function LanguageSyncer() {
|
||||
const { setLanguage, language } = useLanguage();
|
||||
const location = useLocation();
|
||||
|
||||
const legacyLanguage = location.pathname.startsWith('/en') ? 'en' : 'de';
|
||||
const targetPathname = location.pathname.replace(/^\/(de|en)(?=\/|$)/, '') || '/';
|
||||
|
||||
useEffect(() => {
|
||||
setLanguage(legacyLanguage);
|
||||
}, [legacyLanguage, setLanguage]);
|
||||
const urlLang = getLanguageFromPath(location.pathname);
|
||||
if (urlLang && urlLang !== language) {
|
||||
setLanguage(urlLang);
|
||||
}
|
||||
}, [location.pathname, language, setLanguage]);
|
||||
|
||||
return (
|
||||
<Navigate
|
||||
to={{ pathname: targetPathname, search: location.search, hash: location.hash }}
|
||||
replace
|
||||
/>
|
||||
);
|
||||
return null;
|
||||
}
|
||||
|
||||
function AppShell() {
|
||||
|
|
@ -53,24 +48,28 @@ function AppShell() {
|
|||
<a href="#main-content" className="skip-link">
|
||||
{texts.accessibility.skipLinks.skipToHero}
|
||||
</a>
|
||||
<LanguageSyncer />
|
||||
<Navbar />
|
||||
<main id="main-content" className="app__main">
|
||||
<Suspense fallback={<p role="status" className="sr-only">Loading…</p>}>
|
||||
<Routes>
|
||||
{/* Legacy locale-prefixed URLs → clean URLs */}
|
||||
<Route path="/de/*" element={<LegacyLanguageRedirect />} />
|
||||
<Route path="/en/*" element={<LegacyLanguageRedirect />} />
|
||||
{/* Root redirects → German (primary market) */}
|
||||
<Route path="/" element={<Navigate to="/de/" replace />} />
|
||||
<Route path="/technical" element={<Navigate to="/de/technical" replace />} />
|
||||
|
||||
{/* Canonical, language-neutral routes */}
|
||||
<Route path="/" element={<LandingPage />} />
|
||||
<Route path="/technical" element={<HomePage />} />
|
||||
<Route path="/audit" element={<AuditPage />} />
|
||||
<Route path="/schnellcheck" element={<QuickcheckPage />} />
|
||||
{/* German routes */}
|
||||
<Route path="/de/" element={<LandingPage />} />
|
||||
<Route path="/de/technical" element={<HomePage />} />
|
||||
<Route path="/de/audit" element={<AuditPage />} />
|
||||
|
||||
{/* English routes */}
|
||||
<Route path="/en/" element={<LandingPage />} />
|
||||
<Route path="/en/technical" element={<HomePage />} />
|
||||
<Route path="/en/audit" element={<AuditPage />} />
|
||||
|
||||
{/* Shared pages (noindex, language-independent) */}
|
||||
<Route path="/imprint" element={<ImprintPage />} />
|
||||
<Route path="/privacy-policy" element={<PrivacyPolicy />} />
|
||||
<Route path="*" element={<Navigate to="/" replace />} />
|
||||
</Routes>
|
||||
</Suspense>
|
||||
</main>
|
||||
|
|
|
|||
|
|
@ -1,9 +1,9 @@
|
|||
import { LanguageProvider, detectInitialLanguage } from '../contexts/LanguageContext';
|
||||
import { LanguageProvider, getLanguageFromPath } from '../contexts/LanguageContext';
|
||||
import { ThemeProvider } from '../contexts/ThemeContext';
|
||||
import AppRouter from './AppRouter';
|
||||
|
||||
function PortfolioApp() {
|
||||
const initialLanguage = detectInitialLanguage();
|
||||
const initialLanguage = getLanguageFromPath(globalThis.location.pathname) ?? 'de';
|
||||
|
||||
return (
|
||||
<LanguageProvider initialLanguage={initialLanguage}>
|
||||
|
|
|
|||
|
|
@ -8,8 +8,6 @@ import { ThemeProvider } from '../../contexts/ThemeContext';
|
|||
import LandingPage from '../../pages/LandingPage';
|
||||
import HomePage from '../../pages/HomePage';
|
||||
import AuditPage from '../../pages/AuditPage';
|
||||
import ImprintPage from '../../pages/ImprintPage';
|
||||
import PrivacyPolicy from '../../pages/PrivacyPolicy';
|
||||
import Footer from '../../components/layout/Footer';
|
||||
|
||||
function renderPage(children: ReactNode) {
|
||||
|
|
@ -38,30 +36,10 @@ describe('accessibility smoke test', () => {
|
|||
expect(await axe(container)).toHaveNoViolations();
|
||||
});
|
||||
|
||||
it('Footer links to the clean technical portfolio URL', () => {
|
||||
it('Footer keeps the active language for the portfolio link', () => {
|
||||
renderPage(<Footer />);
|
||||
|
||||
const link = screen.getByRole('link', { name: 'Technisches Portfolio' });
|
||||
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();
|
||||
expect(link).toHaveAttribute('href', '/de/technical');
|
||||
});
|
||||
});
|
||||
|
|
|
|||
Binary file not shown.
Binary file not shown.
Binary file not shown.
|
Before Width: | Height: | Size: 114 KiB |
Binary file not shown.
|
Before Width: | Height: | Size: 1.9 MiB |
Binary file not shown.
|
Before Width: | Height: | Size: 150 KiB |
|
|
@ -1,15 +1,26 @@
|
|||
import { useNavigate, useLocation } from 'react-router-dom';
|
||||
import { useLanguage } from '../contexts/LanguageContext';
|
||||
import type { Language } from '../data/types';
|
||||
import '../scss/language-toggle.scss';
|
||||
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() {
|
||||
const { language, setLanguage } = useLanguage();
|
||||
const { language } = useLanguage();
|
||||
const { announce } = useScreenReaderAnnouncements();
|
||||
const navigate = useNavigate();
|
||||
const location = useLocation();
|
||||
|
||||
const handleLanguageChange = (newLang: Language) => {
|
||||
if (newLang !== language) {
|
||||
setLanguage(newLang);
|
||||
const targetPath = getEquivalentPath(location.pathname, newLang);
|
||||
navigate(targetPath);
|
||||
announce(`Language changed to ${newLang === 'en' ? 'English' : 'Deutsch'}`);
|
||||
}
|
||||
};
|
||||
|
|
|
|||
|
|
@ -3,10 +3,11 @@ import { useScrollReveal } from '../../hooks/useScrollReveal';
|
|||
import { useLanguage } from '../../contexts/LanguageContext';
|
||||
|
||||
/**
|
||||
* Promotes the two free accessibility offers from the main landing page.
|
||||
* Promotes the audit landing page from the main landing page. Lives in the
|
||||
* landing folder because it belongs to that page, not to /audit itself.
|
||||
*/
|
||||
export default function AuditTeaserSection() {
|
||||
const { texts } = useLanguage();
|
||||
const { texts, language } = useLanguage();
|
||||
const t = texts.audit.teaser;
|
||||
const sectionRef = useScrollReveal();
|
||||
|
||||
|
|
@ -21,30 +22,14 @@ export default function AuditTeaserSection() {
|
|||
<h2 id="audit-teaser-title" className="audit-teaser-section__title reveal-item">
|
||||
{t.title}
|
||||
</h2>
|
||||
<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
|
||||
to="/audit"
|
||||
className="audit-teaser-section__cta"
|
||||
onClick={() => window.scrollTo({ top: 0, behavior: 'smooth' })}
|
||||
>
|
||||
{t.auditCtaText}
|
||||
</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>
|
||||
<p className="audit-teaser-section__text reveal-item">{t.text}</p>
|
||||
<Link
|
||||
to={`/${language}/audit`}
|
||||
className="audit-teaser-section__cta reveal-item"
|
||||
onClick={() => window.scrollTo({ top: 0, behavior: 'smooth' })}
|
||||
>
|
||||
{t.ctaText}
|
||||
</Link>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -1,13 +1,13 @@
|
|||
import { Globe, Mail } from 'lucide-react';
|
||||
import { Globe, Linkedin, Mail } from 'lucide-react';
|
||||
import { Link, useLocation } from 'react-router-dom';
|
||||
import { personalConfig, createEmailLink } from '../../config/personal';
|
||||
import { useLanguage } from '../../contexts/LanguageContext';
|
||||
|
||||
export default function Footer() {
|
||||
const { texts } = useLanguage();
|
||||
const { texts, language } = useLanguage();
|
||||
const location = useLocation();
|
||||
const isTechnicalPage = location.pathname.includes('/technical', 0);
|
||||
const technicalLinkTarget = '/technical';
|
||||
const technicalLinkTarget = `/${language}/technical`;
|
||||
// Email obfuscation function using config
|
||||
const handleEmailClick = (e: React.MouseEvent) => {
|
||||
e.preventDefault();
|
||||
|
|
@ -24,25 +24,15 @@ export default function Footer() {
|
|||
<Link to="/imprint" className="footer__link">
|
||||
{texts.footer.imprintText}
|
||||
</Link>
|
||||
<Link to="/privacy-policy" className="footer__link">
|
||||
{texts.footer.privacyPolicyText}
|
||||
</Link>
|
||||
<Link
|
||||
to="/audit"
|
||||
to={`/${language}/audit`}
|
||||
className="footer__link"
|
||||
onClick={() => window.scrollTo({ top: 0, behavior: 'smooth' })}
|
||||
>
|
||||
{texts.footer.auditLinkText}
|
||||
</Link>
|
||||
<Link
|
||||
to="/schnellcheck"
|
||||
className="footer__link"
|
||||
onClick={() => window.scrollTo({ top: 0, behavior: 'smooth' })}
|
||||
>
|
||||
{texts.footer.quickcheckLinkText}
|
||||
</Link>
|
||||
<Link
|
||||
to={isTechnicalPage ? '/' : technicalLinkTarget}
|
||||
to={isTechnicalPage ? `/${language}/` : technicalLinkTarget}
|
||||
className="footer__link"
|
||||
onClick={() => window.scrollTo({ top: 0, behavior: 'smooth' })}
|
||||
>
|
||||
|
|
@ -58,7 +48,6 @@ export default function Footer() {
|
|||
className="footer__social-button"
|
||||
onClick={() => window.open(personalConfig.social.git.url, '_blank')}
|
||||
aria-label={texts.footer.gitAriaLabel}
|
||||
type="button"
|
||||
>
|
||||
<Globe className="footer__social-icon" />
|
||||
</button>
|
||||
|
|
@ -67,29 +56,14 @@ export default function Footer() {
|
|||
className="footer__social-button"
|
||||
onClick={() => window.open(personalConfig.social.linkedin.url, '_blank')}
|
||||
aria-label={texts.footer.linkedinAriaLabel}
|
||||
type="button"
|
||||
>
|
||||
<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>
|
||||
<Linkedin className="footer__social-icon" />
|
||||
</button>
|
||||
|
||||
<button
|
||||
className="footer__social-button"
|
||||
onClick={handleEmailClick}
|
||||
aria-label={texts.footer.emailAriaLabel}
|
||||
type="button"
|
||||
>
|
||||
<Mail className="footer__social-icon" />
|
||||
</button>
|
||||
|
|
|
|||
|
|
@ -14,18 +14,17 @@ export default function Navbar() {
|
|||
const navigate = useNavigate();
|
||||
const { texts } = useLanguage();
|
||||
|
||||
const isLandingPage = location.pathname === '/';
|
||||
const technicalPath = '/technical';
|
||||
let lastMainPage = '/';
|
||||
|
||||
try {
|
||||
lastMainPage = sessionStorage.getItem('portfolio:last-main-page') || '/';
|
||||
} catch {
|
||||
// Use the landing page as the default when session storage is unavailable.
|
||||
}
|
||||
const isLandingPage = location.pathname === '/de/' || location.pathname === '/en/' || location.pathname === '/';
|
||||
// The audit page is a lead-magnet landing page: every extra nav target is an
|
||||
// exit. It also has no scroll sections, so the section buttons would only
|
||||
// navigate away to /de/technical#… - see scrollUtils.isScrollablePath.
|
||||
const isAuditPage = location.pathname === '/de/audit' || location.pathname === '/en/audit';
|
||||
const technicalPath = location.pathname.startsWith('/en') ? '/en/technical' : '/de/technical';
|
||||
|
||||
let menuItems = texts.navigation.menuItems;
|
||||
if (isLandingPage || (location.pathname !== technicalPath && lastMainPage === '/')) {
|
||||
if (isAuditPage) {
|
||||
menuItems = [];
|
||||
} else if (isLandingPage) {
|
||||
menuItems = texts.navigation.landingMenuItems;
|
||||
}
|
||||
|
||||
|
|
@ -34,14 +33,6 @@ export default function Navbar() {
|
|||
|
||||
// Track active section based on scroll position
|
||||
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) {
|
||||
return;
|
||||
}
|
||||
|
|
@ -77,13 +68,11 @@ export default function Navbar() {
|
|||
return (
|
||||
<div className={`navbar${isLandingPage ? ' navbar--landing' : ''}`}>
|
||||
<Link
|
||||
to="/"
|
||||
to={isLandingPage ? location.pathname : technicalPath}
|
||||
className="navbar__name"
|
||||
onClick={(e) => {
|
||||
if (isLandingPage) {
|
||||
e.preventDefault();
|
||||
window.scrollTo({ top: 0, behavior: 'smooth' });
|
||||
}
|
||||
e.preventDefault();
|
||||
window.scrollTo({ top: 0, behavior: 'smooth' });
|
||||
}}
|
||||
>
|
||||
<img src={logoUrl} alt={texts.navigation.logoAlt} className="navbar__logo" />
|
||||
|
|
@ -92,7 +81,6 @@ export default function Navbar() {
|
|||
{menuItems.map((item) => (
|
||||
<button
|
||||
key={item.label}
|
||||
type="button"
|
||||
onClick={() => handleNavigation(item.section)}
|
||||
className={`navbar__container__button ${displayedActiveSection === item.section.toLowerCase() ? 'navbar__container__button--active' : ''}`}
|
||||
>
|
||||
|
|
|
|||
|
|
@ -12,7 +12,7 @@ interface ProjectsSectionProps {
|
|||
}
|
||||
|
||||
export default function ProjectsSection(props: ProjectsSectionProps = {}) {
|
||||
const { language, texts: allTexts } = useLanguage();
|
||||
const { texts: allTexts } = useLanguage();
|
||||
const texts = allTexts.projects;
|
||||
const accessibilityTexts = allTexts.accessibility;
|
||||
const localizedProjectsData: Project[] = texts.projectItems.map((item) => ({
|
||||
|
|
@ -31,8 +31,9 @@ export default function ProjectsSection(props: ProjectsSectionProps = {}) {
|
|||
title = texts.title,
|
||||
subtitle = texts.subtitle,
|
||||
projects = [...localizedProjectsData].sort((a, b) => {
|
||||
const displayOrder = [5, 8, 6, 4, 3, 1, 2, 7];
|
||||
return displayOrder.indexOf(a.id) - displayOrder.indexOf(b.id);
|
||||
const yearA = Number.parseInt(a.year || '0');
|
||||
const yearB = Number.parseInt(b.year || '0');
|
||||
return yearB - yearA; // Descending order (newest first)
|
||||
})
|
||||
} = props;
|
||||
|
||||
|
|
@ -117,9 +118,7 @@ export default function ProjectsSection(props: ProjectsSectionProps = {}) {
|
|||
<div className="projects-section__image-container">
|
||||
<img
|
||||
src={project.image || "/api/placeholder/400/200"}
|
||||
alt={language === 'de'
|
||||
? `Screenshot der Website ${project.title}`
|
||||
: `${project.title} website screenshot`}
|
||||
alt={`${project.title} project screenshot`}
|
||||
className="projects-section__image"
|
||||
/>
|
||||
|
||||
|
|
|
|||
|
|
@ -138,12 +138,8 @@ export const audit = {
|
|||
},
|
||||
|
||||
teaser: {
|
||||
title: 'Kostenlose Angebote',
|
||||
auditTitle: 'Erst mal sehen, wo du stehst?',
|
||||
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',
|
||||
title: 'Erst mal sehen, wo du stehst?',
|
||||
text: 'Ich prüfe eine Seite deiner Website nach WCAG und schicke dir einen kostenfreien Kurzreport – plus 15 Minuten Auswertung. Für Unternehmen.',
|
||||
ctaText: 'Zum kostenfreien Audit',
|
||||
},
|
||||
};
|
||||
|
|
|
|||
|
|
@ -1,11 +1,9 @@
|
|||
export const footer = {
|
||||
imprintText: 'Impressum',
|
||||
privacyPolicyText: 'Datenschutz',
|
||||
imprintText: 'Impressum und Datenschutz',
|
||||
copyrightText: 'Alle Rechte vorbehalten.',
|
||||
technicalLinkText: 'Technisches Portfolio',
|
||||
landingLinkText: 'Landing Page',
|
||||
auditLinkText: 'Kostenfreies Audit',
|
||||
quickcheckLinkText: 'Kostenloser Schnellcheck',
|
||||
gitAriaLabel: 'Git-Profil',
|
||||
linkedinAriaLabel: 'LinkedIn-Profil',
|
||||
emailAriaLabel: 'E-Mail senden',
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
export const imprint = {
|
||||
title: 'Impressum',
|
||||
subtitle: 'Rechtliche Informationen zum Anbieter',
|
||||
title: 'Impressum und Datenschutz',
|
||||
subtitle: 'Rechtliche Informationen',
|
||||
companyInfoTitle: 'Angaben gemäß § 5 DDG',
|
||||
umsatzsteuerID: 'Umsatzsteuer-ID: DE316934637',
|
||||
vatID: 'VAT ID: DE316934637',
|
||||
|
|
@ -91,25 +91,6 @@ export const imprint = {
|
|||
],
|
||||
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).',
|
||||
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',
|
||||
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',
|
||||
|
|
|
|||
|
|
@ -5,7 +5,6 @@ export const navigation = {
|
|||
{ section: 'Skills', label: 'Fähigkeiten' },
|
||||
{ section: 'Certifications', label: 'Zertifikate' },
|
||||
{ section: 'Projects', label: 'Projekte' },
|
||||
{ section: 'Audit-Teaser', label: 'Kostenlose Angebote' },
|
||||
{ section: 'Contact', label: 'Kontakt' },
|
||||
],
|
||||
landingMenuItems: [
|
||||
|
|
@ -15,7 +14,6 @@ export const navigation = {
|
|||
{ section: 'Processes', label: 'Wie ich arbeite' },
|
||||
{ section: 'Projects', label: 'Projekte' },
|
||||
{ section: 'References', label: 'Referenzen' },
|
||||
{ section: 'Audit-Teaser', label: 'Kostenlose Angebote' },
|
||||
{ section: 'Contact', label: 'Kontakt' },
|
||||
],
|
||||
mobileMenuAriaLabel: 'Menü öffnen',
|
||||
|
|
|
|||
|
|
@ -3,7 +3,7 @@ export const privacyPolicy = {
|
|||
lastUpdated: 'Zuletzt aktualisiert: November 2025',
|
||||
introTitle: 'Überblick',
|
||||
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 verarbeitet, wenn Sie das Formular auf der Seite „Kostenfreies Barrierefreiheits-Audit nach WCAG“ oder den kostenlosen Barrierefreiheits-Schnellcheck 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 nur dann verarbeitet, wenn Sie das Formular auf der Seite „Kostenfreies Barrierefreiheits-Audit nach WCAG“ absenden.',
|
||||
dataCollectionTitle: 'Datenerhebung',
|
||||
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:',
|
||||
|
|
@ -28,25 +28,6 @@ export const privacyPolicy = {
|
|||
auditFormPurposeLabel: 'Zweck:',
|
||||
auditFormPurpose:
|
||||
'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:',
|
||||
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.',
|
||||
|
|
|
|||
|
|
@ -2,16 +2,13 @@ import portfolioImage from '@/assets/portfolio.PNG';
|
|||
import dancaAlegriaImage from '@/assets/Danca-Alegria.png';
|
||||
import lukasImage from '@/assets/lukas.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';
|
||||
|
||||
export const projects = {
|
||||
title: 'Ausgewählte Projekte',
|
||||
subtitle: 'Eine Übersicht meiner Arbeiten und persönlichen Projekte',
|
||||
codeButtonText: 'Code',
|
||||
liveButtonText: 'Website öffnen',
|
||||
liveButtonText: 'Live',
|
||||
projectItems: [
|
||||
{
|
||||
id: 1,
|
||||
|
|
@ -56,33 +53,21 @@ export const projects = {
|
|||
},
|
||||
{
|
||||
id: 5,
|
||||
title: 'arcum-nova.de',
|
||||
title: 'Barrierefreiheits-Audit für einen Unternehmenskunden',
|
||||
description:
|
||||
'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: arcumNovaImage,
|
||||
technologies: ['WCAG 2.2', 'WordPress', 'Avada', 'Barrierefreiheit'],
|
||||
'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.',
|
||||
image: placeholderImage,
|
||||
technologies: ['WCAG 2.2', 'BITV 2.0', 'EN 301 549', 'axe DevTools', 'WAVE'],
|
||||
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,
|
||||
title: 'gxplex.org',
|
||||
title: 'Individuelle MediaWiki-Plattform (GxPlex)',
|
||||
description:
|
||||
'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: gxplexImage,
|
||||
technologies: ['MediaWiki 1.43.8', 'Semantic MediaWiki', 'Cavendish', 'Bewertungsfunktion'],
|
||||
'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.',
|
||||
image: placeholderImage,
|
||||
technologies: ['MediaWiki', 'MySQL', 'PHP', 'SSL'],
|
||||
year: '2026',
|
||||
live: 'https://gxplex.org',
|
||||
},
|
||||
{
|
||||
id: 7,
|
||||
|
|
|
|||
|
|
@ -20,12 +20,10 @@ export interface TextConfig {
|
|||
// Footer
|
||||
footer: {
|
||||
imprintText: string;
|
||||
privacyPolicyText: string;
|
||||
copyrightText: string;
|
||||
technicalLinkText: string;
|
||||
landingLinkText: string;
|
||||
auditLinkText: string;
|
||||
quickcheckLinkText: string;
|
||||
gitAriaLabel: string;
|
||||
linkedinAriaLabel: string;
|
||||
emailAriaLabel: string;
|
||||
|
|
@ -238,16 +236,6 @@ export interface TextConfig {
|
|||
dataDeletionIntro: string;
|
||||
dataDeletionReasons: 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;
|
||||
webhostingText: string;
|
||||
webhostingDataTypes: string[];
|
||||
|
|
@ -321,16 +309,6 @@ export interface TextConfig {
|
|||
auditFormDataList: string[];
|
||||
auditFormPurposeLabel: 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;
|
||||
auditFormLegalBasis: string;
|
||||
auditFormRetentionLabel: string;
|
||||
|
|
@ -365,22 +343,22 @@ export interface TextConfig {
|
|||
problem: {
|
||||
title: string;
|
||||
intro: string;
|
||||
items: { text: string; }[];
|
||||
items: { text: string }[];
|
||||
outro: string;
|
||||
};
|
||||
deliverable: {
|
||||
title: string;
|
||||
intro: string;
|
||||
items: { title: string; text: string; }[];
|
||||
items: { title: string; text: string }[];
|
||||
};
|
||||
process: {
|
||||
title: string;
|
||||
steps: { title: string; text: string; }[];
|
||||
steps: { title: string; text: string }[];
|
||||
};
|
||||
limits: {
|
||||
title: string;
|
||||
intro: string;
|
||||
items: { text: string; }[];
|
||||
items: { text: string }[];
|
||||
outro: string;
|
||||
};
|
||||
form: {
|
||||
|
|
@ -424,12 +402,8 @@ export interface TextConfig {
|
|||
};
|
||||
teaser: {
|
||||
title: string;
|
||||
auditTitle: string;
|
||||
auditText: string;
|
||||
auditCtaText: string;
|
||||
quickcheckTitle: string;
|
||||
quickcheckText: string;
|
||||
quickcheckCtaText: string;
|
||||
text: string;
|
||||
ctaText: string;
|
||||
};
|
||||
};
|
||||
|
||||
|
|
|
|||
|
|
@ -138,12 +138,8 @@ export const audit = {
|
|||
},
|
||||
|
||||
teaser: {
|
||||
title: 'Free resources',
|
||||
auditTitle: 'Want to see where you stand first?',
|
||||
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',
|
||||
title: 'Want to see where you stand first?',
|
||||
text: 'I review one page of your website against WCAG and send you a free short report – plus a 15 minute walkthrough. For businesses.',
|
||||
ctaText: 'Go to the free audit',
|
||||
},
|
||||
};
|
||||
|
|
|
|||
|
|
@ -1,11 +1,9 @@
|
|||
export const footer = {
|
||||
imprintText: 'Imprint',
|
||||
privacyPolicyText: 'Privacy Policy',
|
||||
imprintText: 'Impressum und Datenschutz',
|
||||
copyrightText: 'All rights reserved.',
|
||||
technicalLinkText: 'Technical Portfolio',
|
||||
landingLinkText: 'Landing Page',
|
||||
auditLinkText: 'Free audit',
|
||||
quickcheckLinkText: 'Free quick check',
|
||||
gitAriaLabel: 'Git Profile',
|
||||
linkedinAriaLabel: 'LinkedIn Profile',
|
||||
emailAriaLabel: 'Send Email',
|
||||
|
|
|
|||
|
|
@ -1,5 +1,5 @@
|
|||
export const imprint = {
|
||||
title: 'Imprint',
|
||||
title: 'Imprint and Privacy Policy',
|
||||
subtitle: 'Legal Information',
|
||||
companyInfoTitle: 'Information pursuant to § 5 DDG',
|
||||
umsatzsteuerID: 'Umsatzsteuer-ID: DE316934637',
|
||||
|
|
@ -91,25 +91,6 @@ export const imprint = {
|
|||
],
|
||||
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).',
|
||||
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',
|
||||
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',
|
||||
|
|
|
|||
|
|
@ -5,7 +5,6 @@ export const navigation = {
|
|||
{ section: 'Skills', label: 'Skills' },
|
||||
{ section: 'Certifications', label: 'Certifications' },
|
||||
{ section: 'Projects', label: 'Projects' },
|
||||
{ section: 'Audit-Teaser', label: 'Free resources' },
|
||||
{ section: 'Contact', label: 'Contact' },
|
||||
],
|
||||
landingMenuItems: [
|
||||
|
|
@ -15,7 +14,6 @@ export const navigation = {
|
|||
{ section: 'Processes', label: 'How I Work' },
|
||||
{ section: 'Projects', label: 'Projects' },
|
||||
{ section: 'References', label: 'References' },
|
||||
{ section: 'Audit-Teaser', label: 'Free resources' },
|
||||
{ section: 'Contact', label: 'Contact' },
|
||||
],
|
||||
mobileMenuAriaLabel: 'Open menu',
|
||||
|
|
|
|||
|
|
@ -3,7 +3,7 @@ export const privacyPolicy = {
|
|||
lastUpdated: 'Last updated: November 2025',
|
||||
introTitle: 'Overview',
|
||||
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 processed if you submit the form on the "Free accessibility audit to WCAG" page or the free accessibility quick check.',
|
||||
'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.',
|
||||
dataCollectionTitle: 'Data Collection',
|
||||
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:',
|
||||
|
|
@ -28,25 +28,6 @@ export const privacyPolicy = {
|
|||
auditFormPurposeLabel: 'Purpose:',
|
||||
auditFormPurpose:
|
||||
'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:',
|
||||
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.',
|
||||
|
|
|
|||
|
|
@ -2,16 +2,13 @@ import portfolioImage from '@/assets/portfolio.PNG';
|
|||
import dancaAlegriaImage from '@/assets/Danca-Alegria.png';
|
||||
import lukasImage from '@/assets/lukas.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';
|
||||
|
||||
export const projects = {
|
||||
title: 'Featured Projects',
|
||||
subtitle: 'A showcase of my work and personal projects',
|
||||
codeButtonText: 'Code',
|
||||
liveButtonText: 'Open website',
|
||||
liveButtonText: 'Live',
|
||||
projectItems: [
|
||||
{
|
||||
id: 1,
|
||||
|
|
@ -56,33 +53,21 @@ export const projects = {
|
|||
},
|
||||
{
|
||||
id: 5,
|
||||
title: 'arcum-nova.de',
|
||||
title: 'Accessibility Audit for a Corporate Client',
|
||||
description:
|
||||
'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: arcumNovaImage,
|
||||
technologies: ['WCAG 2.2', 'WordPress', 'Avada', 'Accessibility'],
|
||||
'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.',
|
||||
image: placeholderImage,
|
||||
technologies: ['WCAG 2.2', 'BITV 2.0', 'EN 301 549', 'axe DevTools', 'WAVE'],
|
||||
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,
|
||||
title: 'gxplex.org',
|
||||
title: 'Custom MediaWiki Platform (GxPlex)',
|
||||
description:
|
||||
'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: gxplexImage,
|
||||
technologies: ['MediaWiki 1.43.8', 'Semantic MediaWiki', 'Cavendish', 'Rating function'],
|
||||
'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.',
|
||||
image: placeholderImage,
|
||||
technologies: ['MediaWiki', 'MySQL', 'PHP', 'SSL'],
|
||||
year: '2026',
|
||||
live: 'https://gxplex.org',
|
||||
},
|
||||
{
|
||||
id: 7,
|
||||
|
|
|
|||
|
|
@ -7,7 +7,7 @@ export const personalConfig = {
|
|||
email: {
|
||||
user: 'freelancer',
|
||||
domain: 'sascha-bach.de',
|
||||
full: 'freelancer@sascha-bach.de',
|
||||
full: 'freelancer [at] sascha-bach.de',
|
||||
},
|
||||
|
||||
// Booking
|
||||
|
|
|
|||
|
|
@ -3,8 +3,6 @@ import { en, type TextConfig } from '../config/locales/en';
|
|||
import { de } from '../config/locales/de';
|
||||
import type { Language } from '../data/types';
|
||||
|
||||
const LANGUAGE_STORAGE_KEY = 'preferred-language';
|
||||
|
||||
interface LanguageContextType {
|
||||
language: Language;
|
||||
setLanguage: (lang: Language) => void;
|
||||
|
|
@ -13,44 +11,25 @@ interface LanguageContextType {
|
|||
|
||||
const LanguageContext = createContext<LanguageContextType | undefined>(undefined);
|
||||
|
||||
function normalizeLanguage(value: string | null): Language | null {
|
||||
if (value === 'de' || value === 'en') return value;
|
||||
/** Derive the language from the URL pathname prefix (/en/… or /de/…). */
|
||||
export function getLanguageFromPath(pathname: string): Language | null {
|
||||
if (pathname.startsWith('/en')) return 'en';
|
||||
if (pathname.startsWith('/de')) return 'de';
|
||||
return null;
|
||||
}
|
||||
|
||||
function getStoredLanguage(): Language | null {
|
||||
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());
|
||||
export function LanguageProvider({ initialLanguage = 'de', children }: Readonly<{ initialLanguage?: Language; children: ReactNode; }>) {
|
||||
const [language, setLanguage] = useState<Language>(initialLanguage);
|
||||
|
||||
const texts = language === 'de' ? de : en;
|
||||
|
||||
const handleSetLanguage = (lang: Language) => {
|
||||
setLanguage(lang);
|
||||
document.documentElement.lang = lang;
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
document.documentElement.lang = language;
|
||||
try {
|
||||
globalThis.localStorage?.setItem(LANGUAGE_STORAGE_KEY, language);
|
||||
} catch {
|
||||
// Ignore storage errors (private mode, denied storage, etc.).
|
||||
}
|
||||
}, [language]);
|
||||
|
||||
return (
|
||||
|
|
|
|||
|
|
@ -8,7 +8,7 @@ import { PageSeo } from '../components/PageSeo';
|
|||
import { useLanguage } from '../contexts/LanguageContext';
|
||||
|
||||
export default function AuditPage() {
|
||||
const { texts } = useLanguage();
|
||||
const { language, texts } = useLanguage();
|
||||
const t = texts.audit;
|
||||
|
||||
return (
|
||||
|
|
@ -16,7 +16,7 @@ export default function AuditPage() {
|
|||
<PageSeo
|
||||
title={t.seoTitle}
|
||||
description={t.seoDescription}
|
||||
canonical="/audit"
|
||||
canonical={`/${language}/audit`}
|
||||
/>
|
||||
<AuditHeroSection />
|
||||
<AuditProcessSection />
|
||||
|
|
|
|||
|
|
@ -4,7 +4,6 @@ import ServicesSection from '../components/sections/ServicesSection';
|
|||
import SkillsSection from '../components/sections/SkillsSection';
|
||||
import CertificationsSection from '../components/sections/CertificationsSection';
|
||||
import ProjectsSection from '../components/sections/ProjectsSection';
|
||||
import AuditTeaserSection from '../components/landing/AuditTeaserSection';
|
||||
import ContactSection from '../components/sections/ContactSection';
|
||||
import { PageSeo } from '../components/PageSeo';
|
||||
import { useLanguage } from '../contexts/LanguageContext';
|
||||
|
|
@ -21,7 +20,7 @@ export default function HomePage() {
|
|||
description={language === 'de'
|
||||
? '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.'}
|
||||
canonical="/technical"
|
||||
canonical={`/${language}/technical`}
|
||||
/>
|
||||
<HeroSection />
|
||||
<AboutSection />
|
||||
|
|
@ -29,7 +28,6 @@ export default function HomePage() {
|
|||
<SkillsSection />
|
||||
<CertificationsSection />
|
||||
<ProjectsSection />
|
||||
<AuditTeaserSection />
|
||||
<ContactSection />
|
||||
</>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -82,6 +82,170 @@ export default function ImprintPage() {
|
|||
</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>
|
||||
</section>
|
||||
|
|
|
|||
|
|
@ -24,7 +24,7 @@ export default function LandingPage() {
|
|||
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 software developer in Germany specializing in accessible web development. Building inclusive websites compliant with the Accessibility Act.'}
|
||||
canonical="/"
|
||||
canonical={`/${language}/`}
|
||||
/>
|
||||
<HeroSection
|
||||
title={t.title}
|
||||
|
|
|
|||
|
|
@ -3,143 +3,65 @@ import { PageSeo } from '../components/PageSeo';
|
|||
|
||||
export default function PrivacyPolicy() {
|
||||
const { texts: allTexts } = useLanguage();
|
||||
const texts = allTexts.imprint;
|
||||
const privacy = texts.detailedPrivacyPolicy;
|
||||
const texts = allTexts.privacyPolicy;
|
||||
|
||||
return (
|
||||
<section className="imprint-page">
|
||||
<div className="privacy-policy" style={{ maxWidth: '800px', margin: '0 auto', padding: '2rem' }}>
|
||||
<PageSeo
|
||||
title="Privacy Policy | Sascha Bach"
|
||||
description="Privacy policy for the website of Sascha Bach, freelance software developer."
|
||||
canonical="/privacy-policy"
|
||||
noIndex
|
||||
/>
|
||||
<div className="imprint-page__container">
|
||||
<div className="imprint-page__header">
|
||||
<h1 className="imprint-page__title">{privacy.title}</h1>
|
||||
</div>
|
||||
<h1>{texts.title}</h1>
|
||||
<p><strong>{texts.lastUpdated}</strong></p>
|
||||
|
||||
<div className="imprint-page__content">
|
||||
<div className="imprint-page__section">
|
||||
<div className="imprint-page__privacy-content">
|
||||
<p className="imprint-page__privacy-text">{privacy.introduction}</p>
|
||||
<h2>{texts.introTitle}</h2>
|
||||
<p>{texts.introText}</p>
|
||||
|
||||
<div className="imprint-page__privacy-section">
|
||||
<h2 className="imprint-page__privacy-subtitle">{privacy.responsibleTitle}</h2>
|
||||
<p className="imprint-page__privacy-text">{privacy.responsibleText}</p>
|
||||
<div className="imprint-page__contact-info">
|
||||
{privacy.responsibleContact.split('\n').map((line) => (
|
||||
<p key={line} className="imprint-page__contact-line">{line}</p>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
<h2>{texts.dataCollectionTitle}</h2>
|
||||
<p>{texts.dataCollectionText}</p>
|
||||
<ul>
|
||||
{texts.dataCollectionList.map((item, index) => (
|
||||
<li key={index}>{item}</li>
|
||||
))}
|
||||
</ul>
|
||||
|
||||
<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>
|
||||
<h2>{texts.auditFormTitle}</h2>
|
||||
<p>{texts.auditFormText}</p>
|
||||
<p><strong>{texts.auditFormDataTitle}</strong></p>
|
||||
<ul>
|
||||
{texts.auditFormDataList.map((item) => (
|
||||
<li key={item}>{item}</li>
|
||||
))}
|
||||
</ul>
|
||||
<p><strong>{texts.auditFormPurposeLabel}</strong> {texts.auditFormPurpose}</p>
|
||||
<p><strong>{texts.auditFormLegalBasisLabel}</strong> {texts.auditFormLegalBasis}</p>
|
||||
<p><strong>{texts.auditFormRetentionLabel}</strong> {texts.auditFormRetention}</p>
|
||||
<p><strong>{texts.auditFormSecurityLabel}</strong> {texts.auditFormSecurity}</p>
|
||||
|
||||
<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>
|
||||
<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.webhostingTitle}</h2>
|
||||
<p>{texts.webhostingText}</p>
|
||||
<p>
|
||||
<strong>{texts.webhostingProvider}</strong><br />
|
||||
{texts.webhostingProviderName}<br />
|
||||
{texts.webhostingProviderAddress}<br />
|
||||
<a href={texts.webhostingProviderWebsite} target="_blank" rel="noopener noreferrer">
|
||||
{texts.webhostingProviderWebsite}
|
||||
</a>
|
||||
</p>
|
||||
|
||||
<div className="imprint-page__privacy-section">
|
||||
<h2 className="imprint-page__privacy-subtitle">{privacy.dataDeletionTitle}</h2>
|
||||
<p className="imprint-page__privacy-text">{privacy.dataDeletionIntro}</p>
|
||||
<ul className="imprint-page__deletion-list">
|
||||
{privacy.dataDeletionReasons.map((reason) => <li key={reason}>{reason}</li>)}
|
||||
</ul>
|
||||
<p className="imprint-page__privacy-text">{privacy.retentionText}</p>
|
||||
</div>
|
||||
<h2>{texts.rightsTitle}</h2>
|
||||
<p>{texts.rightsText}</p>
|
||||
<ul>
|
||||
{texts.rightsList.map((right, index) => (
|
||||
<li key={index}>{right}</li>
|
||||
))}
|
||||
</ul>
|
||||
|
||||
<div className="imprint-page__privacy-section">
|
||||
<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>
|
||||
{privacy.hostingProvider.privacyPolicyLabel}{' '}
|
||||
<a href={privacy.hostingProvider.website} target="_blank" rel="noopener noreferrer">
|
||||
{privacy.hostingProvider.website}
|
||||
</a>
|
||||
</p>
|
||||
</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>
|
||||
<h2>{texts.contactTitle}</h2>
|
||||
<p>{texts.contactText}</p>
|
||||
<p><strong>Email:</strong> {texts.contactEmail}</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -1,203 +0,0 @@
|
|||
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,8 +60,7 @@
|
|||
font-size: 0.875rem;
|
||||
text-align: center;
|
||||
margin: 0;
|
||||
flex-basis: 100%;
|
||||
order: 99;
|
||||
opacity: 0.7;
|
||||
}
|
||||
|
||||
&__social {
|
||||
|
|
|
|||
|
|
@ -11,6 +11,5 @@
|
|||
@forward 'certifications-section';
|
||||
@forward 'projects-section';
|
||||
@forward 'contact-section';
|
||||
@forward 'quickcheck-page';
|
||||
@forward 'imprint-page';
|
||||
@forward 'audit-page';
|
||||
|
|
|
|||
|
|
@ -104,12 +104,7 @@
|
|||
}
|
||||
|
||||
&__cta {
|
||||
display: inline-flex;
|
||||
width: 100%;
|
||||
min-height: 3.5rem;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
margin-top: auto;
|
||||
display: inline-block;
|
||||
padding: var(--space-4) var(--space-8);
|
||||
border-radius: var(--radius-lg);
|
||||
background: var(--contact-button-bg);
|
||||
|
|
@ -444,37 +439,6 @@
|
|||
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 {
|
||||
@include audit-title;
|
||||
}
|
||||
|
|
@ -484,7 +448,6 @@
|
|||
line-height: var(--leading-relaxed);
|
||||
color: var(--color-text);
|
||||
max-width: 34rem;
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
&__cta {
|
||||
|
|
@ -497,10 +460,14 @@
|
|||
font-size: var(--font-size-lg);
|
||||
font-weight: 600;
|
||||
text-decoration: none;
|
||||
transition: background var(--transition-base);
|
||||
box-shadow: var(--shadow-md);
|
||||
transition:
|
||||
background var(--transition-base),
|
||||
transform var(--transition-base);
|
||||
|
||||
&:hover {
|
||||
background: var(--contact-button-hover-bg);
|
||||
transform: translateY(-2px);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,300 +0,0 @@
|
|||
@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,22 +2,23 @@ import type { NavigateFunction } from 'react-router-dom';
|
|||
|
||||
// Offset to account for sticky navbar height (65px) plus some padding
|
||||
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 {
|
||||
return pathname === LANDING_PATH || pathname === TECHNICAL_PATH;
|
||||
return (
|
||||
pathname === '/de/' ||
|
||||
pathname === '/en/' ||
|
||||
pathname === '/de/technical' ||
|
||||
pathname === '/en/technical' ||
|
||||
// legacy fallbacks
|
||||
pathname === '/' ||
|
||||
pathname === '/technical'
|
||||
);
|
||||
}
|
||||
|
||||
function getSectionTargetPath(): string {
|
||||
try {
|
||||
return sessionStorage.getItem(LAST_MAIN_PAGE_KEY) === TECHNICAL_PATH
|
||||
? TECHNICAL_PATH
|
||||
: LANDING_PATH;
|
||||
} catch {
|
||||
return LANDING_PATH;
|
||||
}
|
||||
/** Returns /de/technical or /en/technical depending on the current path prefix. */
|
||||
function getTechnicalPath(currentPath: string): string {
|
||||
if (currentPath.startsWith('/en')) return '/en/technical';
|
||||
return '/de/technical';
|
||||
}
|
||||
|
||||
export function scrollToSection(
|
||||
|
|
@ -37,9 +38,9 @@ export function scrollToSection(
|
|||
});
|
||||
}
|
||||
} else if (navigate) {
|
||||
navigate(`${getSectionTargetPath()}#${sectionId}`);
|
||||
navigate(`${getTechnicalPath(currentPath)}#${sectionId}`);
|
||||
} else {
|
||||
globalThis.location.href = `${getSectionTargetPath()}#${sectionId}`;
|
||||
globalThis.location.href = `${getTechnicalPath(currentPath)}#${sectionId}`;
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
Loading…
Reference in New Issue