From ee8ca1beb7132624db75bce5fb007085c5104ffd Mon Sep 17 00:00:00 2001 From: Sascha Date: Wed, 29 Jul 2026 17:43:50 +0200 Subject: [PATCH] feat: add free accessibility audit landing page (B2B) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- .gitignore | Bin 309 -> 359 bytes README.md | 55 +- backend/.env.example | 18 + backend/README.md | 67 ++ backend/deploy/DEPLOYMENT.md | 235 +++++ backend/deploy/audit-endpoint.service | 45 + backend/deploy/nginx-api.sascha-bach.de.conf | 63 ++ backend/package.json | 24 + backend/routes/audit.js | 100 ++ backend/server.js | 59 ++ backend/services/emailService.js | 117 +++ backend/services/env.js | 16 + package-lock.json | 896 +++++++++++------- package.json | 4 + prerender.js | 2 + public/.htaccess | 55 +- public/.well-known/.htaccess | 6 + public/AGENTS.md | 2 + public/llms-full.txt | 2 + public/llms.txt | 6 +- public/sitemap.xml | 20 + src/app/AppRouter.tsx | 3 + src/app/__tests__/a11y.smoke.test.tsx | 6 + .../audit/AuditDeliverableSection.tsx | 50 + src/components/audit/AuditForm.tsx | 373 ++++++++ src/components/audit/AuditFormSection.tsx | 28 + src/components/audit/AuditHeroSection.tsx | 27 + src/components/audit/AuditLimitsSection.tsx | 39 + src/components/audit/AuditProblemSection.tsx | 41 + src/components/audit/AuditProcessSection.tsx | 43 + .../audit/__tests__/AuditForm.test.tsx | 173 ++++ src/components/landing/AuditTeaserSection.tsx | 36 + src/components/layout/Footer.tsx | 9 +- src/components/layout/Navigation.tsx | 52 +- src/config/locales/de/audit.ts | 146 +++ src/config/locales/de/footer.ts | 1 + src/config/locales/de/index.ts | 5 +- src/config/locales/de/privacy-policy.ts | 31 +- src/config/locales/en/TextConfig.ts | 92 ++ src/config/locales/en/audit.ts | 146 +++ src/config/locales/en/footer.ts | 1 + src/config/locales/en/index.ts | 2 + src/config/locales/en/privacy-policy.ts | 31 +- src/hooks/useAuditForm.ts | 209 ++++ src/main.tsx | 6 + src/pages/AuditPage.tsx | 29 + src/pages/LandingPage.tsx | 2 + src/pages/PrivacyPolicy.tsx | 13 + src/scss/App.scss | 23 +- src/scss/layout/topbar.scss | 1 - src/scss/sections/_index.scss | 1 + src/scss/sections/audit-page.scss | 712 ++++++++++++++ src/test/setup.ts | 11 +- src/vite-env.d.ts | 9 + vercel.json | 67 -- 55 files changed, 3721 insertions(+), 489 deletions(-) create mode 100644 backend/.env.example create mode 100644 backend/README.md create mode 100644 backend/deploy/DEPLOYMENT.md create mode 100644 backend/deploy/audit-endpoint.service create mode 100644 backend/deploy/nginx-api.sascha-bach.de.conf create mode 100644 backend/package.json create mode 100644 backend/routes/audit.js create mode 100644 backend/server.js create mode 100644 backend/services/emailService.js create mode 100644 backend/services/env.js create mode 100644 public/.well-known/.htaccess create mode 100644 src/components/audit/AuditDeliverableSection.tsx create mode 100644 src/components/audit/AuditForm.tsx create mode 100644 src/components/audit/AuditFormSection.tsx create mode 100644 src/components/audit/AuditHeroSection.tsx create mode 100644 src/components/audit/AuditLimitsSection.tsx create mode 100644 src/components/audit/AuditProblemSection.tsx create mode 100644 src/components/audit/AuditProcessSection.tsx create mode 100644 src/components/audit/__tests__/AuditForm.test.tsx create mode 100644 src/components/landing/AuditTeaserSection.tsx create mode 100644 src/config/locales/de/audit.ts create mode 100644 src/config/locales/en/audit.ts create mode 100644 src/hooks/useAuditForm.ts create mode 100644 src/pages/AuditPage.tsx create mode 100644 src/scss/sections/audit-page.scss delete mode 100644 vercel.json diff --git a/.gitignore b/.gitignore index f1946d707af7a02b9559241c3af97fe9c1961ab4..cad85cf3fdebbf3f20d3f02d9d01c6e32900862e 100644 GIT binary patch literal 359 zcmZWkyKciU4BYh<1hTXNBL73tZb6p-Sp!*QTsAUk=wTp#U&=|Bc9HJH TnXVPGh#tGy^fs2Ytqtx65UqKQ literal 309 zcmZWkL2d&f4CIW&ACPj3RQiW1y;Z&B0TH`)R*qPVynveD*QA?Eb1^gH8Dk&(Hv{^W zp(Wg$&B`u2dw15^ga2rhz;mmmRcC>=s*P!gyWxwMku2*Q;MA6ocsV+IkCI;n6k*vN z<$q-S_L(~g=M{7HJqpF?u~)#{ogG!fb4)uvKG`D*R9QTb;H-df^D%?J(%*!>&3c^f o90)Lu_jJCT)rbl^%8bvc_!HNXR0b@G@8PEB1%oD=Zk(~^7gNz*R{#J2 diff --git a/README.md b/README.md index a5f3c18..5ec7c45 100644 --- a/README.md +++ b/README.md @@ -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 diff --git a/backend/.env.example b/backend/.env.example new file mode 100644 index 0000000..8c66bd8 --- /dev/null +++ b/backend/.env.example @@ -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= diff --git a/backend/README.md b/backend/README.md new file mode 100644 index 0000000..8af5180 --- /dev/null +++ b/backend/README.md @@ -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. diff --git a/backend/deploy/DEPLOYMENT.md b/backend/deploy/DEPLOYMENT.md new file mode 100644 index 0000000..f8d53ff --- /dev/null +++ b/backend/deploy/DEPLOYMENT.md @@ -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 @ + +# 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`. diff --git a/backend/deploy/audit-endpoint.service b/backend/deploy/audit-endpoint.service new file mode 100644 index 0000000..82fd218 --- /dev/null +++ b/backend/deploy/audit-endpoint.service @@ -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 diff --git a/backend/deploy/nginx-api.sascha-bach.de.conf b/backend/deploy/nginx-api.sascha-bach.de.conf new file mode 100644 index 0000000..93b6f98 --- /dev/null +++ b/backend/deploy/nginx-api.sascha-bach.de.conf @@ -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; + } +} diff --git a/backend/package.json b/backend/package.json new file mode 100644 index 0000000..0c4a8f1 --- /dev/null +++ b/backend/package.json @@ -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" + } +} diff --git a/backend/routes/audit.js b/backend/routes/audit.js new file mode 100644 index 0000000..1a13497 --- /dev/null +++ b/backend/routes/audit.js @@ -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 } diff --git a/backend/server.js b/backend/server.js new file mode 100644 index 0000000..b3f41ee --- /dev/null +++ b/backend/server.js @@ -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}`) +}) diff --git a/backend/services/emailService.js b/backend/services/emailService.js new file mode 100644 index 0000000..179e42b --- /dev/null +++ b/backend/services/emailService.js @@ -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('&', '&') + .replaceAll('<', '<') + .replaceAll('>', '>') + .replaceAll('"', '"') + .replaceAll("'", ''') +} + +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 = ` +

Neue Audit-Anfrage

+
+
Name
${escapeHtml(safeName)}
+
Firma
${escapeHtml(safeCompany)}
+
E-Mail
${escapeHtml(safeEmail)}
+
Seite
${escapeHtml(singleLine(url))}
+
Typ
${escapeHtml(roleLabel)}
+
B2B
bestätigt (§ 14 BGB)
+
+

Motivation

+

${escapeHtml(motivation).replaceAll('\n', '
')}

+ ` + + 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.') +} diff --git a/backend/services/env.js b/backend/services/env.js new file mode 100644 index 0000000..a9c1b0f --- /dev/null +++ b/backend/services/env.js @@ -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() +} diff --git a/package-lock.json b/package-lock.json index 285cf4d..5fd553c 100644 --- a/package-lock.json +++ b/package-lock.json @@ -8,6 +8,8 @@ "name": "portfolio-page", "version": "0.0.0", "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", @@ -16,7 +18,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", @@ -41,6 +45,13 @@ "vitest-axe": "^0.1.0" } }, + "node_modules/@adobe/css-tools": { + "version": "4.5.0", + "resolved": "https://registry.npmjs.org/@adobe/css-tools/-/css-tools-4.5.0.tgz", + "integrity": "sha512-6OzddxPio9UiWTCemp4N8cYLV2ZN1ncRnV1cVGtve7dhPOtRkleRyx32GQCYSwDYgaHU3USMm84tNsvKzRCa1Q==", + "dev": true, + "license": "MIT" + }, "node_modules/@asamuzakjp/css-color": { "version": "5.1.11", "resolved": "https://registry.npmjs.org/@asamuzakjp/css-color/-/css-color-5.1.11.tgz", @@ -93,13 +104,13 @@ "license": "MIT" }, "node_modules/@babel/code-frame": { - "version": "7.27.1", - "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.27.1.tgz", - "integrity": "sha512-cjQ7ZlQ0Mv3b47hABuTevyTuYN4i+loJKGeV9flcCgIK37cCXRh+L1bd3iBHlynerhQ7BhCkn2BPbQUL+rGqFg==", + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.29.7.tgz", + "integrity": "sha512-Aup7aUOfpbAUg2ROOJN6Iw5f9DMBlzu0mIkm/malLQFN/YQgO48wCj0Kxa3sEHJvPVFg7siR+qRInwXd2qhQKw==", "dev": true, "license": "MIT", "dependencies": { - "@babel/helper-validator-identifier": "^7.27.1", + "@babel/helper-validator-identifier": "^7.29.7", "js-tokens": "^4.0.0", "picocolors": "^1.1.1" }, @@ -108,9 +119,9 @@ } }, "node_modules/@babel/compat-data": { - "version": "7.28.5", - "resolved": "https://registry.npmjs.org/@babel/compat-data/-/compat-data-7.28.5.tgz", - "integrity": "sha512-6uFXyCayocRbqhZOB+6XcuZbkMNimwfVGFji8CTZnCzOHVGvDqzvitu1re2AU5LROliz7eQPhB8CpAMvnx9EjA==", + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/compat-data/-/compat-data-7.29.7.tgz", + "integrity": "sha512-locTkQyKvwIEgBzVrn8693ebc97F2U8ZHjbXwDXJ5Fn2TCpNwTlKcaKLkdHop5c/icOFE7qt7Q9JC5hnKNa6Gg==", "dev": true, "license": "MIT", "engines": { @@ -118,21 +129,21 @@ } }, "node_modules/@babel/core": { - "version": "7.28.5", - "resolved": "https://registry.npmjs.org/@babel/core/-/core-7.28.5.tgz", - "integrity": "sha512-e7jT4DxYvIDLk1ZHmU/m/mB19rex9sv0c2ftBtjSBv+kVM/902eh0fINUzD7UwLLNR+jU585GxUJ8/EBfAM5fw==", + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/core/-/core-7.29.7.tgz", + "integrity": "sha512-RgHBCvtjbOK2gXSNBNIkNoEc9qoVEtau3hj8gEqKQuL3HZAibKarWFEI3Lfm6EYKkLalOh8eSrj9b+ch9H/VBA==", "dev": true, "license": "MIT", "dependencies": { - "@babel/code-frame": "^7.27.1", - "@babel/generator": "^7.28.5", - "@babel/helper-compilation-targets": "^7.27.2", - "@babel/helper-module-transforms": "^7.28.3", - "@babel/helpers": "^7.28.4", - "@babel/parser": "^7.28.5", - "@babel/template": "^7.27.2", - "@babel/traverse": "^7.28.5", - "@babel/types": "^7.28.5", + "@babel/code-frame": "^7.29.7", + "@babel/generator": "^7.29.7", + "@babel/helper-compilation-targets": "^7.29.7", + "@babel/helper-module-transforms": "^7.29.7", + "@babel/helpers": "^7.29.7", + "@babel/parser": "^7.29.7", + "@babel/template": "^7.29.7", + "@babel/traverse": "^7.29.7", + "@babel/types": "^7.29.7", "@jridgewell/remapping": "^2.3.5", "convert-source-map": "^2.0.0", "debug": "^4.1.0", @@ -149,14 +160,14 @@ } }, "node_modules/@babel/generator": { - "version": "7.28.5", - "resolved": "https://registry.npmjs.org/@babel/generator/-/generator-7.28.5.tgz", - "integrity": "sha512-3EwLFhZ38J4VyIP6WNtt2kUdW9dokXA9Cr4IVIFHuCpZ3H8/YFOl5JjZHisrn1fATPBmKKqXzDFvh9fUwHz6CQ==", + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/generator/-/generator-7.29.7.tgz", + "integrity": "sha512-DkXD5OJQaAQIdZ1bt3UZdEnHAn9Imd3IVBdX03UFe+ony9Ojw5pzr9YVKGDY1jt+Gcn/FnGkNf8r+Vj5NOJWtQ==", "dev": true, "license": "MIT", "dependencies": { - "@babel/parser": "^7.28.5", - "@babel/types": "^7.28.5", + "@babel/parser": "^7.29.7", + "@babel/types": "^7.29.7", "@jridgewell/gen-mapping": "^0.3.12", "@jridgewell/trace-mapping": "^0.3.28", "jsesc": "^3.0.2" @@ -166,14 +177,14 @@ } }, "node_modules/@babel/helper-compilation-targets": { - "version": "7.27.2", - "resolved": "https://registry.npmjs.org/@babel/helper-compilation-targets/-/helper-compilation-targets-7.27.2.tgz", - "integrity": "sha512-2+1thGUUWWjLTYTHZWK1n8Yga0ijBz1XAhUXcKy81rd5g6yh7hGqMp45v7cadSbEHc9G3OTv45SyneRN3ps4DQ==", + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-compilation-targets/-/helper-compilation-targets-7.29.7.tgz", + "integrity": "sha512-wem6WaBj4NaVYVdNhLPPVacES6ZJ+KBBfSkTMD3YZxbP3rm3Di85tJU5ljaUNhaOynt+Aj0xruhYuzQBt8n71g==", "dev": true, "license": "MIT", "dependencies": { - "@babel/compat-data": "^7.27.2", - "@babel/helper-validator-option": "^7.27.1", + "@babel/compat-data": "^7.29.7", + "@babel/helper-validator-option": "^7.29.7", "browserslist": "^4.24.0", "lru-cache": "^5.1.1", "semver": "^6.3.1" @@ -183,9 +194,9 @@ } }, "node_modules/@babel/helper-globals": { - "version": "7.28.0", - "resolved": "https://registry.npmjs.org/@babel/helper-globals/-/helper-globals-7.28.0.tgz", - "integrity": "sha512-+W6cISkXFa1jXsDEdYA8HeevQT/FULhxzR99pxphltZcVaugps53THCeiWA8SguxxpSp3gKPiuYfSWopkLQ4hw==", + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-globals/-/helper-globals-7.29.7.tgz", + "integrity": "sha512-3nQVUAtvkKH9zahfWgw96Jc/uFOmjACE1kQz82E2lqWmHBgjzbNlsC22nuQTfahmWeQtTq5nQ/4Nnd2A1wj4zA==", "dev": true, "license": "MIT", "engines": { @@ -193,29 +204,29 @@ } }, "node_modules/@babel/helper-module-imports": { - "version": "7.27.1", - "resolved": "https://registry.npmjs.org/@babel/helper-module-imports/-/helper-module-imports-7.27.1.tgz", - "integrity": "sha512-0gSFWUPNXNopqtIPQvlD5WgXYI5GY2kP2cCvoT8kczjbfcfuIljTbcWrulD1CIPIX2gt1wghbDy08yE1p+/r3w==", + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-module-imports/-/helper-module-imports-7.29.7.tgz", + "integrity": "sha512-ejHwrQQYcm9xnTivShn2IDOlIzInN34AXskvq9QicvCtEzq1Vzclu/tKF8Jq1Cg8JG2GL6/EmjgsCT7lXepE3g==", "dev": true, "license": "MIT", "dependencies": { - "@babel/traverse": "^7.27.1", - "@babel/types": "^7.27.1" + "@babel/traverse": "^7.29.7", + "@babel/types": "^7.29.7" }, "engines": { "node": ">=6.9.0" } }, "node_modules/@babel/helper-module-transforms": { - "version": "7.28.3", - "resolved": "https://registry.npmjs.org/@babel/helper-module-transforms/-/helper-module-transforms-7.28.3.tgz", - "integrity": "sha512-gytXUbs8k2sXS9PnQptz5o0QnpLL51SwASIORY6XaBKF88nsOT0Zw9szLqlSGQDP/4TljBAD5y98p2U1fqkdsw==", + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-module-transforms/-/helper-module-transforms-7.29.7.tgz", + "integrity": "sha512-UPUVSyXbOh627KiCIGQSgwWzGeBKLkaJ9PJEdrngIwMSzxLR4jS4+f1f1jb7VzBbg8nFLaYotvVPFCTqdrmTAg==", "dev": true, "license": "MIT", "dependencies": { - "@babel/helper-module-imports": "^7.27.1", - "@babel/helper-validator-identifier": "^7.27.1", - "@babel/traverse": "^7.28.3" + "@babel/helper-module-imports": "^7.29.7", + "@babel/helper-validator-identifier": "^7.29.7", + "@babel/traverse": "^7.29.7" }, "engines": { "node": ">=6.9.0" @@ -255,9 +266,9 @@ } }, "node_modules/@babel/helper-validator-option": { - "version": "7.27.1", - "resolved": "https://registry.npmjs.org/@babel/helper-validator-option/-/helper-validator-option-7.27.1.tgz", - "integrity": "sha512-YvjJow9FxbhFFKDSuFnVCe2WxXk1zWc22fFePVNEaWJEu8IrZVlda6N0uHwzZrUM1il7NC9Mlp4MaJYbYd9JSg==", + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-validator-option/-/helper-validator-option-7.29.7.tgz", + "integrity": "sha512-N9ZErrD+yW5geCDtBqnOoxmR8+tNKiGuxKlDpuJxfsqpa2dFcexaziGAE/qoHLiDDreVNMupxGmSoNlyvsA3gw==", "dev": true, "license": "MIT", "engines": { @@ -265,14 +276,14 @@ } }, "node_modules/@babel/helpers": { - "version": "7.28.4", - "resolved": "https://registry.npmjs.org/@babel/helpers/-/helpers-7.28.4.tgz", - "integrity": "sha512-HFN59MmQXGHVyYadKLVumYsA9dBFun/ldYxipEjzA4196jpLZd8UjEEBLkbEkvfYreDqJhZxYAWFPtrfhNpj4w==", + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helpers/-/helpers-7.29.7.tgz", + "integrity": "sha512-1k2lAGRMfHTcwuNYcCNUmaUffmQv8KWMfh2iJUUeRlwlwH4FdNG7mfPI10NPfLHJFThE4Tyr4mv7kTNZOiPuBg==", "dev": true, "license": "MIT", "dependencies": { - "@babel/template": "^7.27.2", - "@babel/types": "^7.28.4" + "@babel/template": "^7.29.7", + "@babel/types": "^7.29.7" }, "engines": { "node": ">=6.9.0" @@ -337,33 +348,33 @@ } }, "node_modules/@babel/template": { - "version": "7.27.2", - "resolved": "https://registry.npmjs.org/@babel/template/-/template-7.27.2.tgz", - "integrity": "sha512-LPDZ85aEJyYSd18/DkjNh4/y1ntkE5KwUHWTiqgRxruuZL2F1yuHligVHLvcHY2vMHXttKFpJn6LwfI7cw7ODw==", + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/template/-/template-7.29.7.tgz", + "integrity": "sha512-puq+Gf35oI24FeN11LkoUQFqv9uwNeWpxXZi/Ji3rRIoKAzKnxRaZ+Gkj0vKS9ZCiTESfng1N9LyOyXvo+m+Gg==", "dev": true, "license": "MIT", "dependencies": { - "@babel/code-frame": "^7.27.1", - "@babel/parser": "^7.27.2", - "@babel/types": "^7.27.1" + "@babel/code-frame": "^7.29.7", + "@babel/parser": "^7.29.7", + "@babel/types": "^7.29.7" }, "engines": { "node": ">=6.9.0" } }, "node_modules/@babel/traverse": { - "version": "7.28.5", - "resolved": "https://registry.npmjs.org/@babel/traverse/-/traverse-7.28.5.tgz", - "integrity": "sha512-TCCj4t55U90khlYkVV/0TfkJkAkUg3jZFA3Neb7unZT8CPok7iiRfaX0F+WnqWqt7OxhOn0uBKXCw4lbL8W0aQ==", + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/traverse/-/traverse-7.29.7.tgz", + "integrity": "sha512-EhlfNQtZ+NK22w5BM61ciuiq1m58ed33Wr1Xan//ZRTy6hgjnwyCffRYwzsGXdASJSUJ1guZILsErh1eQcl+zw==", "dev": true, "license": "MIT", "dependencies": { - "@babel/code-frame": "^7.27.1", - "@babel/generator": "^7.28.5", - "@babel/helper-globals": "^7.28.0", - "@babel/parser": "^7.28.5", - "@babel/template": "^7.27.2", - "@babel/types": "^7.28.5", + "@babel/code-frame": "^7.29.7", + "@babel/generator": "^7.29.7", + "@babel/helper-globals": "^7.29.7", + "@babel/parser": "^7.29.7", + "@babel/template": "^7.29.7", + "@babel/types": "^7.29.7", "debug": "^4.3.1" }, "engines": { @@ -538,9 +549,9 @@ } }, "node_modules/@esbuild/aix-ppc64": { - "version": "0.25.12", - "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.25.12.tgz", - "integrity": "sha512-Hhmwd6CInZ3dwpuGTF8fJG6yoWmsToE+vYgD4nytZVxcu1ulHpUQRAB1UJ8+N1Am3Mz4+xOByoQoSZf4D+CpkA==", + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.28.1.tgz", + "integrity": "sha512-Svl7tq8k/08+p6CXPpRjQ1fKX+1odH/BQbb48fV6fj3CWHhsoIOoY87w1oHXm0qEpkIK3ZfVgp0hed3XBXzXMQ==", "cpu": [ "ppc64" ], @@ -555,9 +566,9 @@ } }, "node_modules/@esbuild/android-arm": { - "version": "0.25.12", - "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.25.12.tgz", - "integrity": "sha512-VJ+sKvNA/GE7Ccacc9Cha7bpS8nyzVv0jdVgwNDaR4gDMC/2TTRc33Ip8qrNYUcpkOHUT5OZ0bUcNNVZQ9RLlg==", + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.28.1.tgz", + "integrity": "sha512-0k2F129Xdio1TdJfzJ8sy1Q47vUD2NnwdhiAf7drUN1EBTfPf4hsFCtmMgu/6m8JSzsBrlmVjudMBQqOfG8usQ==", "cpu": [ "arm" ], @@ -572,9 +583,9 @@ } }, "node_modules/@esbuild/android-arm64": { - "version": "0.25.12", - "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.25.12.tgz", - "integrity": "sha512-6AAmLG7zwD1Z159jCKPvAxZd4y/VTO0VkprYy+3N2FtJ8+BQWFXU+OxARIwA46c5tdD9SsKGZ/1ocqBS/gAKHg==", + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.28.1.tgz", + "integrity": "sha512-34EGEbCIAgosYz6goLcopX6Mo7NyGv9tfwEM2/7Ce2VcVRk568iSvniGWcUXIy7wEDR1wzolcxcriFVrWYcwBg==", "cpu": [ "arm64" ], @@ -589,9 +600,9 @@ } }, "node_modules/@esbuild/android-x64": { - "version": "0.25.12", - "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.25.12.tgz", - "integrity": "sha512-5jbb+2hhDHx5phYR2By8GTWEzn6I9UqR11Kwf22iKbNpYrsmRB18aX/9ivc5cabcUiAT/wM+YIZ6SG9QO6a8kg==", + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.28.1.tgz", + "integrity": "sha512-dbwY7ltSMDWsRatcRpCnES4F+im88OCUgGZjy52shC7GqHRE/cYlxNbB4Z4UpJswpcc4Qxd2oE/ufM0p61IKng==", "cpu": [ "x64" ], @@ -606,9 +617,9 @@ } }, "node_modules/@esbuild/darwin-arm64": { - "version": "0.25.12", - "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.25.12.tgz", - "integrity": "sha512-N3zl+lxHCifgIlcMUP5016ESkeQjLj/959RxxNYIthIg+CQHInujFuXeWbWMgnTo4cp5XVHqFPmpyu9J65C1Yg==", + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.28.1.tgz", + "integrity": "sha512-TZbWkQY7kvTAXbXUT7uVACR5cMHsDiSz9z7ZKAX/RTq/WJEk3QyRr0wZpNhBDX+/0CtdqUIJlOiodQcta6tY3Q==", "cpu": [ "arm64" ], @@ -623,9 +634,9 @@ } }, "node_modules/@esbuild/darwin-x64": { - "version": "0.25.12", - "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.25.12.tgz", - "integrity": "sha512-HQ9ka4Kx21qHXwtlTUVbKJOAnmG1ipXhdWTmNXiPzPfWKpXqASVcWdnf2bnL73wgjNrFXAa3yYvBSd9pzfEIpA==", + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.28.1.tgz", + "integrity": "sha512-zfdzgK9ACBNZLI/CyHTOx81SyNbM6YXn7rxSgX97VjyiPl9W1i4Ka4fgKECEoFCKGpvBj5qArWIGgQjOwkgskQ==", "cpu": [ "x64" ], @@ -640,9 +651,9 @@ } }, "node_modules/@esbuild/freebsd-arm64": { - "version": "0.25.12", - "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.25.12.tgz", - "integrity": "sha512-gA0Bx759+7Jve03K1S0vkOu5Lg/85dou3EseOGUes8flVOGxbhDDh/iZaoek11Y8mtyKPGF3vP8XhnkDEAmzeg==", + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.28.1.tgz", + "integrity": "sha512-wG2EA8ENdEI0qhkSZMjfqrdY+ziCYCPMmtZjjIwOmXFjmyzEHn+UUxk5of+SYsjtfs3VpnlC7QLzSI5hY/rOAw==", "cpu": [ "arm64" ], @@ -657,9 +668,9 @@ } }, "node_modules/@esbuild/freebsd-x64": { - "version": "0.25.12", - "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.25.12.tgz", - "integrity": "sha512-TGbO26Yw2xsHzxtbVFGEXBFH0FRAP7gtcPE7P5yP7wGy7cXK2oO7RyOhL5NLiqTlBh47XhmIUXuGciXEqYFfBQ==", + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.28.1.tgz", + "integrity": "sha512-i7dZ9vQgnvSCzi/rYCXNgtF/U+eKZNJBzu3eTQbRgHnM7tNSizLOkRFAl3qzVc/Op/u5YkHHa4pf/3DOYHthLQ==", "cpu": [ "x64" ], @@ -674,9 +685,9 @@ } }, "node_modules/@esbuild/linux-arm": { - "version": "0.25.12", - "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.25.12.tgz", - "integrity": "sha512-lPDGyC1JPDou8kGcywY0YILzWlhhnRjdof3UlcoqYmS9El818LLfJJc3PXXgZHrHCAKs/Z2SeZtDJr5MrkxtOw==", + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.28.1.tgz", + "integrity": "sha512-qVXBOHQS+d5Y722GwJzJUtOLlX7km3CraOaGormF1pDtPd2C/l1SHRPgjLunLGe51Sh5YYWKMFDyV4SxgMQYTQ==", "cpu": [ "arm" ], @@ -691,9 +702,9 @@ } }, "node_modules/@esbuild/linux-arm64": { - "version": "0.25.12", - "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.25.12.tgz", - "integrity": "sha512-8bwX7a8FghIgrupcxb4aUmYDLp8pX06rGh5HqDT7bB+8Rdells6mHvrFHHW2JAOPZUbnjUpKTLg6ECyzvas2AQ==", + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.28.1.tgz", + "integrity": "sha512-yHs+0uc8+nvEAfAfxrWQKK5peSNzBc4PegcMO0EJ2hT71uA7vB8Ihg2e77R2P7SG5uYjPbHlLLmve4LLLRCf0g==", "cpu": [ "arm64" ], @@ -708,9 +719,9 @@ } }, "node_modules/@esbuild/linux-ia32": { - "version": "0.25.12", - "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.25.12.tgz", - "integrity": "sha512-0y9KrdVnbMM2/vG8KfU0byhUN+EFCny9+8g202gYqSSVMonbsCfLjUO+rCci7pM0WBEtz+oK/PIwHkzxkyharA==", + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.28.1.tgz", + "integrity": "sha512-d1z4ZuP0ajrfz/FhGT4vv278rX8KnPPJx8i5+AtK7TYbx9Le9F1hyzurZpkEyjkGa9dUGhQow4C1NmeGvqxN2w==", "cpu": [ "ia32" ], @@ -725,9 +736,9 @@ } }, "node_modules/@esbuild/linux-loong64": { - "version": "0.25.12", - "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.25.12.tgz", - "integrity": "sha512-h///Lr5a9rib/v1GGqXVGzjL4TMvVTv+s1DPoxQdz7l/AYv6LDSxdIwzxkrPW438oUXiDtwM10o9PmwS/6Z0Ng==", + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.28.1.tgz", + "integrity": "sha512-M5sRjUVZrkm1OAPR3dlOYzNmN+loZKGVi1VUQGrwuqLcbR6qeAz+famMhjASeH3YVKvZz+zT1jlh/keC3Rj/lg==", "cpu": [ "loong64" ], @@ -742,9 +753,9 @@ } }, "node_modules/@esbuild/linux-mips64el": { - "version": "0.25.12", - "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.25.12.tgz", - "integrity": "sha512-iyRrM1Pzy9GFMDLsXn1iHUm18nhKnNMWscjmp4+hpafcZjrr2WbT//d20xaGljXDBYHqRcl8HnxbX6uaA/eGVw==", + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.28.1.tgz", + "integrity": "sha512-mRObBZeHh2OxcBFPWE/FjylkRgZdYuiTR3vaTozquCGOH14iP9oN4x4Ge81CoIDYQrXmIxpFumJBu5MtZpnQJQ==", "cpu": [ "mips64el" ], @@ -759,9 +770,9 @@ } }, "node_modules/@esbuild/linux-ppc64": { - "version": "0.25.12", - "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.25.12.tgz", - "integrity": "sha512-9meM/lRXxMi5PSUqEXRCtVjEZBGwB7P/D4yT8UG/mwIdze2aV4Vo6U5gD3+RsoHXKkHCfSxZKzmDssVlRj1QQA==", + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.28.1.tgz", + "integrity": "sha512-slScBsMAb3GFDcdrCgLwZtPYRoH2H/youv10QiZyRjmsP48fznoveWytSgCI/R0ZcUgpc0ZhIUEx6LHts8yrfQ==", "cpu": [ "ppc64" ], @@ -776,9 +787,9 @@ } }, "node_modules/@esbuild/linux-riscv64": { - "version": "0.25.12", - "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.25.12.tgz", - "integrity": "sha512-Zr7KR4hgKUpWAwb1f3o5ygT04MzqVrGEGXGLnj15YQDJErYu/BGg+wmFlIDOdJp0PmB0lLvxFIOXZgFRrdjR0w==", + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.28.1.tgz", + "integrity": "sha512-kw0owk1o0GFETUJyW0jc0G4Yzs0BHZn0JDZ8JRT088vjJYX777BAs1fDGxAC+q831qOs2DTC96mNsG2opdfyyQ==", "cpu": [ "riscv64" ], @@ -793,9 +804,9 @@ } }, "node_modules/@esbuild/linux-s390x": { - "version": "0.25.12", - "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.25.12.tgz", - "integrity": "sha512-MsKncOcgTNvdtiISc/jZs/Zf8d0cl/t3gYWX8J9ubBnVOwlk65UIEEvgBORTiljloIWnBzLs4qhzPkJcitIzIg==", + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.28.1.tgz", + "integrity": "sha512-/lAIjX8aYFRByhh6L5rYtPEDRqa9de/4V/juOXcta5frjvzXO4/sqEtyytse0g3zZFuWu5cDN0MkLz2qRDD2Ag==", "cpu": [ "s390x" ], @@ -810,9 +821,9 @@ } }, "node_modules/@esbuild/linux-x64": { - "version": "0.25.12", - "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.25.12.tgz", - "integrity": "sha512-uqZMTLr/zR/ed4jIGnwSLkaHmPjOjJvnm6TVVitAa08SLS9Z0VM8wIRx7gWbJB5/J54YuIMInDquWyYvQLZkgw==", + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.28.1.tgz", + "integrity": "sha512-u/anNYF2mmVOEDwLtnQ1wOr3EZ9sTNGLWrsYGYwHWzGA3Si84IOkHXlbWTD1NB+9/1lcnweYKO54uhxZydNzfA==", "cpu": [ "x64" ], @@ -827,9 +838,9 @@ } }, "node_modules/@esbuild/netbsd-arm64": { - "version": "0.25.12", - "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.25.12.tgz", - "integrity": "sha512-xXwcTq4GhRM7J9A8Gv5boanHhRa/Q9KLVmcyXHCTaM4wKfIpWkdXiMog/KsnxzJ0A1+nD+zoecuzqPmCRyBGjg==", + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.28.1.tgz", + "integrity": "sha512-oks0DYbLwWMmaakTsCb+zL4E+aHRVLom9IJZOAthMQEPiQmydXHkziYEsGYRx0uNV/IjEKGAV941JzH02pflqw==", "cpu": [ "arm64" ], @@ -844,9 +855,9 @@ } }, "node_modules/@esbuild/netbsd-x64": { - "version": "0.25.12", - "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.25.12.tgz", - "integrity": "sha512-Ld5pTlzPy3YwGec4OuHh1aCVCRvOXdH8DgRjfDy/oumVovmuSzWfnSJg+VtakB9Cm0gxNO9BzWkj6mtO1FMXkQ==", + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.28.1.tgz", + "integrity": "sha512-aeL6lAnN89Hz43Mlh1G8ARasbuoYvSITDEx0tHh5b7jJnHcssqgjy9Yx430GDpmCa6OyrKoS0aNRjKundRizGg==", "cpu": [ "x64" ], @@ -861,9 +872,9 @@ } }, "node_modules/@esbuild/openbsd-arm64": { - "version": "0.25.12", - "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.25.12.tgz", - "integrity": "sha512-fF96T6KsBo/pkQI950FARU9apGNTSlZGsv1jZBAlcLL1MLjLNIWPBkj5NlSz8aAzYKg+eNqknrUJ24QBybeR5A==", + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.28.1.tgz", + "integrity": "sha512-MEFJe5C3R8pwXdZ5Y21oo6m7ePiS0d9pWucn99O/wvyJZChoIQKrQDxKrGeW8F5+T0okTHesAmDeiHDTIq0V/Q==", "cpu": [ "arm64" ], @@ -878,9 +889,9 @@ } }, "node_modules/@esbuild/openbsd-x64": { - "version": "0.25.12", - "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.25.12.tgz", - "integrity": "sha512-MZyXUkZHjQxUvzK7rN8DJ3SRmrVrke8ZyRusHlP+kuwqTcfWLyqMOE3sScPPyeIXN/mDJIfGXvcMqCgYKekoQw==", + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.28.1.tgz", + "integrity": "sha512-i/ZLIOafE0Z8cI/XANJAixoJL/uRAoS2xOA3rb0xN+KK0K177cMAsQYkzHtBrtMXAKuAc7HGgcWiZ/sRC1Nxgw==", "cpu": [ "x64" ], @@ -895,9 +906,9 @@ } }, "node_modules/@esbuild/openharmony-arm64": { - "version": "0.25.12", - "resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.25.12.tgz", - "integrity": "sha512-rm0YWsqUSRrjncSXGA7Zv78Nbnw4XL6/dzr20cyrQf7ZmRcsovpcRBdhD43Nuk3y7XIoW2OxMVvwuRvk9XdASg==", + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.28.1.tgz", + "integrity": "sha512-ge+Z7EXFNt2BO1oAMsVpiQ8EwndV9i1xXerAeTIK7AtPs3bKFXQM7nlRxDSIUIMeueR1CNXxqztLzdNeReKBJg==", "cpu": [ "arm64" ], @@ -912,9 +923,9 @@ } }, "node_modules/@esbuild/sunos-x64": { - "version": "0.25.12", - "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.25.12.tgz", - "integrity": "sha512-3wGSCDyuTHQUzt0nV7bocDy72r2lI33QL3gkDNGkod22EsYl04sMf0qLb8luNKTOmgF/eDEDP5BFNwoBKH441w==", + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.28.1.tgz", + "integrity": "sha512-BEjgtECkL3vY+SaSQ6nzVfiALUeFxpawyp8Jmf5PtYhf1Ug40N1h/hxlhts+f1FvSvarEigdxS3BlSMI2PJLcQ==", "cpu": [ "x64" ], @@ -929,9 +940,9 @@ } }, "node_modules/@esbuild/win32-arm64": { - "version": "0.25.12", - "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.25.12.tgz", - "integrity": "sha512-rMmLrur64A7+DKlnSuwqUdRKyd3UE7oPJZmnljqEptesKM8wx9J8gx5u0+9Pq0fQQW8vqeKebwNXdfOyP+8Bsg==", + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.28.1.tgz", + "integrity": "sha512-lCv9eK/H6ZJWbE7bh2nw54CZ9M2nupBxJcTsdk/QQnWkdSjKGuxmmH8/GWrlT1eMmZfn4dGcCjRte397WqfQXA==", "cpu": [ "arm64" ], @@ -946,9 +957,9 @@ } }, "node_modules/@esbuild/win32-ia32": { - "version": "0.25.12", - "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.25.12.tgz", - "integrity": "sha512-HkqnmmBoCbCwxUKKNPBixiWDGCpQGVsrQfJoVGYLPT41XWF8lHuE5N6WhVia2n4o5QK5M4tYr21827fNhi4byQ==", + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.28.1.tgz", + "integrity": "sha512-zvb/mB2bSCoJOpoCBgYKKpX6YM6mJBlBUVUtVj41DlZJVEB6/0CKlRYxP5wWl1C1ILiCoAU5wZZ4q1P3qeS6Eg==", "cpu": [ "ia32" ], @@ -963,9 +974,9 @@ } }, "node_modules/@esbuild/win32-x64": { - "version": "0.25.12", - "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.25.12.tgz", - "integrity": "sha512-alJC0uCZpTFrSL0CCDjcgleBXPnCrEAhTBILpeAp7M/OFgoqtAetfBzX0xM00MUsVVPpVjlPuMbREqnZCXaTnA==", + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.28.1.tgz", + "integrity": "sha512-bm4Mowrv+GXMlpWX++EcXw/iLyd1o3+bJkC2DkWXYVvgZCqD/bSj9ctZeAMC3cIxgjRVR2Dufaiu4YPxr5gW1A==", "cpu": [ "x64" ], @@ -1154,6 +1165,24 @@ } } }, + "node_modules/@fontsource/comfortaa": { + "version": "5.3.0", + "resolved": "https://registry.npmjs.org/@fontsource/comfortaa/-/comfortaa-5.3.0.tgz", + "integrity": "sha512-ab4DERH0q6ko8QCpEZzOV9ebK39LgQ2oM91AkLNqu0VIxxwT0nV2D3sOuI7iLirWWq4IMooaIN6/2llxrDN2JA==", + "license": "OFL-1.1", + "funding": { + "url": "https://github.com/sponsors/ayuhito" + } + }, + "node_modules/@fontsource/quicksand": { + "version": "5.3.0", + "resolved": "https://registry.npmjs.org/@fontsource/quicksand/-/quicksand-5.3.0.tgz", + "integrity": "sha512-epmMUyXI39f88bZSCyKlHTZqUWg4O7DqrTXOKqsqbVKMLshX9RtIYCUIw+G2nOfEwdIHIjYbeWdwHRcEzRXBRQ==", + "license": "OFL-1.1", + "funding": { + "url": "https://github.com/sponsors/ayuhito" + } + }, "node_modules/@humanfs/core": { "version": "0.19.1", "resolved": "https://registry.npmjs.org/@humanfs/core/-/core-0.19.1.tgz", @@ -1609,9 +1638,9 @@ "license": "MIT" }, "node_modules/@rollup/rollup-android-arm-eabi": { - "version": "4.53.3", - "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.53.3.tgz", - "integrity": "sha512-mRSi+4cBjrRLoaal2PnqH82Wqyb+d3HsPUN/W+WslCXsZsyHa9ZeQQX/pQsZaVIWDkPcpV6jJ+3KLbTbgnwv8w==", + "version": "4.62.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.62.3.tgz", + "integrity": "sha512-c0wdcekXtQvvn5Tsrk/+op/gUArrbWaFduBnTLP2l1cKLSQs4diMWjJw3m6A0DdzT8dAAX95KpkJ3qynCePbmw==", "cpu": [ "arm" ], @@ -1623,9 +1652,9 @@ ] }, "node_modules/@rollup/rollup-android-arm64": { - "version": "4.53.3", - "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.53.3.tgz", - "integrity": "sha512-CbDGaMpdE9sh7sCmTrTUyllhrg65t6SwhjlMJsLr+J8YjFuPmCEjbBSx4Z/e4SmDyH3aB5hGaJUP2ltV/vcs4w==", + "version": "4.62.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.62.3.tgz", + "integrity": "sha512-3YjElDdWN+qXAFbJ/CzPV+0wspLqh54k/I6GfdYtEJRqg7buSgc1yPM3B+93j1M4neobtkATHZTmxK2AMVGfnA==", "cpu": [ "arm64" ], @@ -1637,9 +1666,9 @@ ] }, "node_modules/@rollup/rollup-darwin-arm64": { - "version": "4.53.3", - "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.53.3.tgz", - "integrity": "sha512-Nr7SlQeqIBpOV6BHHGZgYBuSdanCXuw09hon14MGOLGmXAFYjx1wNvquVPmpZnl0tLjg25dEdr4IQ6GgyToCUA==", + "version": "4.62.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.62.3.tgz", + "integrity": "sha512-Pch2pFNOxxz1hTjypIdPyRTR6riiwRl84+VcN9djS680fw+Co1nAJINrdpqp7KV0NvyuU8ilZXZCjd7ykJl1GQ==", "cpu": [ "arm64" ], @@ -1651,9 +1680,9 @@ ] }, "node_modules/@rollup/rollup-darwin-x64": { - "version": "4.53.3", - "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.53.3.tgz", - "integrity": "sha512-DZ8N4CSNfl965CmPktJ8oBnfYr3F8dTTNBQkRlffnUarJ2ohudQD17sZBa097J8xhQ26AwhHJ5mvUyQW8ddTsQ==", + "version": "4.62.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.62.3.tgz", + "integrity": "sha512-LEuncFUHFiF8t4yZVZvvZA1wk0pjAscRnsrn1EfTEmN4HXotBi2YtcnLRyaK6UbuczW7xZS5ES+81Rdz8Z0T6g==", "cpu": [ "x64" ], @@ -1665,9 +1694,9 @@ ] }, "node_modules/@rollup/rollup-freebsd-arm64": { - "version": "4.53.3", - "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.53.3.tgz", - "integrity": "sha512-yMTrCrK92aGyi7GuDNtGn2sNW+Gdb4vErx4t3Gv/Tr+1zRb8ax4z8GWVRfr3Jw8zJWvpGHNpss3vVlbF58DZ4w==", + "version": "4.62.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.62.3.tgz", + "integrity": "sha512-zvBUvsQUpOWALdDsk6qbS8bXf2VxmPisuudNDrY7x0p0jBdsoZl8HsHczIOgkQiZldmcacMKtBzpoGVNeIe2bQ==", "cpu": [ "arm64" ], @@ -1679,9 +1708,9 @@ ] }, "node_modules/@rollup/rollup-freebsd-x64": { - "version": "4.53.3", - "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.53.3.tgz", - "integrity": "sha512-lMfF8X7QhdQzseM6XaX0vbno2m3hlyZFhwcndRMw8fbAGUGL3WFMBdK0hbUBIUYcEcMhVLr1SIamDeuLBnXS+Q==", + "version": "4.62.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.62.3.tgz", + "integrity": "sha512-C2KmNrcSem/AMg984H/dev+si0lieQGdXdR/lYGJnuumXnFb9Y7QdiI62obFdLlxRYLBv4P0eUVIDbD4c1vVvw==", "cpu": [ "x64" ], @@ -1693,13 +1722,16 @@ ] }, "node_modules/@rollup/rollup-linux-arm-gnueabihf": { - "version": "4.53.3", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.53.3.tgz", - "integrity": "sha512-k9oD15soC/Ln6d2Wv/JOFPzZXIAIFLp6B+i14KhxAfnq76ajt0EhYc5YPeX6W1xJkAdItcVT+JhKl1QZh44/qw==", + "version": "4.62.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.62.3.tgz", + "integrity": "sha512-ggXnsTAEzNQx74XpunRsiZ9aBZDsI7XIa0hm2nzR9f4WzH5/f/d73ZSDaC5ejJ8YLY4NW+V3wr0tjOaeCq8hqA==", "cpu": [ "arm" ], "dev": true, + "libc": [ + "glibc" + ], "license": "MIT", "optional": true, "os": [ @@ -1707,13 +1739,16 @@ ] }, "node_modules/@rollup/rollup-linux-arm-musleabihf": { - "version": "4.53.3", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.53.3.tgz", - "integrity": "sha512-vTNlKq+N6CK/8UktsrFuc+/7NlEYVxgaEgRXVUVK258Z5ymho29skzW1sutgYjqNnquGwVUObAaxae8rZ6YMhg==", + "version": "4.62.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.62.3.tgz", + "integrity": "sha512-2vng+FlzNUhKZxtej3IUqJgbZoQk2M/dwQM20+ULV0R/E/8tr9/P6uEf2iiGIk4HL0zMKh5Jry7mUHdUOvyGgA==", "cpu": [ "arm" ], "dev": true, + "libc": [ + "musl" + ], "license": "MIT", "optional": true, "os": [ @@ -1721,13 +1756,16 @@ ] }, "node_modules/@rollup/rollup-linux-arm64-gnu": { - "version": "4.53.3", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.53.3.tgz", - "integrity": "sha512-RGrFLWgMhSxRs/EWJMIFM1O5Mzuz3Xy3/mnxJp/5cVhZ2XoCAxJnmNsEyeMJtpK+wu0FJFWz+QF4mjCA7AUQ3w==", + "version": "4.62.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.62.3.tgz", + "integrity": "sha512-LLLFZKt4/Nraf9rxDkhiU8QVgLF4WmCkfr0L4fj0fPfIZFBib0DeiFk1hhaYKd03LFAFJcxHslhDFlNJLylf5Q==", "cpu": [ "arm64" ], "dev": true, + "libc": [ + "glibc" + ], "license": "MIT", "optional": true, "os": [ @@ -1735,13 +1773,16 @@ ] }, "node_modules/@rollup/rollup-linux-arm64-musl": { - "version": "4.53.3", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.53.3.tgz", - "integrity": "sha512-kASyvfBEWYPEwe0Qv4nfu6pNkITLTb32p4yTgzFCocHnJLAHs+9LjUu9ONIhvfT/5lv4YS5muBHyuV84epBo/A==", + "version": "4.62.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.62.3.tgz", + "integrity": "sha512-WJkdQCvS9sWNOUBJZfQRKpZGFBztRzcowI+nndmflKgU4XY+3a420FgTOSKTsVqJbnzSxeT4vaJalpOaPo2YCQ==", "cpu": [ "arm64" ], "dev": true, + "libc": [ + "musl" + ], "license": "MIT", "optional": true, "os": [ @@ -1749,13 +1790,33 @@ ] }, "node_modules/@rollup/rollup-linux-loong64-gnu": { - "version": "4.53.3", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-gnu/-/rollup-linux-loong64-gnu-4.53.3.tgz", - "integrity": "sha512-JiuKcp2teLJwQ7vkJ95EwESWkNRFJD7TQgYmCnrPtlu50b4XvT5MOmurWNrCj3IFdyjBQ5p9vnrX4JM6I8OE7g==", + "version": "4.62.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-gnu/-/rollup-linux-loong64-gnu-4.62.3.tgz", + "integrity": "sha512-PwHXCCS2n64/1Ot6rP1YEYA02MGYBcQlr8CSZZyrUG2O7NH6NklYmvr9v3Jy+5e/eDeNchc/ukmKJi9LuflMIQ==", "cpu": [ "loong64" ], "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-loong64-musl": { + "version": "4.62.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-musl/-/rollup-linux-loong64-musl-4.62.3.tgz", + "integrity": "sha512-vUjxINQu3RC8NZS3ykk1gN65gIz8pAopOq2HXuZhiIxHdx7TFvDG+jgrdSgInu1Eza4/Rfi2VzZgyIgEH4WOaw==", + "cpu": [ + "loong64" + ], + "dev": true, + "libc": [ + "musl" + ], "license": "MIT", "optional": true, "os": [ @@ -1763,13 +1824,33 @@ ] }, "node_modules/@rollup/rollup-linux-ppc64-gnu": { - "version": "4.53.3", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-gnu/-/rollup-linux-ppc64-gnu-4.53.3.tgz", - "integrity": "sha512-EoGSa8nd6d3T7zLuqdojxC20oBfNT8nexBbB/rkxgKj5T5vhpAQKKnD+h3UkoMuTyXkP5jTjK/ccNRmQrPNDuw==", + "version": "4.62.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-gnu/-/rollup-linux-ppc64-gnu-4.62.3.tgz", + "integrity": "sha512-wzko4aJ13+0G3kGnviCg5gnXFKd40izKsrf2uOw12US4XqprkDrmwOpeW14aSNa37V8bfPcz5Fkob6LZ3BAPmA==", "cpu": [ "ppc64" ], "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-ppc64-musl": { + "version": "4.62.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-musl/-/rollup-linux-ppc64-musl-4.62.3.tgz", + "integrity": "sha512-8120ue0JUMSwy11stlwnfdX3pPd+WZYGCDBwEHWtIHi6pOpZmsEF5QKB7a/UN+XFdqvobxz98kv8RTqikyCEBw==", + "cpu": [ + "ppc64" + ], + "dev": true, + "libc": [ + "musl" + ], "license": "MIT", "optional": true, "os": [ @@ -1777,13 +1858,16 @@ ] }, "node_modules/@rollup/rollup-linux-riscv64-gnu": { - "version": "4.53.3", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.53.3.tgz", - "integrity": "sha512-4s+Wped2IHXHPnAEbIB0YWBv7SDohqxobiiPA1FIWZpX+w9o2i4LezzH/NkFUl8LRci/8udci6cLq+jJQlh+0g==", + "version": "4.62.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.62.3.tgz", + "integrity": "sha512-XLFHnR3tXMjbOCh2vtVJHmxt+995uJsTERQyseFDRA0xxMxyTZPLa3OIUlyFaO4mF/Lu0FjmWHCuPXJT1n/IOg==", "cpu": [ "riscv64" ], "dev": true, + "libc": [ + "glibc" + ], "license": "MIT", "optional": true, "os": [ @@ -1791,13 +1875,16 @@ ] }, "node_modules/@rollup/rollup-linux-riscv64-musl": { - "version": "4.53.3", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.53.3.tgz", - "integrity": "sha512-68k2g7+0vs2u9CxDt5ktXTngsxOQkSEV/xBbwlqYcUrAVh6P9EgMZvFsnHy4SEiUl46Xf0IObWVbMvPrr2gw8A==", + "version": "4.62.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.62.3.tgz", + "integrity": "sha512-se6yXvNGMIl0f+RQzyh7XAmia8/9kplQx424wnG2w0C1oi6XgO6Y8otKhdXFHbHs88Ihavzmvh1NWjuovE76BQ==", "cpu": [ "riscv64" ], "dev": true, + "libc": [ + "musl" + ], "license": "MIT", "optional": true, "os": [ @@ -1805,13 +1892,16 @@ ] }, "node_modules/@rollup/rollup-linux-s390x-gnu": { - "version": "4.53.3", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.53.3.tgz", - "integrity": "sha512-VYsFMpULAz87ZW6BVYw3I6sWesGpsP9OPcyKe8ofdg9LHxSbRMd7zrVrr5xi/3kMZtpWL/wC+UIJWJYVX5uTKg==", + "version": "4.62.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.62.3.tgz", + "integrity": "sha512-gNoxRefktVIiGflpONuxWWXZAzIQG++z9qHO3xKwk4WdDMuQja3JHGfE1u0i3PfPDyvhypdk+WrgIJqLhGG7sg==", "cpu": [ "s390x" ], "dev": true, + "libc": [ + "glibc" + ], "license": "MIT", "optional": true, "os": [ @@ -1819,13 +1909,16 @@ ] }, "node_modules/@rollup/rollup-linux-x64-gnu": { - "version": "4.53.3", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.53.3.tgz", - "integrity": "sha512-3EhFi1FU6YL8HTUJZ51imGJWEX//ajQPfqWLI3BQq4TlvHy4X0MOr5q3D2Zof/ka0d5FNdPwZXm3Yyib/UEd+w==", + "version": "4.62.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.62.3.tgz", + "integrity": "sha512-V4KtWtQfAFMU7+9/A/VDps/VI8CHd3cYz0L8sgJzz8qK7eY7wI4ruFD82UYIYvW9Z4DtlTfhQcsl4XyPHW5uSg==", "cpu": [ "x64" ], "dev": true, + "libc": [ + "glibc" + ], "license": "MIT", "optional": true, "os": [ @@ -1833,9 +1926,26 @@ ] }, "node_modules/@rollup/rollup-linux-x64-musl": { - "version": "4.53.3", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.53.3.tgz", - "integrity": "sha512-eoROhjcc6HbZCJr+tvVT8X4fW3/5g/WkGvvmwz/88sDtSJzO7r/blvoBDgISDiCjDRZmHpwud7h+6Q9JxFwq1Q==", + "version": "4.62.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.62.3.tgz", + "integrity": "sha512-LBx9LYXvj2CBkMkjLdNAWLwH0MLMin7do2VcVo9kVPibGLkY0BQQut2fv7NVqkXqZ/CrAu9LqDHVV1xHCMpCPw==", + "cpu": [ + "x64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-openbsd-x64": { + "version": "4.62.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-openbsd-x64/-/rollup-openbsd-x64-4.62.3.tgz", + "integrity": "sha512-ABVf3Q0RCu7NcyCCOZQI0pJ3GuSdfSl8EXcy88QtdceIMIoCUdfhsJChZ64L9zVM2aJHjde1Bhn5uqSRcX9ySA==", "cpu": [ "x64" ], @@ -1843,13 +1953,13 @@ "license": "MIT", "optional": true, "os": [ - "linux" + "openbsd" ] }, "node_modules/@rollup/rollup-openharmony-arm64": { - "version": "4.53.3", - "resolved": "https://registry.npmjs.org/@rollup/rollup-openharmony-arm64/-/rollup-openharmony-arm64-4.53.3.tgz", - "integrity": "sha512-OueLAWgrNSPGAdUdIjSWXw+u/02BRTcnfw9PN41D2vq/JSEPnJnVuBgw18VkN8wcd4fjUs+jFHVM4t9+kBSNLw==", + "version": "4.62.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-openharmony-arm64/-/rollup-openharmony-arm64-4.62.3.tgz", + "integrity": "sha512-+2Cy/ldweGBLlPIKsQLF8U5N44a0KDdbrk1rAjHOM9M2K+kGdIVjHLmmrZIcx+9Ny3ke/1JomCsDI1ocb11+sg==", "cpu": [ "arm64" ], @@ -1861,9 +1971,9 @@ ] }, "node_modules/@rollup/rollup-win32-arm64-msvc": { - "version": "4.53.3", - "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.53.3.tgz", - "integrity": "sha512-GOFuKpsxR/whszbF/bzydebLiXIHSgsEUp6M0JI8dWvi+fFa1TD6YQa4aSZHtpmh2/uAlj/Dy+nmby3TJ3pkTw==", + "version": "4.62.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.62.3.tgz", + "integrity": "sha512-dtZvzc8BedpSaFNy75x6uiWwAGTH+aZHDtdrqP6qk+WcLJrfti6sGje1ZJ9UxyzDLF23d/mV+PaMwuC0hL7UVA==", "cpu": [ "arm64" ], @@ -1875,9 +1985,9 @@ ] }, "node_modules/@rollup/rollup-win32-ia32-msvc": { - "version": "4.53.3", - "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.53.3.tgz", - "integrity": "sha512-iah+THLcBJdpfZ1TstDFbKNznlzoxa8fmnFYK4V67HvmuNYkVdAywJSoteUszvBQ9/HqN2+9AZghbajMsFT+oA==", + "version": "4.62.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.62.3.tgz", + "integrity": "sha512-Rj8Ra4noo+aYy7sKBggCx0407mws34kAb1ySyWuq5DAtFBQdkSwnsjCgPrhPe9cvgBKZIukpE+CVHvORCS93kQ==", "cpu": [ "ia32" ], @@ -1889,9 +1999,9 @@ ] }, "node_modules/@rollup/rollup-win32-x64-gnu": { - "version": "4.53.3", - "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-gnu/-/rollup-win32-x64-gnu-4.53.3.tgz", - "integrity": "sha512-J9QDiOIZlZLdcot5NXEepDkstocktoVjkaKUtqzgzpt2yWjGlbYiKyp05rWwk4nypbYUNoFAztEgixoLaSETkg==", + "version": "4.62.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-gnu/-/rollup-win32-x64-gnu-4.62.3.tgz", + "integrity": "sha512-vp7N084ew/odXn2gi/mzm9mUkQu9l6AiN6dt4IeUM2Uvm9o+cVmP+YkqbMOteLbiGgqBBlJZjIMYVCfOOIVbVQ==", "cpu": [ "x64" ], @@ -1903,9 +2013,9 @@ ] }, "node_modules/@rollup/rollup-win32-x64-msvc": { - "version": "4.53.3", - "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.53.3.tgz", - "integrity": "sha512-UhTd8u31dXadv0MopwGgNOBpUVROFKWVQgAg5N1ESyCz8AuBcMqm4AuTjrwgQKGDfoFuz02EuMRHQIw/frmYKQ==", + "version": "4.62.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.62.3.tgz", + "integrity": "sha512-MOG/3gTOn4Fwf574RVOaY61I5o6P90legkFADiTyn1hyjNydT+cerU2rLUwPdZkKKyJ+iT+K9p7WXK4LM1Ka6g==", "cpu": [ "x64" ], @@ -1953,6 +2063,36 @@ "dequal": "^2.0.3" } }, + "node_modules/@testing-library/jest-dom": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/@testing-library/jest-dom/-/jest-dom-7.0.0.tgz", + "integrity": "sha512-HKAH9C6mBo5yBG6yRO5i43L2iisencAo5z+o5P/saHUoY+miC5ivXRxHBJcFyB5ypPNxHJdK3BoF/3O4DIptMg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@adobe/css-tools": "^4.4.0", + "aria-query": "^5.0.0", + "css.escape": "^1.5.1", + "dom-accessibility-api": "^0.6.3", + "picocolors": "^1.1.1", + "redent": "^3.0.0" + }, + "engines": { + "node": ">=22", + "npm": ">=6", + "yarn": ">=1" + }, + "peerDependencies": { + "@testing-library/dom": ">=10 <11" + } + }, + "node_modules/@testing-library/jest-dom/node_modules/dom-accessibility-api": { + "version": "0.6.3", + "resolved": "https://registry.npmjs.org/dom-accessibility-api/-/dom-accessibility-api-0.6.3.tgz", + "integrity": "sha512-7ZgogeTnjuHbo+ct10G9Ffp0mif17idi0IyWNVA/wcwcm7NPOD/WEHVP3n7n3MhXqxoIYm8d6MuZohYWIZ4T3w==", + "dev": true, + "license": "MIT" + }, "node_modules/@testing-library/react": { "version": "16.3.2", "resolved": "https://registry.npmjs.org/@testing-library/react/-/react-16.3.2.tgz", @@ -1981,6 +2121,20 @@ } } }, + "node_modules/@testing-library/user-event": { + "version": "14.6.1", + "resolved": "https://registry.npmjs.org/@testing-library/user-event/-/user-event-14.6.1.tgz", + "integrity": "sha512-vq7fv0rnt+QTXgPxr5Hjc210p6YKq2kmdziLgnsZGgLJ9e6VAShx1pACLuRjd/AS/sr7phAR58OIIpf0LlmQNw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12", + "npm": ">=6" + }, + "peerDependencies": { + "@testing-library/dom": ">=7.21.4" + } + }, "node_modules/@tootallnate/quickjs-emscripten": { "version": "0.23.0", "resolved": "https://registry.npmjs.org/@tootallnate/quickjs-emscripten/-/quickjs-emscripten-0.23.0.tgz", @@ -2059,9 +2213,9 @@ "license": "MIT" }, "node_modules/@types/estree": { - "version": "1.0.8", - "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.8.tgz", - "integrity": "sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==", + "version": "1.0.9", + "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.9.tgz", + "integrity": "sha512-GhdPgy1el4/ImP05X05Uw4cw2/M93BCUmnEvWZNStlCzEKME4Fkk+YpoA5OiHNQmoS7Cafb8Xa3Pya8m1Qrzeg==", "dev": true, "license": "MIT" }, @@ -2333,9 +2487,9 @@ } }, "node_modules/@typescript-eslint/typescript-estree/node_modules/brace-expansion": { - "version": "2.0.2", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.0.2.tgz", - "integrity": "sha512-Jt0vHyM+jmUBqojB7E1NIYadt0vI0Qxjxd2TErW94wDz+E2LAm5vKMXXwg6ZZBTHPuUlDgQHKXvjGBdfcF1ZDQ==", + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.1.2.tgz", + "integrity": "sha512-w5JZcKgdhDOgOwm8H+KgbosopHMuGcl6qbulwjtz3SM7I7P3yW1eAjzMPLrIE+NQ9vjgANKHWeMHnrT0OXW1oA==", "dev": true, "license": "MIT", "dependencies": { @@ -2343,13 +2497,13 @@ } }, "node_modules/@typescript-eslint/typescript-estree/node_modules/minimatch": { - "version": "9.0.5", - "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-9.0.5.tgz", - "integrity": "sha512-G6T0ZX48xgozx7587koeX9Ys2NYy6Gmv//P89sEte9V9whIapMNF4idKxnW2QtCcLiTWlb/wfCabAtAFWhhBow==", + "version": "9.0.9", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-9.0.9.tgz", + "integrity": "sha512-OBwBN9AL4dqmETlpS2zasx+vTeWclWzkblfZk7KTA5j3jeOONz/tRCnZomUyvNg83wL5Zv9Ss6HMJXAgL8R2Yg==", "dev": true, "license": "ISC", "dependencies": { - "brace-expansion": "^2.0.1" + "brace-expansion": "^2.0.2" }, "engines": { "node": ">=16 || 14 >=14.17" @@ -2600,9 +2754,9 @@ } }, "node_modules/ajv": { - "version": "6.12.6", - "resolved": "https://registry.npmjs.org/ajv/-/ajv-6.12.6.tgz", - "integrity": "sha512-j3fVLgvTo527anyYyJOGTYJbG+vnnQYvE0m5mmkc1TK+nxAppkCLMIL0aZ4dblVCNoGShhm+kzE4ZUykBoMg4g==", + "version": "6.15.0", + "resolved": "https://registry.npmjs.org/ajv/-/ajv-6.15.0.tgz", + "integrity": "sha512-fgFx7Hfoq60ytK2c7DhnF8jIvzYgOMxfugjLOSMHjLIPgenqa7S7oaagATUq99mV6IYvN2tRmC0wnTYX6iPbMw==", "dev": true, "license": "MIT", "dependencies": { @@ -2975,19 +3129,22 @@ } }, "node_modules/baseline-browser-mapping": { - "version": "2.8.31", - "resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.8.31.tgz", - "integrity": "sha512-a28v2eWrrRWPpJSzxc+mKwm0ZtVx/G8SepdQZDArnXYU/XS+IF6mp8aB/4E+hH1tyGCoDo3KlUCdlSxGDsRkAw==", + "version": "2.11.4", + "resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.11.4.tgz", + "integrity": "sha512-s4+sLr9mZ/CyqeRritFeYV/Zx73OAtmaHn6kkBS1XRoJn1hrg3xIDUcpicAEX68tkcIN0iBCgti31C8zxtkhsQ==", "dev": true, "license": "Apache-2.0", "bin": { - "baseline-browser-mapping": "dist/cli.js" + "baseline-browser-mapping": "dist/cli.cjs" + }, + "engines": { + "node": ">=6.0.0" } }, "node_modules/basic-ftp": { - "version": "5.2.0", - "resolved": "https://registry.npmjs.org/basic-ftp/-/basic-ftp-5.2.0.tgz", - "integrity": "sha512-VoMINM2rqJwJgfdHq6RiUudKt2BV+FY5ZFezP/ypmwayk68+NzzAQy4XXLlqsGD4MCzq3DrmNFD/uUmBJuGoXw==", + "version": "5.3.1", + "resolved": "https://registry.npmjs.org/basic-ftp/-/basic-ftp-5.3.1.tgz", + "integrity": "sha512-bopVNp6ugyA150DDuZfPFdt1KZ5a94ZDiwX4hMgZDzF+GttD80lEy8kj98kbyhLXnPvhtIo93mdnLIjpCAeeOw==", "dev": true, "license": "MIT", "engines": { @@ -3015,9 +3172,9 @@ } }, "node_modules/brace-expansion": { - "version": "1.1.12", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.12.tgz", - "integrity": "sha512-9T9UjW3r0UW5c1Q7GTwllptXwhvYmEzFhzMfZ9H7FQWt+uZePjZPjBP/W1ZEyZ1twGWom5/56TF4lPcqjnDHcg==", + "version": "1.1.16", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.16.tgz", + "integrity": "sha512-IDw48K2/2kRkg9LdJxurvq3lV3aBgq0REY89duEqFRthjlPdXHKMj7EnQOXVckxzgisinf3nHfrcE2FufFLXMw==", "dev": true, "license": "MIT", "dependencies": { @@ -3040,9 +3197,9 @@ } }, "node_modules/browserslist": { - "version": "4.28.0", - "resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.28.0.tgz", - "integrity": "sha512-tbydkR/CxfMwelN0vwdP/pLkDwyAASZ+VfWm4EOwlB6SWhx1sYnWLqo8N5j0rAzPfzfRaxt0mM/4wPU/Su84RQ==", + "version": "4.28.7", + "resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.28.7.tgz", + "integrity": "sha512-JxV13hNrFxqjOc8alRbq9dK1MM79NEXYpma2B2J4wAtpWS5zIEIKqWPGCl7N4o7Uc7B7itylh7SuDujATRyyTw==", "dev": true, "funding": [ { @@ -3060,11 +3217,11 @@ ], "license": "MIT", "dependencies": { - "baseline-browser-mapping": "^2.8.25", - "caniuse-lite": "^1.0.30001754", - "electron-to-chromium": "^1.5.249", - "node-releases": "^2.0.27", - "update-browserslist-db": "^1.1.4" + "baseline-browser-mapping": "^2.10.44", + "caniuse-lite": "^1.0.30001806", + "electron-to-chromium": "^1.5.393", + "node-releases": "^2.0.51", + "update-browserslist-db": "^1.2.3" }, "bin": { "browserslist": "cli.js" @@ -3161,9 +3318,9 @@ } }, "node_modules/caniuse-lite": { - "version": "1.0.30001757", - "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001757.tgz", - "integrity": "sha512-r0nnL/I28Zi/yjk1el6ilj27tKcdjLsNqAOZr0yVjWPrSQyHgKI2INaEWw21bAQSv2LXRt1XuCS/GomNpWOxsQ==", + "version": "1.0.30001806", + "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001806.tgz", + "integrity": "sha512-72Cuvd95zbSYPKq6Fhg8eDJRlzgWDf7/mtoZv6Qe/DYNCEBdNxoA3+rZAU2ZhGCpZlns3EssFavaZomckT5Uuw==", "dev": true, "funding": [ { @@ -3314,9 +3471,9 @@ "license": "MIT" }, "node_modules/cookie": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/cookie/-/cookie-1.1.0.tgz", - "integrity": "sha512-vXiThu1/rlos7EGu8TuNZQEg2e9TvhH9dmS4T4ZVzB7Ao1agEZ6EG3sn5n+hZRYUgduISd1HpngFzAZiDGm5vQ==", + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/cookie/-/cookie-1.1.1.tgz", + "integrity": "sha512-ei8Aos7ja0weRpFzJnEA9UHJ/7XQmqglbRwnf2ATjcB9Wq874VKH9kfjjirM6UhU2/E5fFYadylyhFldcqSidQ==", "license": "MIT", "engines": { "node": ">=18" @@ -3397,6 +3554,13 @@ "node": "^10 || ^12.20.0 || ^14.13.0 || >=15.0.0" } }, + "node_modules/css.escape": { + "version": "1.5.1", + "resolved": "https://registry.npmjs.org/css.escape/-/css.escape-1.5.1.tgz", + "integrity": "sha512-YUifsXXuknHlUsmlgyY0PKzgPOr7/FjCePfHNt0jxm83wHZi44VDMQ7/fGNkjY3/jV1MC+1CmZbaHzugyeRtpg==", + "dev": true, + "license": "MIT" + }, "node_modules/csstype": { "version": "3.2.3", "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz", @@ -3639,9 +3803,9 @@ } }, "node_modules/electron-to-chromium": { - "version": "1.5.260", - "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.260.tgz", - "integrity": "sha512-ov8rBoOBhVawpzdre+Cmz4FB+y66Eqrk6Gwqd8NGxuhv99GQ8XqMAr351KEkOt7gukXWDg6gJWEMKgL2RLMPtA==", + "version": "1.5.396", + "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.396.tgz", + "integrity": "sha512-yHiw2Y3C3H9U6TMbOfoWK/BPreiOPXRfTWPBwQBoZG6/8TB6eOPnsy5oaRYuatR7Fw2SJ4kKforgufeo7fq0EQ==", "dev": true, "license": "ISC" }, @@ -3874,9 +4038,9 @@ } }, "node_modules/esbuild": { - "version": "0.25.12", - "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.25.12.tgz", - "integrity": "sha512-bbPBYYrtZbkt6Os6FiTLCTFxvq4tt3JKall1vRwshA3fdVztsLAatFaZobhkBC8/BrPetoa0oksYoKXoG4ryJg==", + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.28.1.tgz", + "integrity": "sha512-HrJrvZv5ayxBzPfwphOoNzkzOIIlifzk0KJrGK2c8R4+LKpMtpYLQeUdjnwjWv/LZlkH2laZk+4w78pi99D4Vw==", "dev": true, "hasInstallScript": true, "license": "MIT", @@ -3887,32 +4051,32 @@ "node": ">=18" }, "optionalDependencies": { - "@esbuild/aix-ppc64": "0.25.12", - "@esbuild/android-arm": "0.25.12", - "@esbuild/android-arm64": "0.25.12", - "@esbuild/android-x64": "0.25.12", - "@esbuild/darwin-arm64": "0.25.12", - "@esbuild/darwin-x64": "0.25.12", - "@esbuild/freebsd-arm64": "0.25.12", - "@esbuild/freebsd-x64": "0.25.12", - "@esbuild/linux-arm": "0.25.12", - "@esbuild/linux-arm64": "0.25.12", - "@esbuild/linux-ia32": "0.25.12", - "@esbuild/linux-loong64": "0.25.12", - "@esbuild/linux-mips64el": "0.25.12", - "@esbuild/linux-ppc64": "0.25.12", - "@esbuild/linux-riscv64": "0.25.12", - "@esbuild/linux-s390x": "0.25.12", - "@esbuild/linux-x64": "0.25.12", - "@esbuild/netbsd-arm64": "0.25.12", - "@esbuild/netbsd-x64": "0.25.12", - "@esbuild/openbsd-arm64": "0.25.12", - "@esbuild/openbsd-x64": "0.25.12", - "@esbuild/openharmony-arm64": "0.25.12", - "@esbuild/sunos-x64": "0.25.12", - "@esbuild/win32-arm64": "0.25.12", - "@esbuild/win32-ia32": "0.25.12", - "@esbuild/win32-x64": "0.25.12" + "@esbuild/aix-ppc64": "0.28.1", + "@esbuild/android-arm": "0.28.1", + "@esbuild/android-arm64": "0.28.1", + "@esbuild/android-x64": "0.28.1", + "@esbuild/darwin-arm64": "0.28.1", + "@esbuild/darwin-x64": "0.28.1", + "@esbuild/freebsd-arm64": "0.28.1", + "@esbuild/freebsd-x64": "0.28.1", + "@esbuild/linux-arm": "0.28.1", + "@esbuild/linux-arm64": "0.28.1", + "@esbuild/linux-ia32": "0.28.1", + "@esbuild/linux-loong64": "0.28.1", + "@esbuild/linux-mips64el": "0.28.1", + "@esbuild/linux-ppc64": "0.28.1", + "@esbuild/linux-riscv64": "0.28.1", + "@esbuild/linux-s390x": "0.28.1", + "@esbuild/linux-x64": "0.28.1", + "@esbuild/netbsd-arm64": "0.28.1", + "@esbuild/netbsd-x64": "0.28.1", + "@esbuild/openbsd-arm64": "0.28.1", + "@esbuild/openbsd-x64": "0.28.1", + "@esbuild/openharmony-arm64": "0.28.1", + "@esbuild/sunos-x64": "0.28.1", + "@esbuild/win32-arm64": "0.28.1", + "@esbuild/win32-ia32": "0.28.1", + "@esbuild/win32-x64": "0.28.1" } }, "node_modules/escalade": { @@ -4343,9 +4507,9 @@ } }, "node_modules/flatted": { - "version": "3.3.3", - "resolved": "https://registry.npmjs.org/flatted/-/flatted-3.3.3.tgz", - "integrity": "sha512-GX+ysw4PBCz0PzosHDepZGANEuFCMLrnRTiEy9McGjmkCQYwRq4A/X786G/fjM/+OjsWSU1ZrY5qyARZmO/uwg==", + "version": "3.4.3", + "resolved": "https://registry.npmjs.org/flatted/-/flatted-3.4.3.tgz", + "integrity": "sha512-/zipXxyO6rGvuNGDiULY9MvEGSkb2gaG4GGH4ygMi0ZZzyMHdUZBmntJmx5x1G2VuPytCwGN4xsJP6cw+sK+vQ==", "dev": true, "license": "ISC" }, @@ -4768,9 +4932,9 @@ } }, "node_modules/immutable": { - "version": "5.1.4", - "resolved": "https://registry.npmjs.org/immutable/-/immutable-5.1.4.tgz", - "integrity": "sha512-p6u1bG3YSnINT5RQmx/yRZBpenIl30kVxkTLDyHLIMk0gict704Q9n+thfDI7lTRm9vXdDYutVzXhzcThxTnXA==", + "version": "5.1.9", + "resolved": "https://registry.npmjs.org/immutable/-/immutable-5.1.9.tgz", + "integrity": "sha512-m8nVez3rwrgmWxtLMt1ZYXB2Lv7OKYn/disyxAlSDYAlKSlFoPPfIAmAM/M5xqL4m4C/wAPw7S2/CNaUii1Hxg==", "dev": true, "license": "MIT" }, @@ -4834,9 +4998,9 @@ } }, "node_modules/ip-address": { - "version": "10.1.0", - "resolved": "https://registry.npmjs.org/ip-address/-/ip-address-10.1.0.tgz", - "integrity": "sha512-XXADHxXmvT9+CRxhXg56LJovE+bmWnEWB78LB83VZTprKTmaC5QfruXocxzTZ2Kl0DNwKuBdlIhjL8LeY8Sf8Q==", + "version": "10.3.1", + "resolved": "https://registry.npmjs.org/ip-address/-/ip-address-10.3.1.tgz", + "integrity": "sha512-1e9d3kb97NHJTIJDZW9rKqW2h6+dFa50Dy0fpPSMQp2ADje5gvKsXmdiK6dwY5t76TaTt5+P5N1Y/LoToIxP6g==", "dev": true, "license": "MIT", "engines": { @@ -5282,10 +5446,20 @@ "license": "MIT" }, "node_modules/js-yaml": { - "version": "4.1.1", - "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.1.1.tgz", - "integrity": "sha512-qQKT4zQxXl8lLwBtHMWwaTcGfFOZviOJet3Oy/xmGk2gZH677CJM9EvtfdSkgWcATZhj/55JZ0rmy3myCT5lsA==", + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.3.0.tgz", + "integrity": "sha512-1td788aAnnZ5qs7V2QIRl1owjtYpbKt749Y3xauqQgwIIGF/xXWz1wMTEBx5O3LK3lXLVuqXPdPxj2BoFHaW9Q==", "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/puzrin" + }, + { + "type": "github", + "url": "https://github.com/sponsors/nodeca" + } + ], "license": "MIT", "dependencies": { "argparse": "^2.0.1" @@ -5578,9 +5752,9 @@ } }, "node_modules/minimatch": { - "version": "3.1.2", - "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.2.tgz", - "integrity": "sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw==", + "version": "3.1.5", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.5.tgz", + "integrity": "sha512-VgjWUsnnT6n+NUk6eZq77zeFdpW2LWDzP6zFGrCbHXiYNul5Dzqk2HHQ5uFH2DNW5Xbp8+jVzaeNt94ssEEl4w==", "dev": true, "license": "ISC", "dependencies": { @@ -5613,9 +5787,9 @@ "optional": true }, "node_modules/nanoid": { - "version": "3.3.11", - "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.11.tgz", - "integrity": "sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w==", + "version": "3.3.16", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.16.tgz", + "integrity": "sha512-bzlKTyNJ7+LdGIIwy8ijFpIqEQIvafahV7eYykJ8Cvh42EdJeODoJ6gUJXpQJvej1BddH8OqTXZNE/KfbWAu8Q==", "dev": true, "funding": [ { @@ -5657,11 +5831,14 @@ "optional": true }, "node_modules/node-releases": { - "version": "2.0.27", - "resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.27.tgz", - "integrity": "sha512-nmh3lCkYZ3grZvqcCH+fjmQ7X+H0OeZgP40OierEaAptX4XofMh5kwNbWh7lBduUzCcV/8kZ+NDLCwm2iorIlA==", + "version": "2.0.51", + "resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.51.tgz", + "integrity": "sha512-wRNIrw4DmVLKQlbgOMdkMx27Wrpzes2hh5Jtbi2bjPd+4wJstWIqP5A+lscnqbm0xxmT5Bpg8Lec5ItEBwx6BQ==", "dev": true, - "license": "MIT" + "license": "MIT", + "engines": { + "node": ">=18" + } }, "node_modules/object-inspect": { "version": "1.13.4", @@ -5959,9 +6136,9 @@ "license": "ISC" }, "node_modules/picomatch": { - "version": "2.3.1", - "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.1.tgz", - "integrity": "sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA==", + "version": "2.3.2", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.2.tgz", + "integrity": "sha512-V7+vQEJ06Z+c5tSye8S+nHUfI51xoXIXjHQ99cQtKUkQqqO1kO/KCJUfZXuB47h/YBlDhah2H3hdUGXn8ie0oA==", "dev": true, "license": "MIT", "optional": true, @@ -5983,9 +6160,9 @@ } }, "node_modules/postcss": { - "version": "8.5.6", - "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.6.tgz", - "integrity": "sha512-3Ybi1tAuwAP9s0r1UQ2J4n5Y0G05bJkpUIO0/bI9MhwmD70S5aTWbXGBwxHrelT+XM1k6dM0pk+SwNkpTRN7Pg==", + "version": "8.5.23", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.23.tgz", + "integrity": "sha512-g50586zr4bZmwFiTlflMu8E0bDTb5I5gertgwAKmsdUlTQIhZtunzUlD1WSzwcVWPoAVpsrA6vlfCD7oXvRwgg==", "dev": true, "funding": [ { @@ -6003,7 +6180,7 @@ ], "license": "MIT", "dependencies": { - "nanoid": "^3.3.11", + "nanoid": "^3.3.16", "picocolors": "^1.1.1", "source-map-js": "^1.2.1" }, @@ -6197,9 +6374,9 @@ } }, "node_modules/react-router": { - "version": "7.9.6", - "resolved": "https://registry.npmjs.org/react-router/-/react-router-7.9.6.tgz", - "integrity": "sha512-Y1tUp8clYRXpfPITyuifmSoE2vncSME18uVLgaqyxh9H35JWpIfzHo+9y3Fzh5odk/jxPW29IgLgzcdwxGqyNA==", + "version": "7.18.1", + "resolved": "https://registry.npmjs.org/react-router/-/react-router-7.18.1.tgz", + "integrity": "sha512-GDLgg3i3uM0aeJO3Fm+TCS+sDQ7gu12T6x0qdTEzcwqEfleci7JwugVNIF3U//0FWKnJT7ptG+20B2jfDqnZAg==", "license": "MIT", "dependencies": { "cookie": "^1.0.1", @@ -6219,12 +6396,12 @@ } }, "node_modules/react-router-dom": { - "version": "7.9.6", - "resolved": "https://registry.npmjs.org/react-router-dom/-/react-router-dom-7.9.6.tgz", - "integrity": "sha512-2MkC2XSXq6HjGcihnx1s0DBWQETI4mlis4Ux7YTLvP67xnGxCvq+BcCQSO81qQHVUTM1V53tl4iVVaY5sReCOA==", + "version": "7.18.1", + "resolved": "https://registry.npmjs.org/react-router-dom/-/react-router-dom-7.18.1.tgz", + "integrity": "sha512-KaZh+X/6UtEp28x51AUYZDMg9NGoz2ja3dNHa+ta/tk40vCzKhQ/RypCWBMLbmDr6//E24Vv5uPsrqXFozdkAg==", "license": "MIT", "dependencies": { - "react-router": "7.9.6" + "react-router": "7.18.1" }, "engines": { "node": ">=20.0.0" @@ -6352,13 +6529,13 @@ } }, "node_modules/rollup": { - "version": "4.53.3", - "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.53.3.tgz", - "integrity": "sha512-w8GmOxZfBmKknvdXU1sdM9NHcoQejwF/4mNgj2JuEEdRaHwwF12K7e9eXn1nLZ07ad+du76mkVsyeb2rKGllsA==", + "version": "4.62.3", + "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.62.3.tgz", + "integrity": "sha512-Gu0c0iH9FzgX1L1t7ByIbbS3Vmdz+6KHm/EsqmmC71gUQ82yvZRkTK6XzrFObSka91WUVdynqp6nsfilzr5k6Q==", "dev": true, "license": "MIT", "dependencies": { - "@types/estree": "1.0.8" + "@types/estree": "1.0.9" }, "bin": { "rollup": "dist/bin/rollup" @@ -6368,28 +6545,31 @@ "npm": ">=8.0.0" }, "optionalDependencies": { - "@rollup/rollup-android-arm-eabi": "4.53.3", - "@rollup/rollup-android-arm64": "4.53.3", - "@rollup/rollup-darwin-arm64": "4.53.3", - "@rollup/rollup-darwin-x64": "4.53.3", - "@rollup/rollup-freebsd-arm64": "4.53.3", - "@rollup/rollup-freebsd-x64": "4.53.3", - "@rollup/rollup-linux-arm-gnueabihf": "4.53.3", - "@rollup/rollup-linux-arm-musleabihf": "4.53.3", - "@rollup/rollup-linux-arm64-gnu": "4.53.3", - "@rollup/rollup-linux-arm64-musl": "4.53.3", - "@rollup/rollup-linux-loong64-gnu": "4.53.3", - "@rollup/rollup-linux-ppc64-gnu": "4.53.3", - "@rollup/rollup-linux-riscv64-gnu": "4.53.3", - "@rollup/rollup-linux-riscv64-musl": "4.53.3", - "@rollup/rollup-linux-s390x-gnu": "4.53.3", - "@rollup/rollup-linux-x64-gnu": "4.53.3", - "@rollup/rollup-linux-x64-musl": "4.53.3", - "@rollup/rollup-openharmony-arm64": "4.53.3", - "@rollup/rollup-win32-arm64-msvc": "4.53.3", - "@rollup/rollup-win32-ia32-msvc": "4.53.3", - "@rollup/rollup-win32-x64-gnu": "4.53.3", - "@rollup/rollup-win32-x64-msvc": "4.53.3", + "@rollup/rollup-android-arm-eabi": "4.62.3", + "@rollup/rollup-android-arm64": "4.62.3", + "@rollup/rollup-darwin-arm64": "4.62.3", + "@rollup/rollup-darwin-x64": "4.62.3", + "@rollup/rollup-freebsd-arm64": "4.62.3", + "@rollup/rollup-freebsd-x64": "4.62.3", + "@rollup/rollup-linux-arm-gnueabihf": "4.62.3", + "@rollup/rollup-linux-arm-musleabihf": "4.62.3", + "@rollup/rollup-linux-arm64-gnu": "4.62.3", + "@rollup/rollup-linux-arm64-musl": "4.62.3", + "@rollup/rollup-linux-loong64-gnu": "4.62.3", + "@rollup/rollup-linux-loong64-musl": "4.62.3", + "@rollup/rollup-linux-ppc64-gnu": "4.62.3", + "@rollup/rollup-linux-ppc64-musl": "4.62.3", + "@rollup/rollup-linux-riscv64-gnu": "4.62.3", + "@rollup/rollup-linux-riscv64-musl": "4.62.3", + "@rollup/rollup-linux-s390x-gnu": "4.62.3", + "@rollup/rollup-linux-x64-gnu": "4.62.3", + "@rollup/rollup-linux-x64-musl": "4.62.3", + "@rollup/rollup-openbsd-x64": "4.62.3", + "@rollup/rollup-openharmony-arm64": "4.62.3", + "@rollup/rollup-win32-arm64-msvc": "4.62.3", + "@rollup/rollup-win32-ia32-msvc": "4.62.3", + "@rollup/rollup-win32-x64-gnu": "4.62.3", + "@rollup/rollup-win32-x64-msvc": "4.62.3", "fsevents": "~2.3.2" } }, @@ -7085,9 +7265,9 @@ } }, "node_modules/tinyglobby/node_modules/picomatch": { - "version": "4.0.3", - "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.3.tgz", - "integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==", + "version": "4.0.5", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.5.tgz", + "integrity": "sha512-RvwwcruNjI1ncT5xRakeyS9Lf8lcItv34KD+aif+VH9kduAyfYBipGh12274xtenIPZ119/R9BdTBa8gAwSh0A==", "dev": true, "license": "MIT", "engines": { @@ -7374,9 +7554,9 @@ "license": "MIT" }, "node_modules/update-browserslist-db": { - "version": "1.1.4", - "resolved": "https://registry.npmjs.org/update-browserslist-db/-/update-browserslist-db-1.1.4.tgz", - "integrity": "sha512-q0SPT4xyU84saUX+tomz1WLkxUbuaJnR1xWt17M7fJtEJigJeWUNGUqrauFXsHnqev9y9JTRGwk13tFBuKby4A==", + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/update-browserslist-db/-/update-browserslist-db-1.2.3.tgz", + "integrity": "sha512-Js0m9cx+qOgDxo0eMiFGEueWztz+d4+M3rGlmKPT+T4IS/jP4ylw3Nwpu6cpTTP8R1MAC1kF4VbdLt3ARf209w==", "dev": true, "funding": [ { @@ -7422,13 +7602,13 @@ "license": "MIT" }, "node_modules/vite": { - "version": "7.2.4", - "resolved": "https://registry.npmjs.org/vite/-/vite-7.2.4.tgz", - "integrity": "sha512-NL8jTlbo0Tn4dUEXEsUg8KeyG/Lkmc4Fnzb8JXN/Ykm9G4HNImjtABMJgkQoVjOBN/j2WAwDTRytdqJbZsah7w==", + "version": "7.3.6", + "resolved": "https://registry.npmjs.org/vite/-/vite-7.3.6.tgz", + "integrity": "sha512-4XP60spRGjSZFf1qYH+dJIkK2znL3zQfl9KkOV9MkkRR/3Dls0dxaBsQPTloEc5BLXWPL9vsOxopxyKoMmDueg==", "dev": true, "license": "MIT", "dependencies": { - "esbuild": "^0.25.0", + "esbuild": "^0.27.0 || ^0.28.0", "fdir": "^6.5.0", "picomatch": "^4.0.3", "postcss": "^8.5.6", @@ -7515,9 +7695,9 @@ } }, "node_modules/vite/node_modules/picomatch": { - "version": "4.0.3", - "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.3.tgz", - "integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==", + "version": "4.0.5", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.5.tgz", + "integrity": "sha512-RvwwcruNjI1ncT5xRakeyS9Lf8lcItv34KD+aif+VH9kduAyfYBipGh12274xtenIPZ119/R9BdTBa8gAwSh0A==", "dev": true, "license": "MIT", "engines": { @@ -7874,9 +8054,9 @@ "license": "ISC" }, "node_modules/ws": { - "version": "8.20.0", - "resolved": "https://registry.npmjs.org/ws/-/ws-8.20.0.tgz", - "integrity": "sha512-sAt8BhgNbzCtgGbt2OxmpuryO63ZoDk/sqaB/znQm94T4fCEsy/yV+7CdC1kJhOU9lboAEU7R3kquuycDoibVA==", + "version": "8.21.1", + "resolved": "https://registry.npmjs.org/ws/-/ws-8.21.1.tgz", + "integrity": "sha512-+0NTnW77fFN/DjQi6k/Sq/Yvk4Sgajw7urW8V+asjXnRgDs9gyGkdb7EzgfhA4goXsRIZKE28fzIXBHEzhuiWw==", "dev": true, "license": "MIT", "engines": { diff --git a/package.json b/package.json index 1224d2d..b75a48a 100644 --- a/package.json +++ b/package.json @@ -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", diff --git a/prerender.js b/prerender.js index fdf2240..901c383 100644 --- a/prerender.js +++ b/prerender.js @@ -19,8 +19,10 @@ const PORT = 4173 const ROUTES = [ '/de/', '/de/technical', + '/de/audit', '/en/', '/en/technical', + '/en/audit', '/imprint', '/privacy-policy', ] diff --git a/public/.htaccess b/public/.htaccess index 152dce1..872784f 100644 --- a/public/.htaccess +++ b/public/.htaccess @@ -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 -RewriteRule ^ index.html [QSA,L] \ No newline at end of file +RewriteCond %{REQUEST_FILENAME} !-d +RewriteRule ^ index.html [QSA,L] + +# --------------------------------------------------------------------------- +# Security headers +# --------------------------------------------------------------------------- + + # 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 "; rel=\"alternate\"; type=\"text/markdown\", ; rel=\"alternate\"; type=\"text/markdown\", ; rel=\"alternate\"; type=\"text/markdown\", ; rel=\"agents\"; type=\"application/json\", ; rel=\"agent-card\"; type=\"application/json\", ; rel=\"service-desc\"; type=\"application/json\", ; rel=\"service-desc\"; type=\"application/json\"" + + +# --------------------------------------------------------------------------- +# 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. +# --------------------------------------------------------------------------- + + + Require all denied + + diff --git a/public/.well-known/.htaccess b/public/.well-known/.htaccess new file mode 100644 index 0000000..d72183e --- /dev/null +++ b/public/.well-known/.htaccess @@ -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. + + Header set Access-Control-Allow-Origin "*" + Header set Cache-Control "public, max-age=3600" + diff --git a/public/AGENTS.md b/public/AGENTS.md index 58b0446..4c9e4c4 100644 --- a/public/AGENTS.md +++ b/public/AGENTS.md @@ -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 diff --git a/public/llms-full.txt b/public/llms-full.txt index b546f30..96b4200 100644 --- a/public/llms-full.txt +++ b/public/llms-full.txt @@ -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 diff --git a/public/llms.txt b/public/llms.txt index a87847f..1638d11 100644 --- a/public/llms.txt +++ b/public/llms.txt @@ -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 diff --git a/public/sitemap.xml b/public/sitemap.xml index 8bf6994..42ff641 100644 --- a/public/sitemap.xml +++ b/public/sitemap.xml @@ -41,4 +41,24 @@ monthly 0.7 + + + https://sascha-bach.de/de/audit + + + + 2026-07-29 + monthly + 0.9 + + + + https://sascha-bach.de/en/audit + + + + 2026-07-29 + monthly + 0.8 + diff --git a/src/app/AppRouter.tsx b/src/app/AppRouter.tsx index 602eb80..df4d012 100644 --- a/src/app/AppRouter.tsx +++ b/src/app/AppRouter.tsx @@ -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 */} } /> } /> + } /> {/* English routes */} } /> } /> + } /> {/* Shared pages (noindex, language-independent) */} } /> diff --git a/src/app/__tests__/a11y.smoke.test.tsx b/src/app/__tests__/a11y.smoke.test.tsx index 8f0a0cb..c9a28d7 100644 --- a/src/app/__tests__/a11y.smoke.test.tsx +++ b/src/app/__tests__/a11y.smoke.test.tsx @@ -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(); expect(await axe(container)).toHaveNoViolations(); }); + + it('AuditPage has no axe violations', async () => { + const { container } = renderPage(); + expect(await axe(container)).toHaveNoViolations(); + }); }); diff --git a/src/components/audit/AuditDeliverableSection.tsx b/src/components/audit/AuditDeliverableSection.tsx new file mode 100644 index 0000000..edd139d --- /dev/null +++ b/src/components/audit/AuditDeliverableSection.tsx @@ -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 ( +
+
+

+ {t.title} +

+ +

{t.intro}

+ +
    + {t.items.map((item, index) => { + const Icon = ICONS[index] ?? FileText; + return ( +
  • +
  • + ); + })} +
+
+
+ ); +} diff --git a/src/components/audit/AuditForm.tsx b/src/components/audit/AuditForm.tsx new file mode 100644 index 0000000..41754ba --- /dev/null +++ b/src/components/audit/AuditForm.tsx @@ -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(null); + const successRef = useRef(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 ( +
+

{t.successTitle}

+

{t.successText}

+
+ ); + } + + const isSubmitting = status === 'submitting'; + + return ( +
{ + 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 && ( +
+

{t.errorSummaryTitle}

+
    + {errorEntries.map(({ field, message }) => ( +
  • + + {message} + +
  • + ))} +
+
+ )} + + {status === 'error' && ( +
+

{t.errorTitle}

+

{t.errorText}

+
+ )} + + {/* Name */} +
+ + setField('name', event.target.value)} + /> + {errors.name && ( +

+ {errors.name} +

+ )} +
+ + {/* Company - the offer is B2B only, so this is required */} +
+ +

+ {t.companyHint} +

+ setField('company', event.target.value)} + /> + {errors.company && ( +

+ {errors.company} +

+ )} +
+ + {/* Email */} +
+ +

+ {t.emailHint} +

+ setField('email', event.target.value)} + /> + {errors.email && ( +

+ {errors.email} +

+ )} +
+ + {/* URL under review */} +
+ +

+ {t.urlHint} +

+ setField('url', event.target.value)} + /> + {errors.url && ( +

+ {errors.url} +

+ )} +
+ + {/* Agency or end client */} +
+ {t.roleLegend} + +
+ setField('role', 'agency')} + /> + +
+ +
+ setField('role', 'client')} + /> + +
+ + {errors.role && ( +

+ {errors.role} +

+ )} +
+ + {/* Motivation */} +
+ +

+ {t.motivationHint} +

+