feat: add free accessibility audit landing page (B2B)

Adds /de/audit and /en/audit with a lead-magnet page (WCAG audit for
free, PDF report + 15min walkthrough) and a request form (name,
company, email, URL, agency/client, motivation), including the
mandatory § 14 BGB business-only notice and confirmation checkbox.

Also, since the site turned out to run exclusively on the Bitpalast
static host rather than Vercel:
- remove vercel.json, port its security headers, agent-discovery
  Link header and .well-known CORS/cache headers into public/.htaccess
- fix the .htaccess rewrite so prerendered routes are served instead of
  falling back to the empty SPA shell
- self-host Comfortaa/Quicksand via Fontsource instead of Google Fonts
- add a separate backend/ Express service for the audit endpoint,
  meant to run on the IONOS VPS independently of the static site
- update the privacy policy to reflect the form and font hosting

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
Sascha 2026-07-29 17:43:50 +02:00
parent 91a6180f44
commit ee8ca1beb7
55 changed files with 3721 additions and 489 deletions

BIN
.gitignore vendored

Binary file not shown.

View File

@ -20,31 +20,34 @@ This portfolio website serves as a comprehensive showcase of professional experi
- **Projects Section**: Portfolio of completed projects with descriptions and links
- **Certifications Section**: Professional certifications and achievements
- **Services Section**: Available professional services
- **Contact Section**: Functional contact form with backend email integration
- **Contact Section**: Email and appointment links (no form - the address is obfuscated)
- **Accessibility Audit Landing Page**: `/de/audit` and `/en/audit`, with a request form
- **Multi-page Routing**: React Router with smooth hash navigation between sections
- **Legal Compliance**: German Impressum page for legal requirements
- **Theme Support**: Built-in light/dark theme toggle with persistence
- **Responsive Navigation**: Mobile-friendly navigation with hamburger menu
- **Backend Integration**: Node.js/Express server with Gmail SMTP for contact form
- **Audit Endpoint**: Separate Node.js/Express service, deployed on its own (see `backend/README.md`)
- **Enhanced UX**: Detailed error handling, loading states, and user feedback
## Technology Stack
### Frontend
- **React 18** with TypeScript for type-safe component development
- **React 19** with TypeScript for type-safe component development
- **Vite** for fast development and optimized production builds
- **React Router Dom** for multi-page navigation and hash-based scrolling
- **SCSS** with modular architecture and theme system
- **Lucide React** for consistent iconography
- **Fontsource** for self-hosted Comfortaa and Quicksand (no Google Fonts CDN)
### Backend
### Backend (`backend/`, deployed separately)
- **Node.js** with Express.js for RESTful API
- **Nodemailer** with Gmail SMTP integration for contact form
- **Joi** for request validation and data sanitization
- **Node.js** with Express.js, serving only the audit request endpoint
- **Nodemailer** over SMTP
- **express-validator** for request validation and sanitisation
- **Express Rate Limit** for API protection and spam prevention
- **CORS** for secure cross-origin requests
- **CORS** restricted to an explicit origin allowlist
- **Helmet** for response security headers
### Development & Quality
@ -116,38 +119,30 @@ npm install
npm run dev
```
4. Set up the backend (for contact form functionality):
4. Open your browser and navigate to `http://localhost:5173`
### Audit endpoint (only needed to work on the audit form)
```bash
cd backend
npm install
cp .env.example .env # then fill in the SMTP credentials
npm start
```
5. Create a `.env` file in the backend directory with your Gmail credentials:
Point the frontend at it with `VITE_AUDIT_ENDPOINT` in a `.env` at the project
root, for example `VITE_AUDIT_ENDPOINT=http://localhost:3001/api/audit-request`.
```env
EMAIL_SERVICE=gmail
EMAIL_USER=your-email@gmail.com
EMAIL_PASS=your-app-password
RECIPIENT_EMAIL=your-email@gmail.com
RECIPIENT_NAME=Your Name
FRONTEND_URL=http://localhost:5173
```
6. Start the backend server:
```bash
node server.js
```
7. Open your browser and navigate to `http://localhost:5173`
See `backend/README.md` for deployment and the protections in place.
### Backend Setup Notes
- The contact form requires a Gmail account with an app-specific password
- Generate an app password in your Google Account settings (2-factor authentication required)
- The backend server runs on port 3002 by default
- CORS is configured to allow requests from the frontend development server
- The service is deployed separately from the static site - it runs on the VPS,
not on the web host that serves `dist/`
- It binds to `127.0.0.1` and expects nginx in front of it for TLS
- `ALLOWED_ORIGINS` is an explicit allowlist; requests from other origins are refused
- The CSP `connect-src` in `public/.htaccess` must name the endpoint's host,
otherwise the browser blocks the request
### Building for Production

18
backend/.env.example Normal file
View File

@ -0,0 +1,18 @@
# Copy to .env on the VPS. Never commit the real file.
# Port the Node process binds to (localhost only, nginx proxies to it).
PORT=3001
# Comma separated list of origins allowed to POST to the endpoint.
ALLOWED_ORIGINS=https://sascha-bach.de,https://www.sascha-bach.de
# Where audit requests are delivered.
AUDIT_RECIPIENT=freelancer@sascha-bach.de
# SMTP credentials of the sending mailbox.
SMTP_HOST=
SMTP_PORT=587
SMTP_USER=
SMTP_PASS=
# Envelope sender. Defaults to SMTP_USER when unset.
SMTP_FROM=

67
backend/README.md Normal file
View File

@ -0,0 +1,67 @@
# Audit request endpoint
Small Express service that receives the form on `/de/audit` and forwards it by
email. It is deployed **separately** from the static site: the website is built
with Vite and uploaded by FTP, this service runs on the VPS.
Its dependencies are intentionally kept out of the frontend `package.json` so
they never end up in the browser bundle.
## Endpoints
| Method | Path | Purpose |
| ------ | --------------------- | ----------------------------- |
| `GET` | `/health` | Liveness probe |
| `POST` | `/api/audit-request` | Accepts one audit request |
Expected JSON body:
```json
{
"name": "…",
"email": "…",
"url": "https://…",
"role": "agency | client",
"motivation": "…",
"gdprConsent": "true",
"website": ""
}
```
`website` is a honeypot and must stay empty. A filled honeypot is answered with
`200` on purpose so bots learn nothing and do not retry.
## Setup
```bash
cd backend
npm install
cp .env.example .env # then fill in the SMTP credentials
npm start
```
The process binds to `127.0.0.1` only. Put nginx in front of it with TLS, for
example on `api.sascha-bach.de`, and proxy to the port from `.env`.
Then point the frontend at it by setting `VITE_AUDIT_ENDPOINT` before building:
```bash
VITE_AUDIT_ENDPOINT=https://api.sascha-bach.de/api/audit-request npm run build
```
If the CSP `connect-src` in `public/.htaccess` names a different host than the
one you deploy to, the browser blocks the request - keep the two in sync.
## Protections in place
- CORS allowlist from `ALLOWED_ORIGINS`, never `*`
- Rate limit: 3 requests per IP per 15 minutes
- Request body capped at 10 kB
- Server-side validation of every field (`express-validator`)
- Honeypot field
- Consent is required, not assumed
- CR/LF stripped from values used in mail headers, HTML escaped in the body
- No personal data written to logs
`app.set('trust proxy', 1)` assumes exactly one proxy hop (nginx). Adjust it if
you add a second one, otherwise the rate limiter sees a single IP for everyone.

View File

@ -0,0 +1,235 @@
# Deploying the audit endpoint on the IONOS VPS
The website itself stays on the Bitpalast webspace (static files via FTP). Only
this endpoint runs on the VPS, alongside the existing Git and Nextcloud
services. Nothing here modifies their configuration.
Assumes Debian or Ubuntu with `sudo`. Adjust package commands for other
distributions.
---
## 0. DNS
Create an **A record** `api.sascha-bach.de` pointing at the VPS IP address
(and an AAAA record if the VPS has IPv6). Wait until it resolves:
```bash
dig +short api.sascha-bach.de
```
Nothing below works until this answers.
---
## 1. Survey the machine first
```bash
ssh <user>@<vps>
# Which web server is in front? The nginx config below assumes nginx.
systemctl is-active nginx apache2 2>/dev/null
# Is port 3001 free? Gitea commonly sits on 3000, so check before assuming.
sudo ss -tlnp | grep -E ':(3000|3001)\b' || echo "3001 frei"
# Node 20 or newer present?
node --version 2>/dev/null || echo "Node fehlt"
```
If **Apache** is in front instead of nginx, skip step 6 and set up an Apache
`ProxyPass` for `api.sascha-bach.de` instead — the rest is unchanged.
If **3001 is taken**, pick another free port and change it in three places:
`.env` (`PORT`), the nginx `proxy_pass` lines, and nothing else.
---
## 2. Install Node (only if missing)
```bash
curl -fsSL https://deb.nodesource.com/setup_22.x | sudo -E bash -
sudo apt-get install -y nodejs
node --version # must be >= 20
```
---
## 3. Dedicated service user
The endpoint must not run as root and must not share the Git user's account.
```bash
sudo useradd --system --create-home --home-dir /var/lib/auditapi \
--shell /usr/sbin/nologin auditapi
```
---
## 4. Fetch the code
```bash
sudo mkdir -p /opt/audit-endpoint
sudo chown auditapi:auditapi /opt/audit-endpoint
sudo -u auditapi git clone https://git.sascha-bach.de/saschabach/portfolio-page.git \
/opt/audit-endpoint
```
If the repository is private, use a deploy token or an SSH key belonging to
`auditapi`.
```bash
cd /opt/audit-endpoint/backend
sudo -u auditapi npm install --omit=dev
```
---
## 5. Configuration
```bash
sudo -u auditapi cp /opt/audit-endpoint/backend/.env.example \
/opt/audit-endpoint/backend/.env
sudo -u auditapi nano /opt/audit-endpoint/backend/.env
```
Fill in:
| Variable | Value |
| --- | --- |
| `PORT` | `3001` (or the free port from step 1) |
| `ALLOWED_ORIGINS` | `https://sascha-bach.de,https://www.sascha-bach.de` — the **website** origin, not the API origin |
| `AUDIT_RECIPIENT` | where requests are delivered |
| `SMTP_HOST` / `SMTP_PORT` / `SMTP_USER` / `SMTP_PASS` | mailbox credentials |
| `SMTP_FROM` | an address **on your own domain**, so SPF and DMARC pass |
Lock the file down — it holds the mailbox password:
```bash
sudo chmod 600 /opt/audit-endpoint/backend/.env
sudo chown auditapi:auditapi /opt/audit-endpoint/backend/.env
```
Smoke-test before wiring up systemd:
```bash
cd /opt/audit-endpoint/backend && sudo -u auditapi node server.js
# expect: Audit endpoint listening on 127.0.0.1:3001
# Ctrl+C
```
---
## 6. systemd service
```bash
sudo cp /opt/audit-endpoint/backend/deploy/audit-endpoint.service \
/etc/systemd/system/
sudo systemctl daemon-reload
sudo systemctl enable --now audit-endpoint
systemctl status audit-endpoint --no-pager
curl -s http://127.0.0.1:3001/health # → {"status":"ok"}
```
---
## 7. nginx vhost
Adding a file and reloading never touches the running Git or Nextcloud vhosts.
```bash
sudo cp /opt/audit-endpoint/backend/deploy/nginx-api.sascha-bach.de.conf \
/etc/nginx/sites-available/api.sascha-bach.de
sudo ln -s /etc/nginx/sites-available/api.sascha-bach.de \
/etc/nginx/sites-enabled/
# Validates the WHOLE config. If this fails, nothing has changed yet.
sudo nginx -t
# reload, not restart - existing connections to git/nextcloud survive
sudo systemctl reload nginx
```
---
## 8. TLS certificate
```bash
sudo apt-get install -y certbot python3-certbot-nginx # if missing
sudo certbot --nginx -d api.sascha-bach.de
```
certbot only edits the `api.sascha-bach.de` block. Verify renewal works:
```bash
sudo certbot renew --dry-run
```
---
## 9. Verify from outside
```bash
curl -sI https://api.sascha-bach.de/health # → HTTP/2 200
curl -s -X POST https://api.sascha-bach.de/api/audit-request \
-H 'Content-Type: application/json' \
-H 'Origin: https://sascha-bach.de' \
-d '{"name":"Test","company":"Test GmbH","email":"du@example.de",
"url":"https://example.de","role":"client","motivation":"Test",
"businessConfirmation":"true","gdprConsent":"true","website":""}'
# → {"success":true,...} and an email arrives
```
Then confirm the protections actually bite:
```bash
# Foreign origin → rejected
curl -s -X POST https://api.sascha-bach.de/api/audit-request \
-H 'Content-Type: application/json' -H 'Origin: https://evil.example' \
-d '{}' -o /dev/null -w '%{http_code}\n'
# Fourth request within 15 minutes → 429
```
---
## 10. Point the website at it
Back on the workstation, in the project root:
```bash
echo 'VITE_AUDIT_ENDPOINT=https://api.sascha-bach.de/api/audit-request' > .env
npm run deploy
```
Vite bakes the value in at build time, so this must happen **before** the
upload. `.env` is gitignored.
The hostname must match in three places or the browser blocks the request:
- `public/.htaccess``connect-src 'self' https://api.sascha-bach.de`
- `VITE_AUDIT_ENDPOINT`
- the nginx `server_name` and its certificate
---
## Updating later
```bash
cd /opt/audit-endpoint
sudo -u auditapi git pull
cd backend && sudo -u auditapi npm install --omit=dev
sudo systemctl restart audit-endpoint
```
## Logs
```bash
journalctl -u audit-endpoint -f
```
Request contents are never logged, only that a request was forwarded — see the
note in `services/emailService.js`.

View File

@ -0,0 +1,45 @@
[Unit]
Description=Audit request endpoint for sascha-bach.de
Documentation=https://git.sascha-bach.de/saschabach/portfolio-page
After=network-online.target
Wants=network-online.target
[Service]
Type=simple
User=auditapi
Group=auditapi
WorkingDirectory=/opt/audit-endpoint/backend
ExecStart=/usr/bin/node server.js
Restart=always
RestartSec=5
# No EnvironmentFile on purpose: server.js already loads .env through dotenv,
# and dotenv handles quoting and special characters in SMTP passwords more
# predictably than systemd's parser.
# --- Hardening -------------------------------------------------------------
# The service needs exactly three things: read its own code, listen on
# loopback, open an outbound SMTP connection. Everything else is denied.
NoNewPrivileges=true
PrivateTmp=true
PrivateDevices=true
ProtectSystem=strict
ProtectHome=true
ProtectKernelTunables=true
ProtectKernelModules=true
ProtectControlGroups=true
ProtectHostname=true
ProtectClock=true
RestrictAddressFamilies=AF_INET AF_INET6
RestrictNamespaces=true
RestrictRealtime=true
RestrictSUIDSGID=true
LockPersonality=true
SystemCallArchitectures=native
# Node's JIT needs writable+executable memory, so this one stays off.
# Setting it to true makes V8 fail to start.
MemoryDenyWriteExecute=false
[Install]
WantedBy=multi-user.target

View File

@ -0,0 +1,63 @@
# Reverse proxy for the audit request endpoint.
#
# Deliberately a separate server block on its own hostname, so it does not
# touch the existing git / nextcloud vhosts on this machine. Reload nginx
# rather than restarting it, and the other services never drop a connection.
#
# Install to /etc/nginx/sites-available/api.sascha-bach.de and symlink into
# sites-enabled. certbot --nginx rewrites the TLS parts of this file.
server {
listen 80;
listen [::]:80;
server_name api.sascha-bach.de;
# Needed once so certbot can answer the HTTP-01 challenge.
location /.well-known/acme-challenge/ {
root /var/www/html;
}
location / {
return 301 https://$host$request_uri;
}
}
server {
listen 443 ssl;
listen [::]:443 ssl;
http2 on;
server_name api.sascha-bach.de;
# certbot inserts ssl_certificate / ssl_certificate_key here.
# The Node process caps bodies at 10 kB. Reject oversized ones at the edge
# so they never reach it.
client_max_body_size 16k;
# Only these two paths exist. Everything else is refused rather than
# forwarded, which keeps the attack surface to what the form needs.
location = /api/audit-request {
proxy_pass http://127.0.0.1:3001;
proxy_http_version 1.1;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
# Required: server.js runs with `trust proxy 1` and derives the
# rate-limit key from this header. Without it every visitor shares
# one bucket and three submissions lock out everyone for 15 minutes.
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_read_timeout 30s;
}
location = /health {
proxy_pass http://127.0.0.1:3001;
proxy_http_version 1.1;
proxy_set_header Host $host;
proxy_set_header X-Forwarded-Proto $scheme;
access_log off;
}
location / {
return 404;
}
}

24
backend/package.json Normal file
View File

@ -0,0 +1,24 @@
{
"name": "portfolio-audit-endpoint",
"private": true,
"version": "1.0.0",
"type": "module",
"description": "Audit request endpoint for sascha-bach.de. Deployed separately from the static site.",
"main": "server.js",
"scripts": {
"start": "node server.js",
"dev": "node --watch server.js"
},
"dependencies": {
"cors": "^2.8.5",
"dotenv": "^17.2.2",
"express": "^5.1.0",
"express-rate-limit": "^8.1.0",
"express-validator": "^7.2.1",
"helmet": "^8.1.0",
"nodemailer": "^7.0.6"
},
"engines": {
"node": ">=20"
}
}

100
backend/routes/audit.js Normal file
View File

@ -0,0 +1,100 @@
import express from 'express'
import rateLimit from 'express-rate-limit'
import { body, validationResult } from 'express-validator'
import { sendAuditRequestEmail } from '../services/emailService.js'
const router = express.Router()
const auditLimiter = rateLimit({
windowMs: 15 * 60 * 1000, // 15 minutes
max: 3, // per IP
message: {
success: false,
message: 'Too many audit requests from this IP, please try again later.',
},
standardHeaders: true,
legacyHeaders: false,
})
// 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('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 }),
body('url')
.trim()
.isURL({ protocols: ['http', 'https'], require_protocol: true })
.isLength({ max: 2048 }),
body('role').isIn(['agency', 'client']),
body('motivation').trim().isLength({ min: 1, max: 2000 }).escape(),
body('businessConfirmation')
.equals('true')
.withMessage('Business (B2B) confirmation required'),
body('gdprConsent').equals('true').withMessage('GDPR consent required'),
body('website').optional().isEmpty(), // Honeypot
]
router.post('/', auditLimiter, validateAuditRequest, async (req, res) => {
try {
const errors = validationResult(req)
if (!errors.isEmpty()) {
// Field names only - never echo the submitted values back into logs.
return res.status(400).json({
success: false,
message: 'Invalid form data.',
fields: errors.array().map((error) => error.path),
})
}
const {
name,
company,
email,
url,
role,
motivation,
businessConfirmation,
gdprConsent,
website,
} = req.body
if (website) {
console.warn('Honeypot triggered, discarding submission.')
// Answer 200 so the bot does not learn anything and does not retry.
return res.json({ success: true, message: 'Thank you.' })
}
// The offer is B2B only (§ 14 BGB). Without the explicit confirmation the
// request is refused rather than quietly accepted.
if (businessConfirmation !== 'true') {
return res.status(400).json({
success: false,
message: 'This offer is available to businesses only.',
})
}
if (gdprConsent !== 'true') {
return res.status(400).json({
success: false,
message: 'GDPR consent required.',
})
}
await sendAuditRequestEmail({ name, company, email, url, role, motivation })
res.json({
success: true,
message: 'Thank you, your audit request arrived.',
})
} catch (error) {
// Message only. The submitted data must never end up in server logs.
console.error('Audit request failed:', error.message)
res.status(500).json({
success: false,
message: 'Failed to send the request. Please try again later.',
})
}
})
export { router as auditRouter }

59
backend/server.js Normal file
View File

@ -0,0 +1,59 @@
import express from 'express'
import cors from 'cors'
import helmet from 'helmet'
import 'dotenv/config'
import { auditRouter } from './routes/audit.js'
import { env } from './services/env.js'
const app = express()
// Behind nginx, so trust exactly one proxy hop. Without this the rate limiter
// would see the proxy IP for everyone and throttle all visitors together.
app.set('trust proxy', 1)
app.use(helmet())
// An allowlist, never '*'. ALLOWED_ORIGINS is comma separated.
const allowedOrigins = (env('ALLOWED_ORIGINS', '') ?? '')
.split(',')
.map((origin) => origin.trim().replace(/\/$/, ''))
.filter(Boolean)
// An empty allowlist refuses every browser request, which looks exactly like
// a broken endpoint. Say so at startup instead of at 3am.
if (allowedOrigins.length === 0) {
console.warn(
'WARNING: ALLOWED_ORIGINS is empty - all cross-origin requests will be refused.'
)
}
app.use(
cors({
origin(origin, callback) {
// Same-origin and curl send no Origin header at all.
if (!origin) return callback(null, true)
if (allowedOrigins.includes(origin)) return callback(null, true)
callback(new Error('Origin not allowed'))
},
methods: ['POST'],
allowedHeaders: ['Content-Type'],
})
)
// The form payload is a few hundred bytes. Anything near this cap is abuse.
app.use(express.json({ limit: '10kb' }))
app.get('/health', (_req, res) => res.json({ status: 'ok' }))
app.use('/api/audit-request', auditRouter)
// Keep internals out of the response body.
app.use((error, _req, res, _next) => {
console.error('Unhandled error:', error.message)
res.status(500).json({ success: false, message: 'Internal server error.' })
})
const port = Number(env('PORT', '3001'))
app.listen(port, '127.0.0.1', () => {
console.log(`Audit endpoint listening on 127.0.0.1:${port}`)
})

View File

@ -0,0 +1,117 @@
import nodemailer from 'nodemailer'
import { env } from './env.js'
let cachedTransporter = null
function getTransporter() {
if (cachedTransporter) return cachedTransporter
const host = env('SMTP_HOST')
const user = env('SMTP_USER')
const pass = env('SMTP_PASS')
if (!host || !user || !pass) {
throw new Error('SMTP configuration is incomplete.')
}
const port = Number(env('SMTP_PORT', '587'))
if (!Number.isInteger(port) || port <= 0 || port > 65535) {
throw new Error(`SMTP_PORT is not a valid port: ${env('SMTP_PORT')}`)
}
cachedTransporter = nodemailer.createTransport({
host,
port,
secure: port === 465, // implicit TLS on 465, STARTTLS otherwise
auth: { user, pass },
})
return cachedTransporter
}
/**
* Strips CR and LF so a submitted value can never inject extra mail headers
* when it is used in Subject or Reply-To.
*/
function singleLine(value) {
return String(value).replace(/[\r\n]+/g, ' ').trim()
}
/** Minimal HTML escaping - the payload is attacker-controlled by definition. */
function escapeHtml(value) {
return String(value)
.replaceAll('&', '&amp;')
.replaceAll('<', '&lt;')
.replaceAll('>', '&gt;')
.replaceAll('"', '&quot;')
.replaceAll("'", '&#39;')
}
const ROLE_LABELS = {
agency: 'Agentur / Dienstleister',
client: 'Endkunde (eigene Website)',
}
export async function sendAuditRequestEmail({
name,
company,
email,
url,
role,
motivation,
}) {
const transporter = getTransporter()
const to = env('AUDIT_RECIPIENT')
// Falls back to the authenticated mailbox when SMTP_FROM is left blank.
const from = env('SMTP_FROM', env('SMTP_USER'))
if (!to) {
throw new Error('AUDIT_RECIPIENT is not configured.')
}
const safeName = singleLine(name)
const safeCompany = singleLine(company)
const safeEmail = singleLine(email)
const roleLabel = ROLE_LABELS[role] ?? role
const textBody = [
`Name: ${safeName}`,
`Firma: ${safeCompany}`,
`E-Mail: ${safeEmail}`,
`Seite: ${singleLine(url)}`,
`Typ: ${roleLabel}`,
'B2B: bestätigt (§ 14 BGB)',
'',
'Motivation:',
motivation,
].join('\n')
const htmlBody = `
<h2>Neue Audit-Anfrage</h2>
<dl>
<dt><strong>Name</strong></dt><dd>${escapeHtml(safeName)}</dd>
<dt><strong>Firma</strong></dt><dd>${escapeHtml(safeCompany)}</dd>
<dt><strong>E-Mail</strong></dt><dd>${escapeHtml(safeEmail)}</dd>
<dt><strong>Seite</strong></dt><dd>${escapeHtml(singleLine(url))}</dd>
<dt><strong>Typ</strong></dt><dd>${escapeHtml(roleLabel)}</dd>
<dt><strong>B2B</strong></dt><dd>bestätigt (§ 14 BGB)</dd>
</dl>
<h3>Motivation</h3>
<p>${escapeHtml(motivation).replaceAll('\n', '<br>')}</p>
`
await transporter.sendMail({
from,
to,
replyTo: safeEmail,
subject: `Audit-Anfrage: ${safeCompany} (${safeName})`,
text: textBody,
html: htmlBody,
})
// Deliberately no recipient address, no payload - keep logs free of
// personal data (Art. 5 GDPR, data minimisation).
console.log('Audit request forwarded.')
}

16
backend/services/env.js Normal file
View File

@ -0,0 +1,16 @@
/**
* Reads an environment variable, treating blank as absent.
*
* `process.env.X ?? fallback` is not enough: a key that is present but empty
* - which is how .env.example ships the optional ones, e.g. `SMTP_FROM=` -
* is an empty string, not undefined, so `??` keeps the empty value and the
* fallback never applies. That produced an empty From header and port 0.
*
* Dependency-free on purpose, so both server.js and emailService.js can use
* it without dragging nodemailer into the importing module's graph.
*/
export function env(name, fallback = undefined) {
const value = process.env[name]
if (value === undefined || value.trim() === '') return fallback
return value.trim()
}

896
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@ -14,6 +14,8 @@
"deploy:prod": "npm run build && node deploy.js"
},
"dependencies": {
"@fontsource/comfortaa": "^5.3.0",
"@fontsource/quicksand": "^5.3.0",
"lucide-react": "^0.542.0",
"react": "^19.1.1",
"react-dom": "^19.1.1",
@ -22,7 +24,9 @@
"devDependencies": {
"@eslint/js": "^9.32.0",
"@testing-library/dom": "^10.4.1",
"@testing-library/jest-dom": "^7.0.0",
"@testing-library/react": "^16.3.2",
"@testing-library/user-event": "^14.6.1",
"@types/node": "^25.0.10",
"@types/react": "^19.1.9",
"@types/react-dom": "^19.1.7",

View File

@ -19,8 +19,10 @@ const PORT = 4173
const ROUTES = [
'/de/',
'/de/technical',
'/de/audit',
'/en/',
'/en/technical',
'/en/audit',
'/imprint',
'/privacy-policy',
]

View File

@ -1,4 +1,55 @@
Options -MultiViews
Options -MultiViews -Indexes
DirectoryIndex index.html
# ---------------------------------------------------------------------------
# Routing
# ---------------------------------------------------------------------------
RewriteEngine On
# Serve the prerendered pages produced by prerender.js.
# /de/technical is a directory, not a file, so the SPA fallback below would
# otherwise swallow it and ship the empty shell to crawlers.
RewriteCond %{REQUEST_FILENAME} -d
RewriteCond %{REQUEST_FILENAME}/index.html -f
RewriteRule ^(.+?)/?$ $1/index.html [L]
# SPA fallback, only for paths that are neither a file nor a directory.
RewriteCond %{REQUEST_FILENAME} !-f
RewriteCond %{REQUEST_FILENAME} !-d
RewriteRule ^ index.html [QSA,L]
# ---------------------------------------------------------------------------
# Security headers
# ---------------------------------------------------------------------------
<IfModule mod_headers.c>
# script-src keeps 'unsafe-inline' on purpose: Vite injects an inline
# modulepreload polyfill, and index.html carries four inline JSON-LD blocks
# that browsers also evaluate against script-src. Removing it silently breaks
# both the app and the structured data.
# style-src likewise: React sets inline style attributes (--reveal-delay).
Header always set Content-Security-Policy "default-src 'self'; base-uri 'self'; font-src 'self'; img-src 'self' data:; script-src 'self' 'unsafe-inline'; style-src 'self' 'unsafe-inline'; connect-src 'self' https://api.sascha-bach.de; object-src 'none'; frame-ancestors 'none'; form-action 'self' mailto:; upgrade-insecure-requests"
# No 'preload' yet - that directive is practically irreversible and should
# only be added once every subdomain is permanently HTTPS.
Header always set Strict-Transport-Security "max-age=63072000; includeSubDomains"
Header always set Referrer-Policy "strict-origin-when-cross-origin"
Header always set X-Content-Type-Options "nosniff"
Header always set X-Frame-Options "DENY"
Header always set Cross-Origin-Opener-Policy "same-origin"
Header always set Permissions-Policy "camera=(), microphone=(), geolocation=(), browsing-topics=()"
# Agent discovery documents (ported from the former vercel.json).
Header always set Link "</llms.txt>; rel=\"alternate\"; type=\"text/markdown\", </llms-full.txt>; rel=\"alternate\"; type=\"text/markdown\", </AGENTS.md>; rel=\"alternate\"; type=\"text/markdown\", </.well-known/agents.json>; rel=\"agents\"; type=\"application/json\", </.well-known/agent-card.json>; rel=\"agent-card\"; type=\"application/json\", </.well-known/webmcp.json>; rel=\"service-desc\"; type=\"application/json\", </.well-known/mcp.json>; rel=\"service-desc\"; type=\"application/json\""
</IfModule>
# ---------------------------------------------------------------------------
# Keep dotfiles unreachable (.env, .git remnants, editor leftovers).
# This matches the final filename only, so /.well-known/agent-card.json stays
# reachable - its basename does not start with a dot.
# ---------------------------------------------------------------------------
<IfModule mod_authz_core.c>
<FilesMatch "^\.">
Require all denied
</FilesMatch>
</IfModule>

View File

@ -0,0 +1,6 @@
# Agent discovery documents are meant to be fetched cross-origin.
# Ported from the CORS/cache block of the former vercel.json.
<IfModule mod_headers.c>
Header set Access-Control-Allow-Origin "*"
Header set Cache-Control "public, max-age=3600"
</IfModule>

View File

@ -14,6 +14,8 @@ This site is the public portfolio of Sascha Bach, a freelance software developer
- English landing page: https://sascha-bach.de/en/
- German technical portfolio: https://sascha-bach.de/de/technical
- English technical portfolio: https://sascha-bach.de/en/technical
- German accessibility audit offer (free, WCAG, businesses only): https://sascha-bach.de/de/audit
- English accessibility audit offer (free, WCAG, businesses only): https://sascha-bach.de/en/audit
- Imprint: https://sascha-bach.de/imprint
- Privacy policy: https://sascha-bach.de/privacy-policy

View File

@ -20,6 +20,8 @@ Sascha Bach is a freelance software developer based in Germany. The portfolio em
- `https://sascha-bach.de/en/`: English landing page
- `https://sascha-bach.de/de/technical`: German technical portfolio
- `https://sascha-bach.de/en/technical`: English technical portfolio
- `https://sascha-bach.de/de/audit`: German landing page for the accessibility audit free of charge, with the request form (businesses only)
- `https://sascha-bach.de/en/audit`: English version of the same offer
- `https://sascha-bach.de/imprint`: legal notice
- `https://sascha-bach.de/privacy-policy`: privacy policy

View File

@ -8,6 +8,8 @@
- English landing: https://sascha-bach.de/en/
- German technical portfolio: https://sascha-bach.de/de/technical
- English technical portfolio: https://sascha-bach.de/en/technical
- German accessibility audit offer (free, WCAG, businesses only): https://sascha-bach.de/de/audit
- English accessibility audit offer (free, WCAG, businesses only): https://sascha-bach.de/en/audit
- Imprint: https://sascha-bach.de/imprint
- Privacy policy: https://sascha-bach.de/privacy-policy
@ -22,7 +24,9 @@
- The site is informational and bilingual.
- The primary market is German-speaking, so `/de/` is the default entrypoint.
- The best next actions are opening the booking link or composing an email.
- The best next actions are requesting the accessibility audit at /de/audit, opening the booking link, or composing an email.
- The audit is a review of one single page against WCAG, delivered as a short PDF report plus a 15 minute walkthrough, free of charge.
- The audit is offered to businesses only (entrepreneurs, traders and freelancers under § 14 BGB), not to consumers.
- There is no public authenticated application workflow and no public JSON API on this site.
## More detail

View File

@ -41,4 +41,24 @@
<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>
</urlset>

View File

@ -8,6 +8,7 @@ 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 ImprintPage = lazy(() => import('../pages/ImprintPage'));
const PrivacyPolicy = lazy(() => import('../pages/PrivacyPolicy'));
@ -56,10 +57,12 @@ function AppShell() {
{/* 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 />} />

View File

@ -7,6 +7,7 @@ import { LanguageProvider } from '../../contexts/LanguageContext';
import { ThemeProvider } from '../../contexts/ThemeContext';
import LandingPage from '../../pages/LandingPage';
import HomePage from '../../pages/HomePage';
import AuditPage from '../../pages/AuditPage';
function renderPage(children: ReactNode) {
return render(
@ -28,4 +29,9 @@ describe('accessibility smoke test', () => {
const { container } = renderPage(<HomePage />);
expect(await axe(container)).toHaveNoViolations();
});
it('AuditPage has no axe violations', async () => {
const { container } = renderPage(<AuditPage />);
expect(await axe(container)).toHaveNoViolations();
});
});

View File

@ -0,0 +1,50 @@
import { FileText, MessagesSquare } from 'lucide-react';
import { useScrollReveal } from '../../hooks/useScrollReveal';
import { useLanguage } from '../../contexts/LanguageContext';
const ICONS = [FileText, MessagesSquare];
export default function AuditDeliverableSection() {
const { texts } = useLanguage();
const t = texts.audit.deliverable;
const sectionRef = useScrollReveal();
return (
<section
id="audit-deliverable"
className="audit-deliverable-section"
ref={sectionRef}
aria-labelledby="audit-deliverable-title"
>
<div className="audit-deliverable-section__container">
<h2
id="audit-deliverable-title"
className="audit-deliverable-section__title reveal-item"
>
{t.title}
</h2>
<p className="audit-deliverable-section__intro reveal-item">{t.intro}</p>
<ul className="audit-deliverable-section__list">
{t.items.map((item, index) => {
const Icon = ICONS[index] ?? FileText;
return (
<li
key={item.title}
className="audit-deliverable-section__item reveal-item"
style={{ '--reveal-delay': `${index * 80}ms` } as React.CSSProperties}
>
<Icon className="audit-deliverable-section__icon" aria-hidden="true" />
<div className="audit-deliverable-section__body">
<h3 className="audit-deliverable-section__item-title">{item.title}</h3>
<p className="audit-deliverable-section__text">{item.text}</p>
</div>
</li>
);
})}
</ul>
</div>
</section>
);
}

View File

@ -0,0 +1,373 @@
import { useEffect, useId, useRef } from 'react';
import { Link } from 'react-router-dom';
import { useLanguage } from '../../contexts/LanguageContext';
import { useScreenReaderAnnouncements } from '../../hooks/useScreenReaderAnnouncements';
import {
AUDIT_FIELD_ORDER,
useAuditForm,
type AuditFieldName,
} from '../../hooks/useAuditForm';
export default function AuditForm() {
const { texts } = useLanguage();
const t = texts.audit.form;
const { announce } = useScreenReaderAnnouncements();
const { values, errors, status, failedSubmitCount, setField, submit } = useAuditForm({
texts: t,
onSubmitStart: () => announce(t.sendingAnnouncement),
onSuccess: () => announce(t.successAnnouncement),
});
const summaryRef = useRef<HTMLDivElement>(null);
const successRef = useRef<HTMLDivElement>(null);
// Unique id prefix so several instances could coexist without colliding.
const id = useId();
const fieldId = (field: string) => `${id}-${field}`;
const errorId = (field: string) => `${id}-${field}-error`;
const hintId = (field: string) => `${id}-${field}-hint`;
const errorEntries = AUDIT_FIELD_ORDER.filter((field) => errors[field]).map(
(field) => ({ field, message: errors[field] as string })
);
// Move focus to the summary after a rejected submit. Without this the user
// is left at the button with the errors announced nowhere.
useEffect(() => {
if (failedSubmitCount > 0) {
summaryRef.current?.focus();
}
}, [failedSubmitCount]);
useEffect(() => {
if (status === 'success') {
successRef.current?.focus();
}
}, [status]);
/** Wires a field to its hint and error node for assistive technology. */
const describedBy = (field: AuditFieldName, hasHint: boolean) => {
const ids = [];
if (hasHint) ids.push(hintId(field));
if (errors[field]) ids.push(errorId(field));
return ids.length > 0 ? ids.join(' ') : undefined;
};
if (status === 'success') {
return (
<div
className="audit-form__status audit-form__status--success"
role="status"
ref={successRef}
tabIndex={-1}
>
<h3 className="audit-form__status-title">{t.successTitle}</h3>
<p className="audit-form__status-message">{t.successText}</p>
</div>
);
}
const isSubmitting = status === 'submitting';
return (
<form
className="audit-form"
noValidate
onSubmit={(event) => {
event.preventDefault();
void submit();
}}
>
{/* Focusable so it can receive focus after a rejected submit. Rendered
only when there is something to say, so focus never lands on nothing. */}
{errorEntries.length > 0 && (
<div
className="audit-form__error-summary"
role="alert"
ref={summaryRef}
tabIndex={-1}
>
<h3 className="audit-form__error-summary-title">{t.errorSummaryTitle}</h3>
<ul className="audit-form__error-summary-list">
{errorEntries.map(({ field, message }) => (
<li key={field}>
<a href={`#${fieldId(field)}`} className="audit-form__error-summary-link">
{message}
</a>
</li>
))}
</ul>
</div>
)}
{status === 'error' && (
<div className="audit-form__status audit-form__status--error" role="alert">
<h3 className="audit-form__status-title">{t.errorTitle}</h3>
<p className="audit-form__status-message">{t.errorText}</p>
</div>
)}
{/* Name */}
<div className="audit-form__group">
<label className="audit-form__label" htmlFor={fieldId('name')}>
{t.nameLabel}
</label>
<input
className="audit-form__input"
id={fieldId('name')}
name="name"
type="text"
autoComplete="name"
required
value={values.name}
aria-invalid={errors.name ? true : undefined}
aria-describedby={describedBy('name', false)}
onChange={(event) => setField('name', event.target.value)}
/>
{errors.name && (
<p className="audit-form__error" id={errorId('name')}>
{errors.name}
</p>
)}
</div>
{/* Company - the offer is B2B only, so this is required */}
<div className="audit-form__group">
<label className="audit-form__label" htmlFor={fieldId('company')}>
{t.companyLabel}
</label>
<p className="audit-form__hint" id={hintId('company')}>
{t.companyHint}
</p>
<input
className="audit-form__input"
id={fieldId('company')}
name="company"
type="text"
autoComplete="organization"
required
value={values.company}
aria-invalid={errors.company ? true : undefined}
aria-describedby={describedBy('company', true)}
onChange={(event) => setField('company', event.target.value)}
/>
{errors.company && (
<p className="audit-form__error" id={errorId('company')}>
{errors.company}
</p>
)}
</div>
{/* Email */}
<div className="audit-form__group">
<label className="audit-form__label" htmlFor={fieldId('email')}>
{t.emailLabel}
</label>
<p className="audit-form__hint" id={hintId('email')}>
{t.emailHint}
</p>
<input
className="audit-form__input"
id={fieldId('email')}
name="email"
type="email"
autoComplete="email"
required
value={values.email}
aria-invalid={errors.email ? true : undefined}
aria-describedby={describedBy('email', true)}
onChange={(event) => setField('email', event.target.value)}
/>
{errors.email && (
<p className="audit-form__error" id={errorId('email')}>
{errors.email}
</p>
)}
</div>
{/* URL under review */}
<div className="audit-form__group">
<label className="audit-form__label" htmlFor={fieldId('url')}>
{t.urlLabel}
</label>
<p className="audit-form__hint" id={hintId('url')}>
{t.urlHint}
</p>
<input
className="audit-form__input"
id={fieldId('url')}
name="url"
type="url"
inputMode="url"
autoComplete="url"
required
value={values.url}
aria-invalid={errors.url ? true : undefined}
aria-describedby={describedBy('url', true)}
onChange={(event) => setField('url', event.target.value)}
/>
{errors.url && (
<p className="audit-form__error" id={errorId('url')}>
{errors.url}
</p>
)}
</div>
{/* Agency or end client */}
<fieldset
className="audit-form__fieldset"
aria-invalid={errors.role ? true : undefined}
aria-describedby={errors.role ? errorId('role') : undefined}
>
<legend className="audit-form__legend">{t.roleLegend}</legend>
<div className="audit-form__radio-row">
<input
className="audit-form__radio"
id={fieldId('role')}
type="radio"
name="role"
value="agency"
checked={values.role === 'agency'}
onChange={() => setField('role', 'agency')}
/>
<label className="audit-form__radio-label" htmlFor={fieldId('role')}>
{t.roleAgency}
</label>
</div>
<div className="audit-form__radio-row">
<input
className="audit-form__radio"
id={fieldId('role-client')}
type="radio"
name="role"
value="client"
checked={values.role === 'client'}
onChange={() => setField('role', 'client')}
/>
<label className="audit-form__radio-label" htmlFor={fieldId('role-client')}>
{t.roleClient}
</label>
</div>
{errors.role && (
<p className="audit-form__error" id={errorId('role')}>
{errors.role}
</p>
)}
</fieldset>
{/* Motivation */}
<div className="audit-form__group">
<label className="audit-form__label" htmlFor={fieldId('motivation')}>
{t.motivationLabel}
</label>
<p className="audit-form__hint" id={hintId('motivation')}>
{t.motivationHint}
</p>
<textarea
className="audit-form__textarea"
id={fieldId('motivation')}
name="motivation"
rows={4}
required
value={values.motivation}
aria-invalid={errors.motivation ? true : undefined}
aria-describedby={describedBy('motivation', true)}
onChange={(event) => setField('motivation', event.target.value)}
/>
{errors.motivation && (
<p className="audit-form__error" id={errorId('motivation')}>
{errors.motivation}
</p>
)}
</div>
{/* B2B confirmation. Kept separate from the GDPR consent on purpose:
they are two distinct declarations and must be ticked individually. */}
<div className="audit-form__group audit-form__group--consent">
<div className="audit-form__checkbox-row">
<input
className="audit-form__checkbox"
id={fieldId('businessConfirmation')}
name="businessConfirmation"
type="checkbox"
required
checked={values.businessConfirmation}
aria-invalid={errors.businessConfirmation ? true : undefined}
aria-describedby={
errors.businessConfirmation ? errorId('businessConfirmation') : undefined
}
onChange={(event) => setField('businessConfirmation', event.target.checked)}
/>
<label
className="audit-form__checkbox-label"
htmlFor={fieldId('businessConfirmation')}
>
{t.businessLabel}
</label>
</div>
{errors.businessConfirmation && (
<p className="audit-form__error" id={errorId('businessConfirmation')}>
{errors.businessConfirmation}
</p>
)}
</div>
{/* Consent */}
<div className="audit-form__group audit-form__group--consent">
<div className="audit-form__checkbox-row">
<input
className="audit-form__checkbox"
id={fieldId('consent')}
name="consent"
type="checkbox"
required
checked={values.consent}
aria-invalid={errors.consent ? true : undefined}
aria-describedby={errors.consent ? errorId('consent') : undefined}
onChange={(event) => setField('consent', event.target.checked)}
/>
<label className="audit-form__checkbox-label" htmlFor={fieldId('consent')}>
{t.consentBefore}
<Link to="/privacy-policy" className="audit-form__consent-link">
{t.consentLinkText}
</Link>
{t.consentAfter}
</label>
</div>
{errors.consent && (
<p className="audit-form__error" id={errorId('consent')}>
{errors.consent}
</p>
)}
</div>
{/* Honeypot. Hidden from everyone who renders CSS, and from screen
readers via aria-hidden, so only scripted bots ever fill it in. */}
<div className="audit-form__honeypot" aria-hidden="true">
<label htmlFor={fieldId('website')}>{t.honeypotLabel}</label>
<input
id={fieldId('website')}
name="website"
type="text"
tabIndex={-1}
autoComplete="off"
value={values.website}
onChange={(event) => setField('website', event.target.value)}
/>
</div>
<button
className="audit-form__button"
type="submit"
disabled={isSubmitting}
aria-busy={isSubmitting}
>
{isSubmitting ? t.submittingText : t.submitText}
</button>
</form>
);
}

View File

@ -0,0 +1,28 @@
import { useLanguage } from '../../contexts/LanguageContext';
import AuditForm from './AuditForm';
export default function AuditFormSection() {
const { texts } = useLanguage();
const t = texts.audit.form;
const b2bNotice = texts.audit.b2bNotice;
return (
<section
id="audit-form"
className="audit-form-section"
aria-labelledby="audit-form-title"
>
<div className="audit-form-section__container">
<h2 id="audit-form-title" className="audit-form-section__title">
{t.title}
</h2>
<p className="audit-form-section__description">{t.description}</p>
<p className="audit-form-section__b2b-notice">{b2bNotice}</p>
<div className="audit-form-section__card">
<AuditForm />
</div>
</div>
</section>
);
}

View File

@ -0,0 +1,27 @@
import { useLanguage } from '../../contexts/LanguageContext';
export default function AuditHeroSection() {
const { texts } = useLanguage();
const t = texts.audit.hero;
const b2bNotice = texts.audit.b2bNotice;
return (
<section
id="audit-hero"
className="audit-hero-section"
aria-labelledby="audit-hero-title"
>
<div className="audit-hero-section__container">
<h1 id="audit-hero-title" className="audit-hero-section__title">
{t.title}
</h1>
<p className="audit-hero-section__subtitle">{t.subtitle}</p>
<a href="#audit-form" className="audit-hero-section__cta">
{t.ctaText}
</a>
<p className="audit-hero-section__note">{t.note}</p>
<p className="audit-hero-section__b2b-notice">{b2bNotice}</p>
</div>
</section>
);
}

View File

@ -0,0 +1,39 @@
import { useScrollReveal } from '../../hooks/useScrollReveal';
import { useLanguage } from '../../contexts/LanguageContext';
export default function AuditLimitsSection() {
const { texts } = useLanguage();
const t = texts.audit.limits;
const sectionRef = useScrollReveal();
return (
<section
id="audit-limits"
className="audit-limits-section"
ref={sectionRef}
aria-labelledby="audit-limits-title"
>
<div className="audit-limits-section__container">
<h2 id="audit-limits-title" className="audit-limits-section__title reveal-item">
{t.title}
</h2>
<p className="audit-limits-section__intro reveal-item">{t.intro}</p>
<ul className="audit-limits-section__list" aria-label={t.title}>
{t.items.map((item, index) => (
<li
key={item.text.substring(0, 40)}
className="audit-limits-section__item reveal-item"
style={{ '--reveal-delay': `${index * 80}ms` } as React.CSSProperties}
>
<p className="audit-limits-section__text">{item.text}</p>
</li>
))}
</ul>
<p className="audit-limits-section__outro reveal-item">{t.outro}</p>
</div>
</section>
);
}

View File

@ -0,0 +1,41 @@
import { useScrollReveal } from '../../hooks/useScrollReveal';
import { useLanguage } from '../../contexts/LanguageContext';
export default function AuditProblemSection() {
const { texts } = useLanguage();
const t = texts.audit.problem;
const sectionRef = useScrollReveal();
return (
<section
id="audit-problem"
className="audit-problem-section"
ref={sectionRef}
aria-labelledby="audit-problem-title"
>
<div className="audit-problem-section__container">
<h2 id="audit-problem-title" className="audit-problem-section__title reveal-item">
{t.title}
</h2>
<p className="audit-problem-section__intro reveal-item">{t.intro}</p>
<ul className="audit-problem-section__list">
{t.items.map((item, index) => (
<li
key={item.text.substring(0, 40)}
className="audit-problem-section__item reveal-item"
style={{ '--reveal-delay': `${index * 80}ms` } as React.CSSProperties}
>
<blockquote className="audit-problem-section__quote">
<p className="audit-problem-section__text">{item.text}</p>
</blockquote>
</li>
))}
</ul>
<p className="audit-problem-section__outro reveal-item">{t.outro}</p>
</div>
</section>
);
}

View File

@ -0,0 +1,43 @@
import { useScrollReveal } from '../../hooks/useScrollReveal';
import { useLanguage } from '../../contexts/LanguageContext';
export default function AuditProcessSection() {
const { texts } = useLanguage();
const t = texts.audit.process;
const sectionRef = useScrollReveal();
return (
<section
id="audit-process"
className="audit-process-section"
ref={sectionRef}
aria-labelledby="audit-process-title"
>
<div className="audit-process-section__container">
<h2 id="audit-process-title" className="audit-process-section__title reveal-item">
{t.title}
</h2>
{/* Ordered list: the steps happen in sequence, and screen readers
announce the position without needing the printed number. */}
<ol className="audit-process-section__list">
{t.steps.map((step, index) => (
<li
key={step.title}
className="audit-process-section__step reveal-item"
style={{ '--reveal-delay': `${index * 80}ms` } as React.CSSProperties}
>
<span className="audit-process-section__number" aria-hidden="true">
{index + 1}
</span>
<div className="audit-process-section__body">
<h3 className="audit-process-section__step-title">{step.title}</h3>
<p className="audit-process-section__text">{step.text}</p>
</div>
</li>
))}
</ol>
</div>
</section>
);
}

View File

@ -0,0 +1,173 @@
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
import { render, screen, waitFor } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { MemoryRouter } from 'react-router-dom';
import { LanguageProvider } from '../../../contexts/LanguageContext';
import { ThemeProvider } from '../../../contexts/ThemeContext';
import AuditForm from '../AuditForm';
const ENDPOINT = 'https://api.example.test/api/audit-request';
function renderForm() {
return render(
<MemoryRouter>
<LanguageProvider initialLanguage="de">
<ThemeProvider>
<AuditForm />
</ThemeProvider>
</LanguageProvider>
</MemoryRouter>
);
}
const BUSINESS_LABEL =
'Ich bestätige, dass ich das kostenfreie Audit als Unternehmer / Gewerbetreibender anfordere.';
/** Fills every required field with valid input. */
async function fillValidForm(user: ReturnType<typeof userEvent.setup>) {
await user.type(screen.getByLabelText('Name'), 'Erika Musterfrau');
await user.type(screen.getByLabelText('Firma'), 'Musterfrau GmbH');
await user.type(screen.getByLabelText('E-Mail-Adresse'), 'erika@example.de');
await user.type(
screen.getByLabelText('Welche Seite soll ich prüfen?'),
'https://example.de/kontakt'
);
await user.click(screen.getByLabelText('Agentur oder Dienstleister'));
await user.type(screen.getByLabelText('Worum geht es dir?'), 'Ausschreibung steht an.');
await user.click(screen.getByLabelText(BUSINESS_LABEL));
await user.click(screen.getByLabelText(/Ich bin einverstanden/));
}
describe('AuditForm', () => {
beforeEach(() => {
vi.stubEnv('VITE_AUDIT_ENDPOINT', ENDPOINT);
});
afterEach(() => {
vi.unstubAllEnvs();
vi.restoreAllMocks();
});
it('shows an error summary and moves focus to it when submitted empty', async () => {
const user = userEvent.setup();
const fetchSpy = vi.spyOn(globalThis, 'fetch');
renderForm();
await user.click(screen.getByRole('button', { name: 'Audit anfragen' }));
const summary = await screen.findByRole('alert');
expect(summary).toHaveTextContent('Bitte prüf noch diese Angaben:');
expect(summary).toHaveFocus();
expect(fetchSpy).not.toHaveBeenCalled();
});
it('marks an invalid email with aria-invalid', async () => {
const user = userEvent.setup();
renderForm();
await user.type(screen.getByLabelText('E-Mail-Adresse'), 'nicht-echt');
await user.click(screen.getByRole('button', { name: 'Audit anfragen' }));
await waitFor(() => {
expect(screen.getByLabelText('E-Mail-Adresse')).toHaveAttribute('aria-invalid', 'true');
});
});
it('rejects a URL without a protocol and wires the message to the field', async () => {
const user = userEvent.setup();
renderForm();
const urlField = screen.getByLabelText('Welche Seite soll ich prüfen?');
await user.type(urlField, 'example.de');
await user.click(screen.getByRole('button', { name: 'Audit anfragen' }));
await waitFor(() => expect(urlField).toHaveAttribute('aria-invalid', 'true'));
// The message deliberately appears twice - once in the error summary and
// once at the field. Assert the one a screen reader reads out for the
// field, reached through aria-describedby.
const describedBy = urlField.getAttribute('aria-describedby') ?? '';
const errorNode = describedBy
.split(' ')
.map((id) => document.getElementById(id))
.find((node) => node?.className.includes('audit-form__error'));
expect(errorNode).toHaveTextContent(
'Bitte gib die vollständige Adresse an, inklusive https://'
);
});
it('posts to the configured endpoint and reports success', async () => {
const user = userEvent.setup();
const fetchSpy = vi
.spyOn(globalThis, 'fetch')
.mockResolvedValue(new Response(null, { status: 200 }));
renderForm();
await fillValidForm(user);
await user.click(screen.getByRole('button', { name: 'Audit anfragen' }));
await waitFor(() => expect(fetchSpy).toHaveBeenCalledTimes(1));
const [url, init] = fetchSpy.mock.calls[0];
expect(url).toBe(ENDPOINT);
const payload = JSON.parse(String(init?.body));
expect(payload).toMatchObject({
name: 'Erika Musterfrau',
company: 'Musterfrau GmbH',
email: 'erika@example.de',
url: 'https://example.de/kontakt',
role: 'agency',
businessConfirmation: 'true',
gdprConsent: 'true',
});
expect(await screen.findByRole('status')).toHaveTextContent(
'Danke, deine Anfrage ist angekommen.'
);
});
it('refuses to submit without the B2B confirmation', async () => {
const user = userEvent.setup();
const fetchSpy = vi.spyOn(globalThis, 'fetch');
renderForm();
await fillValidForm(user);
// Take the B2B confirmation back off - everything else stays valid.
await user.click(screen.getByLabelText(BUSINESS_LABEL));
await user.click(screen.getByRole('button', { name: 'Audit anfragen' }));
const summary = await screen.findByRole('alert');
expect(summary).toHaveTextContent('Das Audit ist nur für Unternehmen.');
expect(fetchSpy).not.toHaveBeenCalled();
});
it('sends no request when the honeypot is filled', async () => {
const user = userEvent.setup();
const fetchSpy = vi.spyOn(globalThis, 'fetch');
const { container } = renderForm();
await fillValidForm(user);
// The honeypot is hidden from users, so address it the way a bot would.
const honeypot = container.querySelector<HTMLInputElement>('input[name="website"]');
expect(honeypot).not.toBeNull();
await user.type(honeypot!, 'http://spam.example');
await user.click(screen.getByRole('button', { name: 'Audit anfragen' }));
await screen.findByRole('status');
expect(fetchSpy).not.toHaveBeenCalled();
});
it('reports an error when the endpoint rejects the request', async () => {
const user = userEvent.setup();
vi.spyOn(globalThis, 'fetch').mockResolvedValue(new Response(null, { status: 500 }));
renderForm();
await fillValidForm(user);
await user.click(screen.getByRole('button', { name: 'Audit anfragen' }));
expect(await screen.findByText('Das hat leider nicht geklappt.')).toBeInTheDocument();
});
});

View File

@ -0,0 +1,36 @@
import { Link } from 'react-router-dom';
import { useScrollReveal } from '../../hooks/useScrollReveal';
import { useLanguage } from '../../contexts/LanguageContext';
/**
* 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, language } = useLanguage();
const t = texts.audit.teaser;
const sectionRef = useScrollReveal();
return (
<section
id="audit-teaser"
className="audit-teaser-section"
ref={sectionRef}
aria-labelledby="audit-teaser-title"
>
<div className="audit-teaser-section__container">
<h2 id="audit-teaser-title" className="audit-teaser-section__title reveal-item">
{t.title}
</h2>
<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>
);
}

View File

@ -4,7 +4,7 @@ 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 === '/technical';
// Email obfuscation function using config
@ -23,6 +23,13 @@ export default function Footer() {
<Link to="/imprint" className="footer__link">
{texts.footer.imprintText}
</Link>
<Link
to={`/${language}/audit`}
className="footer__link"
onClick={() => window.scrollTo({ top: 0, behavior: 'smooth' })}
>
{texts.footer.auditLinkText}
</Link>
<Link
to={isTechnicalPage ? '/' : '/technical'}
className="footer__link"

View File

@ -15,10 +15,18 @@ export default function Navbar() {
const { texts } = useLanguage();
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';
const menuItems = isLandingPage
? texts.navigation.landingMenuItems
: texts.navigation.menuItems;
let menuItems = texts.navigation.menuItems;
if (isAuditPage) {
menuItems = [];
} else if (isLandingPage) {
menuItems = texts.navigation.landingMenuItems;
}
const isSectionTrackingPage = location.pathname === technicalPath || isLandingPage;
const displayedActiveSection = isSectionTrackingPage ? activeSection : '';
@ -81,24 +89,28 @@ export default function Navbar() {
))}
<LanguageToggle />
<ThemeToggle />
{/* Burger Button für Mobile */}
<button
type="button"
className={`navbar__burger-button ${menuOpen ? 'active' : ''}`}
aria-label={texts.navigation.mobileMenuAriaLabel}
aria-expanded={menuOpen}
aria-controls="mobile-menu"
onClick={() => setMenuOpen(!menuOpen)}
>
{[0, 1, 2].map(i => <span key={i} className="navbar__burger-button__item"></span>)}
</button>
{/* Burger Button für Mobile - entfällt, wenn es nichts zu öffnen gibt */}
{menuItems.length > 0 && (
<>
<button
type="button"
className={`navbar__burger-button ${menuOpen ? 'active' : ''}`}
aria-label={texts.navigation.mobileMenuAriaLabel}
aria-expanded={menuOpen}
aria-controls="mobile-menu"
onClick={() => setMenuOpen(!menuOpen)}
>
{[0, 1, 2].map(i => <span key={i} className="navbar__burger-button__item"></span>)}
</button>
<MobileMenu
menuItems={menuItems}
isOpen={menuOpen}
onItemClick={handleNavigation}
onClose={() => setMenuOpen(false)}
/>
<MobileMenu
menuItems={menuItems}
isOpen={menuOpen}
onItemClick={handleNavigation}
onClose={() => setMenuOpen(false)}
/>
</>
)}
</div>
</div>
);

View File

@ -0,0 +1,146 @@
export const audit = {
seoTitle: 'Kostenfreies Barrierefreiheits-Audit nach WCAG | Sascha Bach',
seoDescription:
'Ich prüfe eine Seite deiner Website nach WCAG und schicke dir einen kostenfreien Kurzreport mit den wichtigsten Baustellen plus 15 Minuten Auswertung. Nur für Unternehmen.',
/**
* Pflichtangabe: das Angebot ist ausdrücklich B2B. Der Hinweis steht sowohl
* im Hero als auch direkt am Formular, damit er vor dem Absenden gelesen wird.
*/
b2bNotice:
'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.',
hero: {
title: 'Kostenfreies Barrierefreiheits-Audit nach WCAG',
subtitle:
'Ich prüfe eine Seite deiner Website und sage dir, wo du stehst. Konkret, verständlich und ohne Verkaufsgespräch.',
ctaText: 'Audit anfragen',
note: 'Kein Abo, keine versteckten Kosten. Du bekommst einen Report und ein Gespräch.',
},
problem: {
title: 'Kennst du das?',
intro:
'Seit dem 28. Juni 2025 gilt das Barrierefreiheitsstärkungsgesetz. Seitdem höre ich immer wieder dieselben vier Sätze:',
items: [
{ text: 'Betrifft mich das BFSG überhaupt? Das beantwortet mir niemand, ohne mir gleich etwas zu verkaufen.' },
{ text: 'Mein Test-Tool zeigt 82 von 100 Punkten. Ist das gut? Und was mache ich jetzt damit?' },
{ text: 'Ich habe ein Angebot für ein Vollaudit bekommen, weiß aber nicht, ob sich der Aufwand lohnt.' },
{ text: 'Ich habe keine Ahnung, ob die Agentur, die meine Seite gebaut hat, das Thema überhaupt auf dem Schirm hatte.' },
],
outro:
'Das Audit beantwortet genau diese Fragen an einer echten Seite von dir.',
},
deliverable: {
title: 'Das bekommst du',
intro: 'Zwei Dinge, beide konkret:',
items: [
{
title: 'Einen PDF-Kurzreport',
text: 'Die wichtigsten Barrieren auf deiner Seite, jeweils mit WCAG-Bezug, einer Einschätzung, wie schwer sie wiegen, und einem konkreten Vorschlag zur Behebung. Verständlich geschrieben, auch ohne Technikwissen.',
},
{
title: '15 Minuten Auswertung',
text: 'Wir gehen den Report gemeinsam durch. Du fragst nach, ich ordne ein, was dringend ist und was warten kann. Kein Verkaufsgespräch.',
},
],
},
process: {
title: 'So läuft es ab',
steps: [
{
title: 'Du schickst das Formular ab',
text: 'Nenn mir die Seite, die ich prüfen soll, und worum es dir geht. Das dauert zwei Minuten.',
},
{
title: 'Ich prüfe die Seite',
text: 'Mit Screenreader, nur mit der Tastatur und gegen die WCAG-Kriterien. Von Hand, denn ein automatisches Tool findet nur einen Teil davon.',
},
{
title: 'Du bekommst Report und Termin',
text: 'Innerhalb von fünf Werktagen liegt der Report in deinem Postfach, zusammen mit einem Link zur Terminbuchung.',
},
],
},
limits: {
title: 'Was das Audit nicht ist',
intro: 'Damit wir uns richtig verstehen:',
items: [
{ text: 'Es ist eine Seite, nicht die ganze Website. Für den vollständigen Überblick braucht es mehr.' },
{ text: 'Es ist kein Rechtsgutachten. Ich sage dir nur, was technisch nicht stimmt.' },
{ text: 'Es ist keine Umsetzung. Ich zeige dir die Baustellen. Ob du sie selbst behebst, deine Agentur beauftragst oder mich, ist deine Entscheidung.' },
],
outro:
'Wenn du danach mit mir weiterarbeiten willst, freue ich mich. Wenn nicht, hast du trotzdem einen Report, mit dem du arbeiten kannst.',
},
form: {
title: 'Audit anfragen',
description: 'Ein paar Angaben, dann bin ich dran.',
nameLabel: 'Name',
nameError: 'Bitte trag deinen Namen ein.',
companyLabel: 'Firma',
companyHint: 'Bei Freiberuflern reicht der Name, unter dem du auftrittst.',
companyError: 'Bitte trag deine Firma ein.',
emailLabel: 'E-Mail-Adresse',
emailHint: 'Hierhin schicke ich den Report.',
emailErrorRequired: 'Bitte trag deine E-Mail-Adresse ein.',
emailErrorInvalid: 'Diese Adresse sieht nicht vollständig aus. Beispiel: name@firma.de',
urlLabel: 'Welche Seite soll ich prüfen?',
urlHint: 'Die vollständige Adresse einer einzelnen Seite, zum Beispiel https://deine-firma.de/kontakt',
urlErrorRequired: 'Bitte nenn mir die Seite, die ich prüfen soll.',
urlErrorInvalid: 'Bitte gib die vollständige Adresse an, inklusive https://',
roleLegend: 'Wer bist du?',
roleAgency: 'Agentur oder Dienstleister',
roleClient: 'Endkunde es geht um meine eigene Website',
roleError: 'Bitte wähl eine der beiden Antworten.',
motivationLabel: 'Worum geht es dir?',
motivationHint:
'Ein, zwei Sätze reichen. Zum Beispiel: Ausschreibung, Beschwerde einer Nutzerin, oder einfach Neugier.',
motivationError: 'Schreib mir kurz, worum es dir geht.',
businessLabel:
'Ich bestätige, dass ich das kostenfreie Audit als Unternehmer / Gewerbetreibender anfordere.',
businessError:
'Das Audit ist nur für Unternehmen. Bitte bestätige, dass du gewerblich anfragst.',
consentBefore:
'Ich bin einverstanden, dass meine Angaben zur Bearbeitung meiner Anfrage gespeichert und verarbeitet werden. Details stehen in der ',
consentLinkText: 'Datenschutzerklärung',
consentAfter: '.',
consentError: 'Ohne deine Einwilligung darf ich die Daten nicht verarbeiten.',
honeypotLabel: 'Dieses Feld bitte frei lassen',
submitText: 'Audit anfragen',
submittingText: 'Wird gesendet …',
errorSummaryTitle: 'Bitte prüf noch diese Angaben:',
successTitle: 'Danke, deine Anfrage ist angekommen.',
successText:
'Ich melde mich innerhalb von fünf Werktagen mit deinem Report. Falls nichts ankommt, schau bitte auch im Spam-Ordner nach.',
errorTitle: 'Das hat leider nicht geklappt.',
errorText:
'Die Anfrage konnte nicht gesendet werden. Versuch es bitte noch einmal oder schreib mir direkt an freelancer@sascha-bach.de.',
sendingAnnouncement: 'Anfrage wird gesendet.',
successAnnouncement: 'Anfrage erfolgreich gesendet.',
},
teaser: {
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',
},
};

View File

@ -3,6 +3,7 @@ export const footer = {
copyrightText: 'Alle Rechte vorbehalten.',
technicalLinkText: 'Technisches Portfolio',
landingLinkText: 'Landing Page',
auditLinkText: 'Kostenfreies Audit',
gitAriaLabel: 'Git-Profil',
linkedinAriaLabel: 'LinkedIn-Profil',
emailAriaLabel: 'E-Mail senden',

View File

@ -17,9 +17,11 @@ import { certifications } from './technical/certifications';
import { contact } from './contact';
import { imprint } from './imprint';
import { privacyPolicy } from './privacy-policy';
import { audit } from './audit';
import { accessibility } from './accessibility';
import type { TextConfig } from '../en/TextConfig';
export const de = {
export const de: TextConfig = {
navigation,
themeToggle,
footer,
@ -39,5 +41,6 @@ export const de = {
contact,
imprint,
privacyPolicy,
audit,
accessibility,
};

View File

@ -3,15 +3,40 @@ export const privacyPolicy = {
lastUpdated: 'Zuletzt aktualisiert: November 2025',
introTitle: 'Überblick',
introText:
'Diese Website ist eine statische Portfolio-Website, die keine personenbezogenen Daten erhebt, verarbeitet oder speichert. Es werden keine Cookies, Tracking- oder Analyse-Tools eingesetzt.',
'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:
'Diese Website erhebt keine personenbezogenen Daten. Wenn Sie auf Kontaktlinks klicken (E-Mail, LinkedIn, GitHub), werden Sie zu externen Diensten weitergeleitet, die eigene Datenschutzrichtlinien haben:',
'Ohne Ihr Zutun erhebt diese Website keine personenbezogenen Daten. Wenn Sie auf Kontaktlinks klicken (E-Mail, LinkedIn, Git), werden Sie zu externen Diensten weitergeleitet, die eigene Datenschutzrichtlinien haben:',
dataCollectionList: [
'E-Mail: Öffnet Ihr Standard-E-Mail-Programm (keine Datenübertragung über diese Website)',
'LinkedIn: Weiterleitung zu LinkedIn (unterliegt den Datenschutzbestimmungen von LinkedIn)',
'GitHub: Weiterleitung zu GitHub (unterliegt den Datenschutzbestimmungen von GitHub)',
'Git: Weiterleitung zu unserer selbst gehosteten Git-Instanz',
],
auditFormTitle: 'Formular „Kostenfreies Barrierefreiheits-Audit nach WCAG“',
auditFormText:
'Wenn Sie das Audit-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.',
auditFormDataTitle: 'Verarbeitete Daten:',
auditFormDataList: [
'Name',
'Firma',
'E-Mail-Adresse',
'Adresse (URL) der zu prüfenden Seite',
'Angabe, ob Sie als Agentur oder als Endkunde anfragen',
'Ihre Freitextangabe zur Motivation',
'Ihre Bestätigung, dass Sie als Unternehmer anfragen (§ 14 BGB)',
],
auditFormPurposeLabel: 'Zweck:',
auditFormPurpose:
'Bearbeitung Ihrer Anfrage, Erstellung des Audit-Reports und Kontaktaufnahme zur Terminvereinbarung.',
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.',
auditFormRetentionLabel: 'Speicherdauer:',
auditFormRetention:
'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.',
auditFormSecurityLabel: 'Schutzmaßnahmen:',
auditFormSecurity:
'Die Übertragung erfolgt ausschließlich verschlüsselt über HTTPS. Der verarbeitende Server wird von uns selbst betrieben. Inhalte der Anfragen werden nicht in Server-Logdateien geschrieben.',
webhostingTitle: 'Webhosting',
webhostingText:
'Diese Website wird von Bitpalast GmbH gehostet. Der Hosting-Anbieter kann technische Daten wie IP-Adressen, Browsertyp und Zugriffszeiten in Server-Logdateien erfassen. Diese Daten sind für den technischen Betrieb und die Sicherheit der Website erforderlich.',

View File

@ -23,6 +23,7 @@ export interface TextConfig {
copyrightText: string;
technicalLinkText: string;
landingLinkText: string;
auditLinkText: string;
gitAriaLabel: string;
linkedinAriaLabel: string;
emailAriaLabel: string;
@ -302,6 +303,18 @@ export interface TextConfig {
dataCollectionTitle: string;
dataCollectionText: string;
dataCollectionList: string[];
auditFormTitle: string;
auditFormText: string;
auditFormDataTitle: string;
auditFormDataList: string[];
auditFormPurposeLabel: string;
auditFormPurpose: string;
auditFormLegalBasisLabel: string;
auditFormLegalBasis: string;
auditFormRetentionLabel: string;
auditFormRetention: string;
auditFormSecurityLabel: string;
auditFormSecurity: string;
webhostingTitle: string;
webhostingText: string;
webhostingProvider: string;
@ -316,6 +329,85 @@ export interface TextConfig {
contactEmail: string;
};
// Accessibility audit landing page
audit: {
seoTitle: string;
seoDescription: string;
b2bNotice: string;
hero: {
title: string;
subtitle: string;
ctaText: string;
note: string;
};
problem: {
title: string;
intro: string;
items: { text: string }[];
outro: string;
};
deliverable: {
title: string;
intro: string;
items: { title: string; text: string }[];
};
process: {
title: string;
steps: { title: string; text: string }[];
};
limits: {
title: string;
intro: string;
items: { text: string }[];
outro: string;
};
form: {
title: string;
description: string;
nameLabel: string;
nameError: string;
companyLabel: string;
companyHint: string;
companyError: string;
emailLabel: string;
emailHint: string;
emailErrorRequired: string;
emailErrorInvalid: string;
urlLabel: string;
urlHint: string;
urlErrorRequired: string;
urlErrorInvalid: string;
roleLegend: string;
roleAgency: string;
roleClient: string;
roleError: string;
motivationLabel: string;
motivationHint: string;
motivationError: string;
businessLabel: string;
businessError: string;
consentBefore: string;
consentLinkText: string;
consentAfter: string;
consentError: string;
honeypotLabel: string;
submitText: string;
submittingText: string;
errorSummaryTitle: string;
successTitle: string;
successText: string;
errorTitle: string;
errorText: string;
sendingAnnouncement: string;
successAnnouncement: string;
};
teaser: {
title: string;
text: string;
ctaText: string;
};
};
// Accessibility and Navigation
accessibility: {
skipLinks: {

View File

@ -0,0 +1,146 @@
export const audit = {
seoTitle: 'Free Accessibility Audit to WCAG | Sascha Bach',
seoDescription:
'I review one page of your website against WCAG and send you a free short report covering the most important issues, plus a 15 minute walkthrough. For businesses only.',
/**
* Mandatory notice: the offer is explicitly B2B. It appears both in the hero
* and next to the form so it is read before submitting.
*/
b2bNotice:
'This offer is aimed exclusively at entrepreneurs, traders and freelancers within the meaning of § 14 German Civil Code (BGB). Not sold or supplied to consumers within the meaning of § 13 BGB.',
hero: {
title: 'Free accessibility audit to WCAG',
subtitle:
'I review one page of your website and tell you where you stand. Concrete, understandable, and without a sales pitch.',
ctaText: 'Request the audit',
note: 'No subscription, no hidden costs. You get a report and a conversation.',
},
problem: {
title: 'Sound familiar?',
intro:
'The German Accessibility Act (BFSG) has been in force since 28 June 2025. Since then I keep hearing the same four sentences:',
items: [
{ text: 'Does the BFSG even apply to me? Nobody answers that without trying to sell me something.' },
{ text: 'My testing tool says 82 out of 100. Is that good? And what do I do with it?' },
{ text: 'I have a quote for a full audit, but I have no idea whether the effort pays off.' },
{ text: 'I have no idea whether the agency that built my site ever considered accessibility at all.' },
],
outro:
'The audit answers exactly these questions on a real page of yours.',
},
deliverable: {
title: 'What you get',
intro: 'Two things, both concrete:',
items: [
{
title: 'A short PDF report',
text: 'The most important barriers on your page, each with its WCAG reference, an assessment of how much it weighs, and a concrete suggestion for fixing it. Written to be understood without a technical background.',
},
{
title: '15 minutes of walkthrough',
text: 'We go through the report together. You ask questions, I put things in order: what is urgent and what can wait. Not a sales call.',
},
],
},
process: {
title: 'How it works',
steps: [
{
title: 'You send the form',
text: 'Tell me which page to review and what this is about. It takes two minutes.',
},
{
title: 'I review the page',
text: 'With a screen reader, using the keyboard only, and against the WCAG criteria. By hand, an automated tool only finds part of it.',
},
{
title: 'You get the report and a slot',
text: 'Within five working days the report lands in your inbox, together with a link to book the call.',
},
],
},
limits: {
title: 'What the audit is not',
intro: 'So we understand each other properly:',
items: [
{ text: 'It is one page, not the whole website. A complete picture takes more than this.' },
{ text: 'It is not a legal opinion. I tell only you what is technically wrong.' },
{ text: 'It is not the fix itself. I show you the problems. Whether you fix them yourself, task your agency or hire me is your decision.' },
],
outro:
'If you want to keep working with me afterwards, I would be glad. If not, you still have a report you can work with.',
},
form: {
title: 'Request the audit',
description: 'A handful of answers, then it is my turn.',
nameLabel: 'Name',
nameError: 'Please enter your name.',
companyLabel: 'Company',
companyHint: 'For freelancers, the name you trade under is enough.',
companyError: 'Please enter your company.',
emailLabel: 'Email address',
emailHint: 'This is where I send the report.',
emailErrorRequired: 'Please enter your email address.',
emailErrorInvalid: 'That address looks incomplete. Example: name@company.com',
urlLabel: 'Which page should I review?',
urlHint: 'The full address of a single page, for example https://your-company.com/contact',
urlErrorRequired: 'Please tell me which page to review.',
urlErrorInvalid: 'Please give the full address, including https://',
roleLegend: 'Who are you?',
roleAgency: 'Agency or service provider',
roleClient: 'End client this is my own website',
roleError: 'Please pick one of the two answers.',
motivationLabel: 'What is this about?',
motivationHint:
'A sentence or two is enough. For example: a tender, a complaint from a user, or plain curiosity.',
motivationError: 'Tell me briefly what this is about.',
businessLabel:
'I confirm that I am requesting the free audit as an entrepreneur or trader.',
businessError:
'The audit is for businesses only. Please confirm that you are asking in a commercial capacity.',
consentBefore:
'I agree that my details may be stored and processed in order to handle my request. The details are in the ',
consentLinkText: 'privacy policy',
consentAfter: '.',
consentError: 'Without your consent I am not allowed to process the data.',
honeypotLabel: 'Please leave this field empty',
submitText: 'Request the audit',
submittingText: 'Sending …',
errorSummaryTitle: 'Please check these entries:',
successTitle: 'Thank you, your request arrived.',
successText:
'I will get back to you within five working days with your report. If nothing arrives, please also check your spam folder.',
errorTitle: 'That did not work.',
errorText:
'The request could not be sent. Please try again or write to me directly at freelancer@sascha-bach.de.',
sendingAnnouncement: 'Sending request.',
successAnnouncement: 'Request sent successfully.',
},
teaser: {
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',
},
};

View File

@ -3,6 +3,7 @@ export const footer = {
copyrightText: 'All rights reserved.',
technicalLinkText: 'Technical Portfolio',
landingLinkText: 'Landing Page',
auditLinkText: 'Free audit',
gitAriaLabel: 'Git Profile',
linkedinAriaLabel: 'LinkedIn Profile',
emailAriaLabel: 'Send Email',

View File

@ -17,6 +17,7 @@ import { certifications } from './technical/certifications';
import { contact } from './contact';
import { imprint } from './imprint';
import { privacyPolicy } from './privacy-policy';
import { audit } from './audit';
import { accessibility } from './accessibility';
import type { TextConfig } from './TextConfig';
@ -43,5 +44,6 @@ export const en: TextConfig = {
contact,
imprint,
privacyPolicy,
audit,
accessibility,
};

View File

@ -3,15 +3,40 @@ export const privacyPolicy = {
lastUpdated: 'Last updated: November 2025',
introTitle: 'Overview',
introText:
'This website is a static portfolio website that does not collect, process, or store any personal data. No cookies, tracking, or analytics tools are used.',
'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:
'This website does not collect any personal data. When you click on contact links (email, LinkedIn, GitHub), you will be redirected to external services that have their own privacy policies:',
'Unless you act, this website collects no personal data. When you click on contact links (email, LinkedIn, Git), you will be redirected to external services that have their own privacy policies:',
dataCollectionList: [
'Email: Opens your default email client (no data transmitted through this website)',
'LinkedIn: Redirects to LinkedIn (subject to LinkedIn\'s privacy policy)',
'GitHub: Redirects to GitHub (subject to GitHub\'s privacy policy)',
'Git: Redirects to our self-hosted Git instance',
],
auditFormTitle: 'The "Free accessibility audit to WCAG" form',
auditFormText:
'When you submit the audit form, the data you entered is transmitted to our own server and forwarded from there to us by email. No external form provider is involved.',
auditFormDataTitle: 'Data processed:',
auditFormDataList: [
'Name',
'Company',
'Email address',
'Address (URL) of the page to be reviewed',
'Whether you are asking as an agency or as an end client',
'Your free-text note on what the request is about',
'Your confirmation that you are asking as an entrepreneur (§ 14 BGB)',
],
auditFormPurposeLabel: 'Purpose:',
auditFormPurpose:
'Handling your request, producing the audit report and contacting you to arrange the call.',
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.',
auditFormRetentionLabel: 'Retention period:',
auditFormRetention:
'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.',
auditFormSecurityLabel: 'Safeguards:',
auditFormSecurity:
'Transmission is encrypted via HTTPS exclusively. The processing server is operated by us. The contents of requests are not written to server log files.',
webhostingTitle: 'Web Hosting',
webhostingText:
'This website is hosted by Bitpalast GmbH. The hosting provider may collect technical data such as IP addresses, browser type, and access times in server log files. This data is necessary for the technical operation and security of the website.',

209
src/hooks/useAuditForm.ts Normal file
View File

@ -0,0 +1,209 @@
import { useCallback, useState } from 'react';
import type { TextConfig } from '../config/locales/en';
type FormTexts = TextConfig['audit']['form'];
export type AuditRole = '' | 'agency' | 'client';
export interface AuditFormValues {
name: string;
company: string;
email: string;
url: string;
role: AuditRole;
motivation: string;
/**
* The offer is B2B only (§ 14 BGB). This is an explicit, recorded
* confirmation rather than a passive notice, so it is forwarded and stored
* with the request.
*/
businessConfirmation: boolean;
consent: boolean;
/** Honeypot. Real people never see it, so anything in here is a bot. */
website: string;
}
/** Every key that can carry a validation message - the honeypot never does. */
export type AuditFieldName = Exclude<keyof AuditFormValues, 'website'>;
export type AuditFormErrors = Partial<Record<AuditFieldName, string>>;
export type SubmitStatus = 'idle' | 'submitting' | 'success' | 'error';
/** Field order drives the order of the error summary, so it matches the form. */
export const AUDIT_FIELD_ORDER: AuditFieldName[] = [
'name',
'company',
'email',
'url',
'role',
'motivation',
'businessConfirmation',
'consent',
];
const EMPTY_VALUES: AuditFormValues = {
name: '',
company: '',
email: '',
url: '',
role: '',
motivation: '',
businessConfirmation: false,
consent: false,
website: '',
};
/**
* Deliberately loose: something before an @, something after it, and a dot in
* the domain. Anything stricter rejects valid addresses, and the only real
* proof that an address works is that the report arrives.
*/
const EMAIL_PATTERN = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
function isUsableHttpUrl(raw: string): boolean {
try {
const parsed = new URL(raw);
return parsed.protocol === 'http:' || parsed.protocol === 'https:';
} catch {
return false;
}
}
export function validateAuditForm(
values: AuditFormValues,
t: FormTexts
): AuditFormErrors {
const errors: AuditFormErrors = {};
if (!values.name.trim()) {
errors.name = t.nameError;
}
if (!values.company.trim()) {
errors.company = t.companyError;
}
const email = values.email.trim();
if (!email) {
errors.email = t.emailErrorRequired;
} else if (!EMAIL_PATTERN.test(email)) {
errors.email = t.emailErrorInvalid;
}
const url = values.url.trim();
if (!url) {
errors.url = t.urlErrorRequired;
} else if (!isUsableHttpUrl(url)) {
errors.url = t.urlErrorInvalid;
}
if (!values.role) {
errors.role = t.roleError;
}
if (!values.motivation.trim()) {
errors.motivation = t.motivationError;
}
if (!values.businessConfirmation) {
errors.businessConfirmation = t.businessError;
}
if (!values.consent) {
errors.consent = t.consentError;
}
return errors;
}
interface UseAuditFormOptions {
texts: FormTexts;
onSuccess?: () => void;
onSubmitStart?: () => void;
}
export function useAuditForm({ texts, onSuccess, onSubmitStart }: UseAuditFormOptions) {
const [values, setValues] = useState<AuditFormValues>(EMPTY_VALUES);
const [errors, setErrors] = useState<AuditFormErrors>({});
const [status, setStatus] = useState<SubmitStatus>('idle');
/** Bumped on every rejected submit so the summary can re-take focus. */
const [failedSubmitCount, setFailedSubmitCount] = useState(0);
const setField = useCallback(
<K extends keyof AuditFormValues>(field: K, value: AuditFormValues[K]) => {
setValues((previous) => ({ ...previous, [field]: value }));
// Clear the message as soon as the field is touched again. Correcting a
// field while its error is still shouting at you is needlessly hostile.
setErrors((previous) => {
if (!(field in previous)) return previous;
const next = { ...previous };
delete next[field as AuditFieldName];
return next;
});
},
[]
);
const submit = useCallback(async () => {
const validationErrors = validateAuditForm(values, texts);
if (Object.keys(validationErrors).length > 0) {
setErrors(validationErrors);
setStatus('idle');
setFailedSubmitCount((count) => count + 1);
return;
}
// Honeypot filled means a bot. Report success so it does not retry, and
// never spend a request on it.
if (values.website.trim()) {
setStatus('success');
return;
}
setErrors({});
setStatus('submitting');
onSubmitStart?.();
const endpoint = import.meta.env.VITE_AUDIT_ENDPOINT;
if (!endpoint) {
// Missing configuration is a deployment fault, not a user mistake -
// surface it instead of pretending the request went out.
console.error('VITE_AUDIT_ENDPOINT is not configured.');
setStatus('error');
return;
}
try {
const response = await fetch(endpoint, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
name: values.name.trim(),
company: values.company.trim(),
email: values.email.trim(),
url: values.url.trim(),
role: values.role,
motivation: values.motivation.trim(),
businessConfirmation: 'true',
gdprConsent: 'true',
website: '',
}),
});
if (!response.ok) {
setStatus('error');
return;
}
setValues(EMPTY_VALUES);
setStatus('success');
onSuccess?.();
} catch {
setStatus('error');
}
}, [values, texts, onSuccess, onSubmitStart]);
return { values, errors, status, failedSubmitCount, setField, submit };
}

View File

@ -1,5 +1,11 @@
import { StrictMode } from 'react';
import { createRoot } from 'react-dom/client';
// Self-hosted brand fonts. Loading these from fonts.googleapis.com would send
// every visitor's IP to Google and contradict the privacy policy.
// Latin subset only - it covers German umlauts and eszett.
import '@fontsource/comfortaa/latin-600.css';
import '@fontsource/quicksand/latin-400.css';
import '@fontsource/quicksand/latin-500.css';
import PortfolioApp from './app/PortfolioApp.tsx';
createRoot(document.getElementById('root')!).render(

29
src/pages/AuditPage.tsx Normal file
View File

@ -0,0 +1,29 @@
import AuditHeroSection from '../components/audit/AuditHeroSection';
import AuditProblemSection from '../components/audit/AuditProblemSection';
import AuditDeliverableSection from '../components/audit/AuditDeliverableSection';
import AuditProcessSection from '../components/audit/AuditProcessSection';
import AuditLimitsSection from '../components/audit/AuditLimitsSection';
import AuditFormSection from '../components/audit/AuditFormSection';
import { PageSeo } from '../components/PageSeo';
import { useLanguage } from '../contexts/LanguageContext';
export default function AuditPage() {
const { language, texts } = useLanguage();
const t = texts.audit;
return (
<>
<PageSeo
title={t.seoTitle}
description={t.seoDescription}
canonical={`/${language}/audit`}
/>
<AuditHeroSection />
<AuditProcessSection />
<AuditProblemSection />
<AuditDeliverableSection />
<AuditLimitsSection />
<AuditFormSection />
</>
);
}

View File

@ -6,6 +6,7 @@ import WinningsSection from '../components/landing/WinningsSection';
import ProcessesSection from '../components/landing/ProcessesSection';
import ProjectsSection from '../components/sections/ProjectsSection';
import ReferencesSection from '../components/landing/ReferencesSection';
import AuditTeaserSection from '../components/landing/AuditTeaserSection';
import ContactSection from '../components/sections/ContactSection';
import { PageSeo } from '../components/PageSeo';
import { useLanguage } from '../contexts/LanguageContext';
@ -40,6 +41,7 @@ export default function LandingPage() {
<ProcessesSection />
<ProjectsSection />
<ReferencesSection />
<AuditTeaserSection />
<ContactSection />
</>
);

View File

@ -27,6 +27,19 @@ export default function PrivacyPolicy() {
))}
</ul>
<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>
<h2>{texts.webhostingTitle}</h2>
<p>{texts.webhostingText}</p>
<p>

View File

@ -12,7 +12,8 @@
// Component styles
@use 'components/back-to-top';
@import url('https://fonts.googleapis.com/css2?family=Comfortaa:wght@600&family=Quicksand:wght@400;500&display=swap');
// Comfortaa 600 and Quicksand 400/500 are self-hosted via @fontsource and
// imported in src/main.tsx - see the note there.
// CSS Reset - Add this
* {
@ -96,6 +97,17 @@
--bg-processes: #f1efe8;
--bg-references: #ffffff;
// Audit landing page backgrounds (light).
// Ordered as the sections appear: hero, process, problem, deliverable,
// limits, form - so the tones alternate down the page.
--bg-audit-hero: #ffffff;
--bg-audit-process: #f1efe8;
--bg-audit-problem: #ffffff;
--bg-audit-deliverable: #f1efe8;
--bg-audit-limits: #ffffff;
--bg-audit-form: #f1efe8;
--bg-audit-teaser: #f1efe8;
// Reused section backgrounds (light)
--about-background: #ffffff;
--projects-background: #ffffff;
@ -149,6 +161,15 @@
--bg-processes: #021829;
--bg-references: #042c53;
// Audit landing page backgrounds (dark)
--bg-audit-hero: #042c53;
--bg-audit-process: #021829;
--bg-audit-problem: #042c53;
--bg-audit-deliverable: #021829;
--bg-audit-limits: #042c53;
--bg-audit-form: #021829;
--bg-audit-teaser: #021829;
// Cards + shadows
--card-glass-bg: rgba(12, 68, 124, 0.5);
--card-glass-border: rgba(230, 241, 251, 0.1);

View File

@ -7,7 +7,6 @@
background: #042c53; // always night-blue independent of --color-primary cascade
color: var(--color-text-on-dark);
height: 65px;
position: sticky;
top: 0;
z-index: 100;
box-shadow: 0 2px 8px rgba(4, 44, 83, 0.2);

View File

@ -12,3 +12,4 @@
@forward 'projects-section';
@forward 'contact-section';
@forward 'imprint-page';
@forward 'audit-page';

View File

@ -0,0 +1,712 @@
@use '../variables' as *;
// Shared shape for every band on the audit page. Keeps the vertical rhythm
// identical to the landing sections without repeating the same six rules.
@mixin audit-band($background) {
padding-block: var(--space-20);
padding-inline: var(--space-4);
background: $background;
position: relative;
&::before {
content: '';
position: absolute;
top: 0;
left: 0;
right: 0;
height: 2px;
background: linear-gradient(
90deg,
transparent 0%,
var(--color-secondary) 30%,
var(--color-primary) 70%,
transparent 100%
);
}
@media (max-width: 768px) {
padding-block: var(--space-12);
}
}
@mixin audit-container {
max-width: 720px;
margin: 0 auto;
display: flex;
flex-direction: column;
gap: var(--space-8);
}
@mixin audit-title {
font-size: clamp(2rem, 5vw, var(--font-size-4xl));
font-weight: 800;
letter-spacing: var(--tracking-tight);
line-height: var(--leading-tight);
color: $color-heading;
}
// The B2B restriction is a legal notice, so it stays readable rather than
// shrinking into fine print: normal body size, full contrast, marked off by a
// border instead of by being small and grey.
@mixin audit-b2b-notice {
font-size: var(--font-size-sm);
line-height: var(--leading-relaxed);
color: var(--color-text);
background: var(--card-glass-bg);
border: 1px solid var(--card-glass-border);
border-left: 3px solid var(--color-accent-deco, var(--color-secondary));
border-radius: var(--radius-md);
padding: var(--space-3) var(--space-4);
text-align: left;
}
@mixin audit-card {
background: var(--card-glass-bg);
backdrop-filter: blur(12px);
-webkit-backdrop-filter: blur(12px);
border: 1px solid var(--card-glass-border);
border-left: 3px solid var(--color-secondary);
border-radius: var(--radius-lg);
box-shadow: var(--shadow-sm);
}
//
// Hero
//
.audit-hero-section {
@include audit-band(var(--bg-audit-hero));
// The hero opens the page, so it carries no top rule.
&::before {
display: none;
}
&__container {
@include audit-container;
gap: var(--space-6);
text-align: center;
align-items: center;
}
&__title {
font-size: clamp(2.25rem, 6vw, 3.5rem);
font-weight: 800;
letter-spacing: var(--tracking-tight);
line-height: var(--leading-tight);
color: $color-heading;
}
&__subtitle {
font-size: var(--font-size-lg);
line-height: var(--leading-relaxed);
color: var(--color-text);
max-width: 34rem;
}
&__cta {
display: inline-block;
padding: var(--space-4) var(--space-8);
border-radius: var(--radius-lg);
background: var(--contact-button-bg);
color: var(--contact-button-text);
font-family: var(--font-headline);
font-size: var(--font-size-lg);
font-weight: 600;
text-decoration: none;
box-shadow: var(--shadow-md);
transition:
background var(--transition-base),
transform var(--transition-base);
&:hover {
background: var(--contact-button-hover-bg);
transform: translateY(-2px);
}
}
&__note {
font-size: var(--font-size-sm);
color: var(--color-text-muted);
max-width: 30rem;
}
&__b2b-notice {
@include audit-b2b-notice;
max-width: 40rem;
}
}
//
// Problem
//
.audit-problem-section {
@include audit-band(var(--bg-audit-problem));
&__container {
@include audit-container;
}
&__title {
@include audit-title;
}
&__intro {
font-size: var(--font-size-lg);
font-weight: 600;
color: var(--color-text);
line-height: var(--leading-normal);
margin-top: calc(var(--space-4) * -1);
}
&__list {
list-style: none;
padding: 0;
margin: 0;
display: flex;
flex-direction: column;
gap: var(--space-3);
}
&__item {
@include audit-card;
border-left-color: var(--color-accent-deco, var(--color-secondary));
padding: var(--space-4) var(--space-5);
}
&__quote {
margin: 0;
}
&__text {
font-size: var(--font-size-base);
line-height: var(--leading-loose);
color: var(--color-text);
font-style: italic;
margin: 0;
}
&__outro {
font-size: var(--font-size-lg);
font-weight: 700;
color: var(--color-text);
line-height: var(--leading-snug);
background: var(--card-glass-bg);
backdrop-filter: blur(12px);
-webkit-backdrop-filter: blur(12px);
padding: var(--space-5) var(--space-6);
border-radius: var(--radius-xl);
border: 1px solid var(--card-glass-border);
border-left: 4px solid var(--color-secondary);
box-shadow: var(--shadow-glow);
}
}
//
// Deliverable
//
.audit-deliverable-section {
@include audit-band(var(--bg-audit-deliverable));
&__container {
@include audit-container;
}
&__title {
@include audit-title;
}
&__intro {
font-size: var(--font-size-lg);
font-weight: 600;
color: var(--color-text);
margin-top: calc(var(--space-4) * -1);
}
&__list {
list-style: none;
padding: 0;
margin: 0;
display: flex;
flex-direction: column;
gap: var(--space-4);
}
&__item {
@include audit-card;
padding: var(--space-5) var(--space-6);
display: flex;
gap: var(--space-4);
align-items: flex-start;
}
&__icon {
flex-shrink: 0;
width: 1.75rem;
height: 1.75rem;
color: var(--color-secondary);
margin-top: 0.15rem;
}
&__body {
display: flex;
flex-direction: column;
gap: var(--space-2);
}
&__item-title {
font-family: var(--font-headline);
font-size: var(--font-size-lg);
font-weight: 600;
color: $color-heading;
margin: 0;
}
&__text {
font-size: var(--font-size-base);
line-height: var(--leading-loose);
color: var(--color-text);
margin: 0;
}
}
//
// Process
//
.audit-process-section {
@include audit-band(var(--bg-audit-process));
&__container {
@include audit-container;
}
&__title {
@include audit-title;
}
&__list {
list-style: none;
padding: 0;
margin: 0;
display: flex;
flex-direction: column;
gap: var(--space-4);
counter-reset: audit-step;
}
&__step {
@include audit-card;
padding: var(--space-5) var(--space-6);
display: flex;
gap: var(--space-4);
align-items: flex-start;
}
&__number {
flex-shrink: 0;
display: flex;
align-items: center;
justify-content: center;
width: 2.25rem;
height: 2.25rem;
border-radius: 9999px;
background: var(--contact-button-bg);
color: var(--contact-button-text);
font-family: var(--font-headline);
font-weight: 600;
font-size: var(--font-size-base);
}
&__body {
display: flex;
flex-direction: column;
gap: var(--space-2);
}
&__step-title {
font-family: var(--font-headline);
font-size: var(--font-size-lg);
font-weight: 600;
color: $color-heading;
margin: 0;
}
&__text {
font-size: var(--font-size-base);
line-height: var(--leading-loose);
color: var(--color-text);
margin: 0;
}
}
//
// Limits
//
.audit-limits-section {
@include audit-band(var(--bg-audit-limits));
&__container {
@include audit-container;
}
&__title {
@include audit-title;
}
&__intro {
font-size: var(--font-size-lg);
font-weight: 600;
color: var(--color-text);
margin-top: calc(var(--space-4) * -1);
}
&__list {
list-style: none;
padding: 0;
margin: 0;
display: flex;
flex-direction: column;
gap: var(--space-3);
}
&__item {
@include audit-card;
padding: var(--space-4) var(--space-5);
}
&__text {
font-size: var(--font-size-base);
line-height: var(--leading-loose);
color: var(--color-text);
margin: 0;
}
&__outro {
font-size: var(--font-size-base);
line-height: var(--leading-relaxed);
color: var(--color-text);
}
}
//
// Form section
//
.audit-form-section {
@include audit-band(var(--bg-audit-form));
scroll-margin-top: 5rem;
&__container {
@include audit-container;
gap: var(--space-4);
}
&__title {
@include audit-title;
}
&__description {
font-size: var(--font-size-lg);
color: var(--color-text);
}
&__b2b-notice {
@include audit-b2b-notice;
}
&__card {
margin-top: var(--space-4);
background: var(--contact-form-bg);
border: 1px solid var(--contact-form-border);
border-radius: var(--radius-xl);
box-shadow: var(--contact-form-shadow);
padding: var(--space-8);
@media (max-width: 768px) {
padding: var(--space-5);
}
}
}
//
// Teaser on the main landing page
//
.audit-teaser-section {
@include audit-band(var(--bg-audit-teaser));
&__container {
@include audit-container;
gap: var(--space-5);
text-align: center;
align-items: center;
}
&__title {
@include audit-title;
}
&__text {
font-size: var(--font-size-lg);
line-height: var(--leading-relaxed);
color: var(--color-text);
max-width: 34rem;
}
&__cta {
display: inline-block;
padding: var(--space-4) var(--space-8);
border-radius: var(--radius-lg);
background: var(--contact-button-bg);
color: var(--contact-button-text);
font-family: var(--font-headline);
font-size: var(--font-size-lg);
font-weight: 600;
text-decoration: none;
box-shadow: var(--shadow-md);
transition:
background var(--transition-base),
transform var(--transition-base);
&:hover {
background: var(--contact-button-hover-bg);
transform: translateY(-2px);
}
}
}
//
// The form itself
//
// Reuses the contact form design tokens so light/dark stay in sync, but
// keeps its own BEM block rather than borrowing contact-section__ names.
//
.audit-form {
display: flex;
flex-direction: column;
gap: var(--space-6);
&__group {
display: flex;
flex-direction: column;
gap: var(--space-2);
}
&__label {
font-family: var(--font-headline);
font-size: var(--font-size-base);
font-weight: 600;
color: var(--color-text);
}
&__hint {
font-size: var(--font-size-sm);
line-height: var(--leading-normal);
color: var(--color-text-muted);
margin: 0;
}
&__input,
&__textarea {
width: 100%;
padding: var(--space-3) var(--space-4);
border-radius: var(--radius-md);
border: 1px solid var(--contact-input-border);
background: var(--contact-input-bg);
color: var(--contact-input-text);
font-family: var(--font-body);
font-size: var(--font-size-base);
transition: border-color var(--transition-fast);
&::placeholder {
color: var(--contact-input-placeholder);
}
&:hover {
border-color: var(--color-secondary);
}
// The error state is never colour-only: the message below carries the
// same information in text.
&[aria-invalid='true'] {
border-color: var(--contact-status-error-border);
border-width: 2px;
}
}
&__textarea {
resize: vertical;
min-height: 7rem;
line-height: var(--leading-relaxed);
}
// Radio group
&__fieldset {
border: 1px solid var(--contact-input-border);
border-radius: var(--radius-md);
padding: var(--space-4) var(--space-5);
display: flex;
flex-direction: column;
gap: var(--space-3);
&[aria-invalid='true'] {
border-color: var(--contact-status-error-border);
border-width: 2px;
}
}
&__legend {
font-family: var(--font-headline);
font-size: var(--font-size-base);
font-weight: 600;
color: var(--color-text);
padding-inline: var(--space-2);
}
&__radio-row,
&__checkbox-row {
display: flex;
align-items: flex-start;
gap: var(--space-3);
}
&__radio,
&__checkbox {
flex-shrink: 0;
// 1.25rem keeps the hit area comfortable; the coarse-pointer rule in
// globals.scss lifts it to the 44px minimum on touch devices.
width: 1.25rem;
height: 1.25rem;
margin-top: 0.15rem;
accent-color: var(--color-primary);
}
&__radio-label,
&__checkbox-label {
font-size: var(--font-size-base);
line-height: var(--leading-relaxed);
color: var(--color-text);
cursor: pointer;
}
&__consent-link {
color: var(--color-secondary);
text-decoration: underline;
}
// Errors
&__error {
font-size: var(--font-size-sm);
font-weight: 600;
line-height: var(--leading-normal);
color: var(--contact-status-error-text);
background: var(--contact-status-error-bg);
border-left: 3px solid var(--contact-status-error-border);
border-radius: var(--radius-sm);
padding: var(--space-2) var(--space-3);
margin: 0;
}
&__error-summary {
background: var(--contact-status-error-bg);
border: 2px solid var(--contact-status-error-border);
border-radius: var(--radius-md);
padding: var(--space-4) var(--space-5);
color: var(--contact-status-error-text);
}
&__error-summary-title {
font-family: var(--font-headline);
font-size: var(--font-size-base);
font-weight: 600;
margin: 0 0 var(--space-2);
}
&__error-summary-list {
margin: 0;
padding-left: var(--space-5);
display: flex;
flex-direction: column;
gap: var(--space-1);
}
&__error-summary-link {
color: inherit;
font-size: var(--font-size-sm);
text-decoration: underline;
}
// Status panels
&__status {
border-radius: var(--radius-md);
padding: var(--space-5) var(--space-6);
border-width: 2px;
border-style: solid;
&--success {
background: var(--contact-status-success-bg);
border-color: var(--contact-status-success-border);
color: var(--contact-status-success-text);
}
&--error {
background: var(--contact-status-error-bg);
border-color: var(--contact-status-error-border);
color: var(--contact-status-error-text);
}
}
&__status-title {
font-family: var(--font-headline);
font-size: var(--font-size-lg);
font-weight: 600;
margin: 0 0 var(--space-2);
}
&__status-message {
font-size: var(--font-size-base);
line-height: var(--leading-relaxed);
margin: 0;
}
// Honeypot
// Off-canvas rather than display:none, because some bots skip fields that
// are obviously hidden.
&__honeypot {
position: absolute;
left: -9999px;
width: 1px;
height: 1px;
overflow: hidden;
}
// Submit
&__button {
align-self: flex-start;
padding: var(--space-4) var(--space-8);
border: none;
border-radius: var(--radius-lg);
background: var(--contact-button-bg);
color: var(--contact-button-text);
font-family: var(--font-headline);
font-size: var(--font-size-lg);
font-weight: 600;
cursor: pointer;
box-shadow: var(--shadow-md);
transition:
background var(--transition-base),
transform var(--transition-base);
&:hover:not(:disabled) {
background: var(--contact-button-hover-bg);
transform: translateY(-2px);
}
&:disabled {
opacity: 0.7;
cursor: progress;
}
@media (max-width: 768px) {
align-self: stretch;
text-align: center;
}
}
}

View File

@ -1,9 +1,18 @@
import '@testing-library/jest-dom/vitest';
import 'vitest-axe/extend-expect';
import * as matchers from 'vitest-axe/matchers';
import { expect } from 'vitest';
import { cleanup } from '@testing-library/react';
import { afterEach, expect } from 'vitest';
expect.extend(matchers);
// Testing Library only registers its own cleanup when `globals` is enabled in
// the Vitest config, which it is not here. Without this, every render stays in
// document.body and screen queries start matching elements from earlier tests.
afterEach(() => {
cleanup();
});
// jsdom has no layout engine, so IntersectionObserver (used by useScrollReveal)
// doesn't exist. A no-op stub is enough since tests don't assert on scroll reveal.
class IntersectionObserverStub implements IntersectionObserver {

9
src/vite-env.d.ts vendored
View File

@ -2,3 +2,12 @@
declare module '*.PNG';
declare module '*.png';
declare module '*.scss';
interface ImportMetaEnv {
/** Absolute URL of the audit request endpoint running on the VPS. */
readonly VITE_AUDIT_ENDPOINT?: string;
}
interface ImportMeta {
readonly env: ImportMetaEnv;
}

View File

@ -1,67 +0,0 @@
{
"buildCommand": "npm run build",
"outputDirectory": "dist",
"framework": "vite",
"headers": [
{
"source": "/(.*)",
"headers": [
{
"key": "Link",
"value": "</llms.txt>; rel=\"alternate\"; type=\"text/markdown\", </llms-full.txt>; rel=\"alternate\"; type=\"text/markdown\", </AGENTS.md>; rel=\"alternate\"; type=\"text/markdown\", </.well-known/agents.json>; rel=\"agents\"; type=\"application/json\", </.well-known/agent-card.json>; rel=\"agent-card\"; type=\"application/json\", </.well-known/webmcp.json>; rel=\"service-desc\"; type=\"application/json\", </.well-known/mcp.json>; rel=\"service-desc\"; type=\"application/json\""
},
{
"key": "Content-Security-Policy",
"value": "default-src 'self'; base-uri 'self'; font-src 'self' data:; img-src 'self' data: https:; script-src 'self' 'unsafe-inline'; style-src 'self' 'unsafe-inline'; connect-src 'self' https:; object-src 'none'; frame-ancestors 'none'; form-action 'self' mailto:; upgrade-insecure-requests"
},
{
"key": "Strict-Transport-Security",
"value": "max-age=63072000; includeSubDomains; preload"
},
{
"key": "Referrer-Policy",
"value": "strict-origin-when-cross-origin"
},
{
"key": "X-Content-Type-Options",
"value": "nosniff"
},
{
"key": "X-Frame-Options",
"value": "DENY"
},
{
"key": "Cross-Origin-Opener-Policy",
"value": "same-origin"
},
{
"key": "Permissions-Policy",
"value": "camera=(), microphone=(), geolocation=(), browsing-topics=()"
}
]
},
{
"source": "/.well-known/(.*)",
"headers": [
{
"key": "Access-Control-Allow-Origin",
"value": "*"
},
{
"key": "Cache-Control",
"value": "public, max-age=3600"
}
]
}
],
"rewrites": [
{ "source": "/", "destination": "/de/" },
{ "source": "/technical", "destination": "/de/technical" },
{ "source": "/de/", "destination": "/de/index.html" },
{ "source": "/de/technical", "destination": "/de/technical/index.html" },
{ "source": "/en/", "destination": "/en/index.html" },
{ "source": "/en/technical", "destination": "/en/technical/index.html" },
{ "source": "/privacy-policy", "destination": "/privacy-policy.html" },
{ "source": "/(.*)", "destination": "/index.html" }
]
}