diff --git a/backend/routes/audit.js b/backend/routes/audit.js index 8c62264..ab0c4b5 100644 --- a/backend/routes/audit.js +++ b/backend/routes/audit.js @@ -1,7 +1,7 @@ import express from 'express' import rateLimit from 'express-rate-limit' import { body, validationResult } from 'express-validator' -import { sendAuditRequestEmail } from '../services/emailService.js' +import { sendAuditRequestEmail, sendQuickcheckRequestEmail } from '../services/emailService.js' const router = express.Router() @@ -19,6 +19,7 @@ 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 }), @@ -37,7 +38,26 @@ const validateAuditRequest = [ body('website').optional().isEmpty(), // Honeypot ] -router.post('/', auditLimiter, validateAuditRequest, async (req, res) => { +const validateQuickcheckRequest = [ + body('formType').equals('quickcheck'), + body('name').optional().trim().isLength({ max: 100 }).escape(), + body('email').trim().isEmail().normalizeEmail().isLength({ max: 254 }), + body('businessConfirmation') + .equals('true') + .withMessage('Business (B2B) confirmation required'), + body('gdprConsent').equals('true').withMessage('GDPR consent required'), + body('website').optional().isEmpty(), // Honeypot +] + +const validateRequest = (req, _res, next) => { + const validators = req.body.formType === 'quickcheck' + ? validateQuickcheckRequest + : validateAuditRequest + + Promise.all(validators.map((validator) => validator.run(req))).then(() => next()) +} + +router.post('/', auditLimiter, validateRequest, async (req, res) => { try { const errors = validationResult(req) if (!errors.isEmpty()) { @@ -49,17 +69,7 @@ router.post('/', auditLimiter, validateAuditRequest, async (req, res) => { }) } - const { - name, - company, - email, - url, - role, - motivation, - businessConfirmation, - gdprConsent, - website, - } = req.body + const { formType = 'audit', name, email, businessConfirmation, gdprConsent, website } = req.body if (website) { console.warn('Honeypot triggered, discarding submission.') @@ -83,6 +93,17 @@ router.post('/', auditLimiter, validateAuditRequest, async (req, res) => { }) } + if (formType === 'quickcheck') { + await sendQuickcheckRequestEmail({ name: name || '', email }) + + return res.json({ + success: true, + message: 'Thank you, your quick check request arrived.', + }) + } + + const { company, url, role, motivation } = req.body + await sendAuditRequestEmail({ name, company, email, url, role, motivation }) res.json({ diff --git a/backend/services/emailService.js b/backend/services/emailService.js index 179e42b..b1c2311 100644 --- a/backend/services/emailService.js +++ b/backend/services/emailService.js @@ -115,3 +115,42 @@ 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: ` +

Neue Schnellcheck-Anfrage

+
+
Name
${escapeHtml(safeName)}
+
E-Mail
${escapeHtml(safeEmail)}
+
B2B
bestätigt (§ 14 BGB)
+
Datenschutz
bestätigt
+
+ `, + }) + + console.log('Quickcheck request forwarded.') +} diff --git a/prerender.js b/prerender.js index 901c383..71dc787 100644 --- a/prerender.js +++ b/prerender.js @@ -17,12 +17,10 @@ const DIST = join(__dirname, 'dist') const PORT = 4173 const ROUTES = [ - '/de/', - '/de/technical', - '/de/audit', - '/en/', - '/en/technical', - '/en/audit', + '/', + '/technical', + '/audit', + '/schnellcheck', '/imprint', '/privacy-policy', ] diff --git a/public/sitemap.xml b/public/sitemap.xml index 42ff641..31b94a8 100644 --- a/public/sitemap.xml +++ b/public/sitemap.xml @@ -1,63 +1,20 @@ - - + - https://sascha-bach.de/de/ - - - - 2025-06-28 + https://sascha-bach.de/ + 2026-08-23 monthly 1.0 - - https://sascha-bach.de/en/ - - - - 2025-06-28 + https://sascha-bach.de/technical + 2026-08-23 monthly 0.9 - - https://sascha-bach.de/de/technical - - - - 2025-06-28 - monthly - 0.8 - - - - https://sascha-bach.de/en/technical - - - - 2025-06-28 - monthly - 0.7 - - - - https://sascha-bach.de/de/audit - - - - 2026-07-29 - monthly - 0.9 - - - - https://sascha-bach.de/en/audit - - - - 2026-07-29 + https://sascha-bach.de/audit + 2026-08-23 monthly 0.8 diff --git a/src/app/AppRouter.tsx b/src/app/AppRouter.tsx index ac559b5..7df7d1e 100644 --- a/src/app/AppRouter.tsx +++ b/src/app/AppRouter.tsx @@ -4,27 +4,32 @@ import '../scss/App.scss'; import Navbar from '../components/layout/Navigation'; import Footer from '../components/layout/Footer'; import BackToTopButton from '../components/BackToTopButton'; -import { getLanguageFromPath, useLanguage } from '../contexts/LanguageContext'; +import { 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')); -/** Syncs the LanguageContext whenever the URL prefix (/de/ or /en/) changes. */ -function LanguageSyncer() { - const { setLanguage, language } = useLanguage(); +function LegacyLanguageRedirect() { + const { setLanguage } = useLanguage(); const location = useLocation(); - useEffect(() => { - const urlLang = getLanguageFromPath(location.pathname); - if (urlLang && urlLang !== language) { - setLanguage(urlLang); - } - }, [location.pathname, language, setLanguage]); + const legacyLanguage = location.pathname.startsWith('/en') ? 'en' : 'de'; + const targetPathname = location.pathname.replace(/^\/(de|en)(?=\/|$)/, '') || '/'; - return null; + useEffect(() => { + setLanguage(legacyLanguage); + }, [legacyLanguage, setLanguage]); + + return ( + + ); } function AppShell() { @@ -48,28 +53,24 @@ function AppShell() { {texts.accessibility.skipLinks.skipToHero} -
Loading…

}> - {/* Root redirects → German (primary market) */} - } /> - } /> + {/* Legacy locale-prefixed URLs → clean URLs */} + } /> + } /> - {/* German routes */} - } /> - } /> - } /> - - {/* English routes */} - } /> - } /> - } /> + {/* Canonical, language-neutral routes */} + } /> + } /> + } /> + } /> {/* Shared pages (noindex, language-independent) */} } /> } /> + } />
diff --git a/src/app/PortfolioApp.tsx b/src/app/PortfolioApp.tsx index ebbc47a..0349dbb 100644 --- a/src/app/PortfolioApp.tsx +++ b/src/app/PortfolioApp.tsx @@ -1,9 +1,9 @@ -import { LanguageProvider, getLanguageFromPath } from '../contexts/LanguageContext'; +import { LanguageProvider, detectInitialLanguage } from '../contexts/LanguageContext'; import { ThemeProvider } from '../contexts/ThemeContext'; import AppRouter from './AppRouter'; function PortfolioApp() { - const initialLanguage = getLanguageFromPath(globalThis.location.pathname) ?? 'de'; + const initialLanguage = detectInitialLanguage(); return ( diff --git a/src/app/__tests__/a11y.smoke.test.tsx b/src/app/__tests__/a11y.smoke.test.tsx index ebe6fed..0e02d3d 100644 --- a/src/app/__tests__/a11y.smoke.test.tsx +++ b/src/app/__tests__/a11y.smoke.test.tsx @@ -8,6 +8,8 @@ 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) { @@ -36,10 +38,30 @@ describe('accessibility smoke test', () => { expect(await axe(container)).toHaveNoViolations(); }); - it('Footer keeps the active language for the portfolio link', () => { + it('Footer links to the clean technical portfolio URL', () => { renderPage(