Compare commits
No commits in common. "main" and "v0.1.1" have entirely different histories.
94 changed files with 189 additions and 5537 deletions
BIN
.DS_Store
vendored
Normal file
BIN
.DS_Store
vendored
Normal file
Binary file not shown.
|
|
@ -5,11 +5,3 @@ node_modules
|
||||||
npm-debug.log*
|
npm-debug.log*
|
||||||
.git
|
.git
|
||||||
README.md
|
README.md
|
||||||
data/*.json
|
|
||||||
storage/uploads/images/*
|
|
||||||
storage/config/*
|
|
||||||
!storage/config/.gitkeep
|
|
||||||
!storage/uploads/images/.gitkeep
|
|
||||||
*.tmp
|
|
||||||
*.temp
|
|
||||||
.DS_Store
|
|
||||||
|
|
|
||||||
36
.env.example
36
.env.example
|
|
@ -1,37 +1,5 @@
|
||||||
# Public base URL used for canonical URLs, sitemap, robots.txt and absolute metadata.
|
NEXT_PUBLIC_SITE_URL=http://localhost:3010
|
||||||
# Local example: http://localhost:3010
|
|
||||||
# Production example: https://funktechnik-schubert.de
|
|
||||||
NEXT_PUBLIC_SITE_URL=
|
|
||||||
|
|
||||||
# Admin login email address. Required for /admin.
|
# Optional spaetere Integration. Nicht im Browser verwenden.
|
||||||
ADMIN_EMAIL=
|
|
||||||
|
|
||||||
# Admin login password. Required for /admin.
|
|
||||||
# Use a long unique password and never commit a real value.
|
|
||||||
ADMIN_PASSWORD=
|
|
||||||
|
|
||||||
# Secret for signing the HttpOnly admin session cookie. Required.
|
|
||||||
# Use a long random value, for example generated by: openssl rand -base64 48
|
|
||||||
ADMIN_SESSION_SECRET=
|
|
||||||
|
|
||||||
# Set to true in production behind HTTPS so admin cookies are Secure.
|
|
||||||
# Use false only for local HTTP development.
|
|
||||||
AUTH_COOKIE_SECURE=false
|
|
||||||
|
|
||||||
# Optional future server-side Olympus intake endpoint.
|
|
||||||
# Leave empty until the Olympus integration is explicitly implemented.
|
|
||||||
OLYMPUS_INTAKE_API_URL=
|
OLYMPUS_INTAKE_API_URL=
|
||||||
|
|
||||||
# Optional future server-side Olympus intake token.
|
|
||||||
# Leave empty until the Olympus integration is explicitly implemented.
|
|
||||||
OLYMPUS_INTAKE_API_TOKEN=
|
OLYMPUS_INTAKE_API_TOKEN=
|
||||||
|
|
||||||
# Server-side Olympus base URL for public repair status links.
|
|
||||||
# Docker example when Hermes is reachable in the same network: http://hermes:8000
|
|
||||||
# Production example: https://olympus.example.internal
|
|
||||||
# This URL must be reachable by the Next.js server, not by the browser.
|
|
||||||
OLYMPUS_PUBLIC_STATUS_API_URL=
|
|
||||||
|
|
||||||
# Optional server-side token for future protected status API access.
|
|
||||||
# Leave empty for Olympus v0.8.1 unless the endpoint is protected later.
|
|
||||||
OLYMPUS_PUBLIC_STATUS_API_TOKEN=
|
|
||||||
|
|
|
||||||
18
.gitignore
vendored
18
.gitignore
vendored
|
|
@ -5,21 +5,3 @@ dist
|
||||||
.env
|
.env
|
||||||
.env.local
|
.env.local
|
||||||
npm-debug.log*
|
npm-debug.log*
|
||||||
data/contact-inquiries.json
|
|
||||||
data/repair-inquiries.json
|
|
||||||
data/site-settings.json
|
|
||||||
data/smtp-settings.json
|
|
||||||
storage/uploads/images/*
|
|
||||||
storage/config/*
|
|
||||||
storage/content/*
|
|
||||||
storage/content/backups/*
|
|
||||||
!storage/.gitkeep
|
|
||||||
!storage/config/.gitkeep
|
|
||||||
!storage/content/.gitkeep
|
|
||||||
!storage/content/backups/
|
|
||||||
!storage/content/backups/.gitkeep
|
|
||||||
!storage/uploads/.gitkeep
|
|
||||||
!storage/uploads/images/.gitkeep
|
|
||||||
.DS_Store
|
|
||||||
*.tmp
|
|
||||||
*.temp
|
|
||||||
|
|
|
||||||
16
Dockerfile
16
Dockerfile
|
|
@ -14,22 +14,16 @@ FROM node:22-alpine AS runner
|
||||||
WORKDIR /app
|
WORKDIR /app
|
||||||
ENV NODE_ENV=production
|
ENV NODE_ENV=production
|
||||||
ENV NEXT_TELEMETRY_DISABLED=1
|
ENV NEXT_TELEMETRY_DISABLED=1
|
||||||
ENV NEXT_PUBLIC_APP_VERSION=0.4.1
|
|
||||||
ENV DOCKER_ENV=true
|
|
||||||
ENV PORT=3010
|
|
||||||
|
|
||||||
RUN addgroup --system --gid 1001 nodejs
|
RUN addgroup --system --gid 1001 nodejs
|
||||||
RUN adduser --system --uid 1001 nextjs
|
RUN adduser --system --uid 1001 nextjs
|
||||||
|
|
||||||
COPY --from=builder --chown=nextjs:nodejs /app/public ./public
|
COPY --from=builder /app/public ./public
|
||||||
COPY --from=builder --chown=nextjs:nodejs /app/.next/standalone ./
|
COPY --from=builder /app/.next/standalone ./
|
||||||
COPY --from=builder --chown=nextjs:nodejs /app/.next/static ./.next/static
|
COPY --from=builder /app/.next/static ./.next/static
|
||||||
COPY --from=builder --chown=nextjs:nodejs /app/scripts/runtime-start.js ./runtime-start.js
|
|
||||||
|
|
||||||
RUN mkdir -p /app/data /app/storage/config /app/storage/content/backups /app/storage/uploads/images /app/.next/cache \
|
|
||||||
&& chown -R nextjs:nodejs /app/data /app/storage /app/.next/cache
|
|
||||||
|
|
||||||
USER nextjs
|
USER nextjs
|
||||||
EXPOSE 3010
|
EXPOSE 3010
|
||||||
|
ENV PORT=3010
|
||||||
|
|
||||||
CMD ["node", "runtime-start.js"]
|
CMD ["node", "server.js"]
|
||||||
|
|
|
||||||
304
README.md
304
README.md
|
|
@ -7,14 +7,10 @@ Eigenständige öffentliche Firmenwebsite für Funktechnik Schubert. Dieses Proj
|
||||||
- Next.js 16 App Router
|
- Next.js 16 App Router
|
||||||
- TypeScript strict
|
- TypeScript strict
|
||||||
- Serverseitige API-Routen für Kontakt und Reparaturannahme
|
- Serverseitige API-Routen für Kontakt und Reparaturannahme
|
||||||
- Geschützter Admin-Bereich unter `/admin`
|
|
||||||
- Lokale Storage-Datenablage für Anfragen, Einstellungen, Medien und Website-Inhalte
|
|
||||||
- Docker
|
- Docker
|
||||||
- Nginx/Reverse-Proxy-fähig
|
- Nginx/Reverse-Proxy-fähig
|
||||||
- SEO Metadata, Sitemap und robots.txt
|
- SEO Metadata, Sitemap und robots.txt
|
||||||
|
|
||||||
Version: `0.4.1`
|
|
||||||
|
|
||||||
## Seiten
|
## Seiten
|
||||||
|
|
||||||
- `/` Startseite
|
- `/` Startseite
|
||||||
|
|
@ -23,40 +19,20 @@ Version: `0.4.1`
|
||||||
- `/reparatur`
|
- `/reparatur`
|
||||||
- `/ueber-uns`
|
- `/ueber-uns`
|
||||||
- `/kontakt`
|
- `/kontakt`
|
||||||
- `/status/[token]`
|
|
||||||
- `/impressum`
|
- `/impressum`
|
||||||
- `/datenschutz`
|
- `/datenschutz`
|
||||||
- `/admin/login`
|
|
||||||
- `/admin`
|
|
||||||
- `/admin/kontaktanfragen`
|
|
||||||
- `/admin/reparaturanfragen`
|
|
||||||
- `/admin/website`
|
|
||||||
- `/admin/medien`
|
|
||||||
- `/admin/einstellungen`
|
|
||||||
- `/admin/smtp`
|
|
||||||
- `/admin/seo`
|
|
||||||
- `/admin/system`
|
|
||||||
|
|
||||||
## Lokale Entwicklung
|
## Lokale Entwicklung
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
npm install
|
npm install
|
||||||
cp .env.example .env
|
|
||||||
npm run dev
|
npm run dev
|
||||||
```
|
```
|
||||||
|
|
||||||
Die Website läuft lokal unter:
|
Die Website läuft lokal unter:
|
||||||
|
|
||||||
```text
|
```text
|
||||||
http://127.0.0.1:3010
|
http://localhost:3010
|
||||||
```
|
|
||||||
|
|
||||||
Für den Admin-Bereich müssen in `.env` mindestens gesetzt sein:
|
|
||||||
|
|
||||||
```text
|
|
||||||
ADMIN_EMAIL
|
|
||||||
ADMIN_PASSWORD
|
|
||||||
ADMIN_SESSION_SECRET
|
|
||||||
```
|
```
|
||||||
|
|
||||||
## Checks
|
## Checks
|
||||||
|
|
@ -85,14 +61,6 @@ Port:
|
||||||
3010:3010
|
3010:3010
|
||||||
```
|
```
|
||||||
|
|
||||||
Die Runtime-Daten werden über Docker-Volumes gespeichert:
|
|
||||||
|
|
||||||
- `funktechnik-data` → `/app/data`
|
|
||||||
- `funktechnik-storage` → `/app/storage`
|
|
||||||
- `funktechnik-next-cache` → `/app/.next/cache`
|
|
||||||
|
|
||||||
Dadurch sind keine manuellen `chmod`- oder `chown`-Befehle notwendig.
|
|
||||||
|
|
||||||
## Umgebung
|
## Umgebung
|
||||||
|
|
||||||
`.env.example` kopieren:
|
`.env.example` kopieren:
|
||||||
|
|
@ -103,277 +71,18 @@ cp .env.example .env
|
||||||
|
|
||||||
Variablen:
|
Variablen:
|
||||||
|
|
||||||
- `NEXT_PUBLIC_SITE_URL`: öffentliche Basis-URL für SEO, Sitemap und Metadaten
|
- `NEXT_PUBLIC_SITE_URL`
|
||||||
- `ADMIN_EMAIL`: Admin-Login E-Mail
|
- `OLYMPUS_INTAKE_API_URL`
|
||||||
- `ADMIN_PASSWORD`: Admin-Login Passwort
|
- `OLYMPUS_INTAKE_API_TOKEN`
|
||||||
- `ADMIN_SESSION_SECRET`: langer Zufallswert zum Signieren der Admin-Session
|
|
||||||
- `AUTH_COOKIE_SECURE`: `true` in Produktion mit HTTPS, lokal `false`
|
|
||||||
- `OLYMPUS_INTAKE_API_URL`: vorbereitet für spätere serverseitige Olympus-Anbindung
|
|
||||||
- `OLYMPUS_INTAKE_API_TOKEN`: vorbereitet für spätere serverseitige Olympus-Anbindung
|
|
||||||
- `OLYMPUS_PUBLIC_STATUS_API_URL`: serverseitig erreichbare Olympus-Basis-URL für öffentliche Reparaturstatuslinks
|
|
||||||
- `OLYMPUS_PUBLIC_STATUS_API_TOKEN`: optionaler serverseitiger Token für spätere geschützte Status-API-Zugriffe
|
|
||||||
|
|
||||||
Die Intake-Variablen sind für eine spätere serverseitige Integration vorbereitet. Die Status-Variablen werden für `/status/[token]` serverseitig verwendet. Sie werden nicht im Browser verwendet.
|
Die Olympus-Variablen sind nur für eine spätere serverseitige Integration vorbereitet. Sie werden nicht im Browser verwendet.
|
||||||
|
|
||||||
Für Reparaturstatuslinks muss `OLYMPUS_PUBLIC_STATUS_API_URL` vom Next.js-Server erreichbar sein. In Docker kann das je nach Netzwerk z. B. `http://hermes:8000` sein. Produktiv sollte eine interne Server-zu-Server-Adresse oder eine abgesicherte Olympus-URL verwendet werden.
|
|
||||||
|
|
||||||
Für lokale Entwicklung kann `AUTH_COOKIE_SECURE=false` bleiben. Produktiv muss HTTPS verwendet und `AUTH_COOKIE_SECURE=true` gesetzt werden. `ADMIN_SESSION_SECRET` muss produktiv ein langer zufälliger Wert sein.
|
|
||||||
|
|
||||||
## API-Routen
|
## API-Routen
|
||||||
|
|
||||||
- `POST /api/repair`
|
- `POST /api/repair`
|
||||||
- `POST /api/contact`
|
- `POST /api/contact`
|
||||||
- `POST /api/admin/login`
|
|
||||||
- `POST /api/admin/logout`
|
|
||||||
- `PATCH /api/admin/contact/[id]`
|
|
||||||
- `PATCH /api/admin/repair/[id]`
|
|
||||||
- `POST /api/admin/settings`
|
|
||||||
- `POST /api/admin/smtp`
|
|
||||||
- `GET/POST /api/admin/media`
|
|
||||||
- `GET/PUT /api/admin/content`
|
|
||||||
- `GET /api/content/settings`
|
|
||||||
- `GET /api/media/[filename]`
|
|
||||||
- `GET /api/health`
|
|
||||||
|
|
||||||
## Öffentlicher Reparaturstatus
|
Aktuell validieren die Routen serverseitig, geben klare JSON-Antworten zurück und schreiben nur technische Metadaten in Server-Logs. Es werden keine Nachrichteninhalte oder Tokens geloggt.
|
||||||
|
|
||||||
Olympus CRM erzeugt sichere Statuslinks für Reparaturen. Die Website stellt dafür die öffentliche Route bereit:
|
|
||||||
|
|
||||||
```text
|
|
||||||
/status/<token>
|
|
||||||
```
|
|
||||||
|
|
||||||
Die Seite ruft Olympus ausschließlich serverseitig auf:
|
|
||||||
|
|
||||||
```text
|
|
||||||
${OLYMPUS_PUBLIC_STATUS_API_URL}/public/repairs/status/<token>
|
|
||||||
```
|
|
||||||
|
|
||||||
Der Browser sieht weder die Olympus-URL noch optionale Server-Tokens. Die öffentliche Statusseite zeigt nur:
|
|
||||||
|
|
||||||
- Reparaturnummer
|
|
||||||
- Gerät
|
|
||||||
- aktuellen Status
|
|
||||||
- letzte Aktualisierung
|
|
||||||
- kundenfreundliche Status-Timeline
|
|
||||||
- optionalen Kostenvoranschlag aus Olympus CRM v0.8.6
|
|
||||||
|
|
||||||
Wenn Olympus einen Kostenvoranschlag im Status `sent` liefert, zeigt die Website:
|
|
||||||
|
|
||||||
- Nummer, Titel, Nachricht an den Kunden und Gültigkeit
|
|
||||||
- Positionen mit Menge, Einheit und Betrag
|
|
||||||
- Zwischensumme, Steuer und Gesamtbetrag
|
|
||||||
- Aktionen zum Freigeben, Ablehnen oder für eine Rückfrage
|
|
||||||
|
|
||||||
Diese Aktionen laufen nicht direkt vom Browser zu Olympus. Der Browser ruft ausschließlich relative Website-Endpunkte auf:
|
|
||||||
|
|
||||||
```text
|
|
||||||
POST /api/status/<token>/estimate/approve
|
|
||||||
POST /api/status/<token>/estimate/decline
|
|
||||||
POST /api/status/<token>/estimate/question
|
|
||||||
```
|
|
||||||
|
|
||||||
Die Website leitet diese Anfragen serverseitig an Olympus weiter:
|
|
||||||
|
|
||||||
```text
|
|
||||||
${OLYMPUS_PUBLIC_STATUS_API_URL}/public/repairs/status/<token>/estimate/<action>
|
|
||||||
```
|
|
||||||
|
|
||||||
Nicht angezeigt werden Kundendaten, interne Notizen, Diagnosedetails oder technische Fehlermeldungen.
|
|
||||||
|
|
||||||
Fehlerverhalten:
|
|
||||||
|
|
||||||
- ungültiger oder abgelaufener Token: freundliche Meldung mit Links zu Reparatur und Kontakt
|
|
||||||
- Olympus nicht erreichbar: neutrale Meldung ohne technische Details
|
|
||||||
|
|
||||||
Aktuell validieren die Routen serverseitig, geben klare JSON-Antworten zurück und schreiben nur technische Metadaten in Server-Logs. Es werden keine Nachrichteninhalte oder Tokens geloggt. Kontakt- und Reparaturanfragen werden lokal unter `data/*.json` gespeichert und nicht versioniert.
|
|
||||||
|
|
||||||
## Admin-Bereich
|
|
||||||
|
|
||||||
Der geschützte Admin-Bereich ist unter `/admin/login` erreichbar. Er verwendet ein signiertes HttpOnly-Cookie und ist für Desktop und Tablet ausgelegt.
|
|
||||||
|
|
||||||
Funktionen:
|
|
||||||
|
|
||||||
- Dashboard mit Kennzahlen
|
|
||||||
- Kontaktanfragen verwalten
|
|
||||||
- Reparaturanfragen verwalten
|
|
||||||
- Website-Inhalte pflegen und sofort veröffentlichen
|
|
||||||
- Firmendaten pflegen
|
|
||||||
- SMTP-Konfiguration speichern und Testmail senden
|
|
||||||
- Medien hochladen
|
|
||||||
- SEO-Übersicht
|
|
||||||
- Systemübersicht
|
|
||||||
|
|
||||||
Website-Inhalte werden unter `/admin/website` gepflegt und beim Speichern sofort auf der öffentlichen Website wirksam. Die Content-Architektur ist bewusst über einen zentralen `ContentService` gekapselt, damit später Olympus CMS oder PostgreSQL als Backend angebunden werden können. SMTP-Versand fuer Kontakt- und Reparaturanfragen ist serverseitig angebunden, sofern `/admin/smtp` vollstaendig konfiguriert ist.
|
|
||||||
|
|
||||||
## Runtime Data
|
|
||||||
|
|
||||||
Im Repository liegen nur:
|
|
||||||
|
|
||||||
```text
|
|
||||||
data/.gitkeep
|
|
||||||
data/contact-inquiries.example.json
|
|
||||||
data/repair-inquiries.example.json
|
|
||||||
```
|
|
||||||
|
|
||||||
Echte Runtime-Dateien werden nicht committed:
|
|
||||||
|
|
||||||
```text
|
|
||||||
data/contact-inquiries.json
|
|
||||||
data/repair-inquiries.json
|
|
||||||
data/site-settings.json
|
|
||||||
```
|
|
||||||
|
|
||||||
SMTP-Konfiguration wird unter `storage/config/smtp.json` gespeichert und nicht committed. Das SMTP-Passwort wird nicht im Admin-Formular ausgegeben.
|
|
||||||
|
|
||||||
Uploads liegen unter `storage/uploads/images/`, werden über `/api/media/[filename]` ausgeliefert und nicht committed.
|
|
||||||
|
|
||||||
Alte Uploads aus `public/uploads/images` werden beim Start einmalig nach `storage/uploads/images` migriert, falls sie dort noch nicht vorhanden sind.
|
|
||||||
|
|
||||||
Website-Inhalte liegen unter `storage/content/` und werden nicht committed:
|
|
||||||
|
|
||||||
```text
|
|
||||||
storage/content/home.json
|
|
||||||
storage/content/services.json
|
|
||||||
storage/content/radio-service.json
|
|
||||||
storage/content/repair.json
|
|
||||||
storage/content/about.json
|
|
||||||
storage/content/contact.json
|
|
||||||
storage/content/settings.json
|
|
||||||
storage/content/backups/
|
|
||||||
```
|
|
||||||
|
|
||||||
Jede Seite besitzt eine eigene JSON-Datei. React-Komponenten lesen diese Dateien nicht direkt, sondern ausschließlich über `lib/content/service.ts`. Vor jedem Speichern erzeugt der Service eine Backup-Datei unter `storage/content/backups/` und behält pro Dokument die letzten 20 Versionen. Änderungen benötigen keinen Neustart und keinen Docker-Build.
|
|
||||||
|
|
||||||
Bearbeitbar sind aktuell:
|
|
||||||
|
|
||||||
- Startseite mit Hero, Call-to-Actions, Featureboxen, Leistungsboxen und Kundenversprechen
|
|
||||||
- Leistungen mit beliebig vielen sortierbaren und ein-/ausblendbaren Einträgen
|
|
||||||
- Funkgeräte-Service mit Einleitung, Marken, Fehlerbildern, Ablauf, Messmöglichkeiten, Abgleich und Reparaturtexten
|
|
||||||
- Reparaturannahme mit SEO-Daten und Seitentexten
|
|
||||||
- Über Uns mit Firmenbeschreibung, Werkstattbeschreibung und Philosophie
|
|
||||||
- Kontakt mit Kontaktinformationen, Öffnungszeiten, Maps-Link und Seitentexten
|
|
||||||
- Footer, Logo, Copyright, Header-CTA und globale SEO-Einstellungen
|
|
||||||
- SEO pro Seite inklusive Meta Title, Meta Description, Keywords, OpenGraph und Social Image
|
|
||||||
|
|
||||||
Bilder für Logo und Social Image werden aus dem Medienbestand ausgewählt. Neue Uploads bleiben im privaten Storage und werden über `/api/media/[filename]` ausgeliefert.
|
|
||||||
|
|
||||||
## Roadmap Kundenportal
|
|
||||||
|
|
||||||
Ein vollständiges Kundenportal ist vorbereitet, aber noch nicht öffentlich implementiert. Die öffentliche Statuslink-Seite ist bereits vorhanden. Für spätere Ausbaustufen sind folgende Routen vorgesehen:
|
|
||||||
|
|
||||||
- `/status/[token]`: öffentliche Statuslink-Seite für Olympus
|
|
||||||
- `/reparatur/status`: Statusabfrage für Reparaturanfragen
|
|
||||||
- `/portal/login`: geschützter Kundenlogin
|
|
||||||
|
|
||||||
Diese Routen sind bewusst noch nicht angelegt. Die spätere Umsetzung soll serverseitig erfolgen, ohne Tokens im Browser-JavaScript zu speichern und ohne bestehende Admin- oder CMS-Funktionen zu umgehen.
|
|
||||||
|
|
||||||
## Healthcheck
|
|
||||||
|
|
||||||
```bash
|
|
||||||
scripts/healthcheck.sh
|
|
||||||
```
|
|
||||||
|
|
||||||
Oder direkt:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
curl http://127.0.0.1:3010/api/health
|
|
||||||
```
|
|
||||||
|
|
||||||
Antwort:
|
|
||||||
|
|
||||||
```json
|
|
||||||
{
|
|
||||||
"status": "ok",
|
|
||||||
"version": "0.4.1",
|
|
||||||
"storage": "ok",
|
|
||||||
"admin": "configured",
|
|
||||||
"smtp": "configured",
|
|
||||||
"timestamp": "..."
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
## SMTP
|
|
||||||
|
|
||||||
SMTP wird im Adminbereich unter `/admin/smtp` konfiguriert. Die Konfiguration wird persistent unter `storage/config/smtp.json` gespeichert und liegt damit im Docker-Storage-Volume, nicht in `public/`.
|
|
||||||
|
|
||||||
Pflichtfelder:
|
|
||||||
|
|
||||||
- SMTP Host
|
|
||||||
- SMTP Port
|
|
||||||
- SMTP Benutzername
|
|
||||||
- SMTP Passwort
|
|
||||||
- Verschlüsselung
|
|
||||||
- Absenderadresse
|
|
||||||
- Empfängeradresse
|
|
||||||
|
|
||||||
Für Apple Mail/iCloud Mail:
|
|
||||||
|
|
||||||
```text
|
|
||||||
Host: smtp.mail.me.com
|
|
||||||
Port: 587
|
|
||||||
Verschlüsselung: STARTTLS
|
|
||||||
Benutzername: vollständige E-Mail-Adresse
|
|
||||||
Passwort: app-spezifisches Passwort
|
|
||||||
```
|
|
||||||
|
|
||||||
Nicht das normale Apple-ID-Passwort verwenden. In der Apple-ID-Verwaltung ein app-spezifisches Passwort erzeugen und dieses als SMTP-Passwort speichern.
|
|
||||||
|
|
||||||
Nach dem Speichern kann im Adminbereich eine Testmail gesendet werden. Kontakt- und Reparaturanfragen werden weiterhin lokal gespeichert. Wenn SMTP konfiguriert ist, wird zusaetzlich eine E-Mail an die konfigurierte Empfaengeradresse gesendet. Schlaegt der Mailversand fehl, bleibt die Anfrage gespeichert; der Besucher sieht keine technische Fehlermeldung.
|
|
||||||
|
|
||||||
Troubleshooting:
|
|
||||||
|
|
||||||
- Host, Port und Verschlüsselung prüfen
|
|
||||||
- bei Apple Mail STARTTLS und Port 587 verwenden
|
|
||||||
- vollständige E-Mail-Adresse als Benutzername verwenden
|
|
||||||
- app-spezifisches Passwort neu erzeugen
|
|
||||||
- letzte Testmail und letzte Fehlermeldung unter `/admin/smtp` prüfen
|
|
||||||
|
|
||||||
## Deployment
|
|
||||||
|
|
||||||
```bash
|
|
||||||
scripts/deploy.sh
|
|
||||||
```
|
|
||||||
|
|
||||||
Das Skript führt aus:
|
|
||||||
|
|
||||||
- `docker compose config`
|
|
||||||
- `docker compose build`
|
|
||||||
- `docker compose up -d`
|
|
||||||
- Healthcheck über `/api/health`
|
|
||||||
|
|
||||||
Produktiv muss die `.env` auf dem Zielsystem gepflegt werden. Secrets werden nicht ins Repository aufgenommen.
|
|
||||||
|
|
||||||
## Update
|
|
||||||
|
|
||||||
```bash
|
|
||||||
git pull
|
|
||||||
docker compose config
|
|
||||||
docker compose build
|
|
||||||
docker compose up -d
|
|
||||||
scripts/healthcheck.sh
|
|
||||||
```
|
|
||||||
|
|
||||||
## Backup
|
|
||||||
|
|
||||||
```bash
|
|
||||||
scripts/backup.sh
|
|
||||||
```
|
|
||||||
|
|
||||||
Das Backup enthält `data/` und `storage/`. Die `.env` wird bewusst nicht automatisch gesichert. Sie muss separat sicher abgelegt werden.
|
|
||||||
|
|
||||||
## Restore
|
|
||||||
|
|
||||||
```bash
|
|
||||||
scripts/restore.sh backups/funktechnik-data-YYYYMMDD-HHMMSS.tar.gz
|
|
||||||
```
|
|
||||||
|
|
||||||
Danach Container neu starten:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
docker compose up -d
|
|
||||||
scripts/healthcheck.sh
|
|
||||||
```
|
|
||||||
|
|
||||||
## Nginx
|
## Nginx
|
||||||
|
|
||||||
|
|
@ -394,5 +103,6 @@ certbot --nginx -d funktechnik-schubert.de -d www.funktechnik-schubert.de
|
||||||
## Offene manuelle Punkte
|
## Offene manuelle Punkte
|
||||||
|
|
||||||
- TODO: Rechtliche Angaben ergänzen
|
- TODO: Rechtliche Angaben ergänzen
|
||||||
|
- Produktives Kontakt-/Mail-System anbinden
|
||||||
- Spätere Olympus-Reparaturannahme serverseitig anbinden
|
- Spätere Olympus-Reparaturannahme serverseitig anbinden
|
||||||
- Finale Domain und HTTPS-Konfiguration setzen
|
- Finale Domain und HTTPS-Konfiguration setzen
|
||||||
|
|
|
||||||
|
|
@ -1,28 +0,0 @@
|
||||||
import { redirect } from "next/navigation";
|
|
||||||
import AdminShell from "@/components/admin/AdminShell";
|
|
||||||
import { requireAdminSession } from "@/lib/admin/auth";
|
|
||||||
import { getSiteSettings } from "@/lib/admin/store";
|
|
||||||
|
|
||||||
export default async function AdminSettingsPage() {
|
|
||||||
if (!await requireAdminSession()) redirect("/admin/login");
|
|
||||||
const settings = await getSiteSettings();
|
|
||||||
|
|
||||||
return (
|
|
||||||
<AdminShell>
|
|
||||||
<div className="admin-page-head">
|
|
||||||
<p className="eyebrow">Konfiguration</p>
|
|
||||||
<h1>Firmeneinstellungen</h1>
|
|
||||||
</div>
|
|
||||||
<form className="admin-card admin-form" action="/api/admin/settings" method="post">
|
|
||||||
<label>Firmenname<input name="companyName" defaultValue={settings.companyName} /></label>
|
|
||||||
<label>Telefon<input name="phone" defaultValue={settings.phone} /></label>
|
|
||||||
<label>E-Mail<input name="email" type="email" defaultValue={settings.email} /></label>
|
|
||||||
<label>Adresse<input name="address" defaultValue={settings.address} /></label>
|
|
||||||
<label>Öffnungszeiten<input name="openingHours" defaultValue={settings.openingHours} /></label>
|
|
||||||
<label>Google Maps Embed URL<input name="googleMapsEmbedUrl" defaultValue={settings.googleMapsEmbedUrl} /></label>
|
|
||||||
<label>Social Links<textarea name="socialLinks" defaultValue={settings.socialLinks} /></label>
|
|
||||||
<button className="button" type="submit">Speichern</button>
|
|
||||||
</form>
|
|
||||||
</AdminShell>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
@ -1,48 +0,0 @@
|
||||||
import { redirect } from "next/navigation";
|
|
||||||
import AdminShell from "@/components/admin/AdminShell";
|
|
||||||
import StatusSelect from "@/components/admin/StatusSelect";
|
|
||||||
import { requireAdminSession } from "@/lib/admin/auth";
|
|
||||||
import { getContactInquiries } from "@/lib/admin/store";
|
|
||||||
|
|
||||||
export default async function AdminContactsPage() {
|
|
||||||
if (!await requireAdminSession()) redirect("/admin/login");
|
|
||||||
const inquiries = await getContactInquiries();
|
|
||||||
|
|
||||||
return (
|
|
||||||
<AdminShell>
|
|
||||||
<div className="admin-page-head">
|
|
||||||
<p className="eyebrow">Anfragen</p>
|
|
||||||
<h1>Kontaktanfragen</h1>
|
|
||||||
</div>
|
|
||||||
<section className="admin-card">
|
|
||||||
<table className="admin-table">
|
|
||||||
<thead><tr><th>Name</th><th>E-Mail</th><th>Betreff</th><th>Datum</th><th>Status</th></tr></thead>
|
|
||||||
<tbody>
|
|
||||||
{inquiries.map((item) => (
|
|
||||||
<tr key={item.id}>
|
|
||||||
<td>{item.name}</td>
|
|
||||||
<td><a href={`mailto:${item.email}`}>{item.email}</a></td>
|
|
||||||
<td>{item.subject}</td>
|
|
||||||
<td>{new Date(item.createdAt).toLocaleString("de-DE")}</td>
|
|
||||||
<td><StatusSelect id={item.id} type="contact" status={item.status} /></td>
|
|
||||||
</tr>
|
|
||||||
))}
|
|
||||||
{inquiries.length === 0 && <tr><td colSpan={5}>Noch keine Kontaktanfragen vorhanden.</td></tr>}
|
|
||||||
</tbody>
|
|
||||||
</table>
|
|
||||||
</section>
|
|
||||||
<section className="admin-card">
|
|
||||||
<h2>Detailansicht</h2>
|
|
||||||
<div className="admin-detail-grid">
|
|
||||||
{inquiries.slice(0, 6).map((item) => (
|
|
||||||
<article key={item.id} className="admin-detail">
|
|
||||||
<h3>{item.name}</h3>
|
|
||||||
<p><strong>{item.subject}</strong></p>
|
|
||||||
<p>{item.message}</p>
|
|
||||||
</article>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</section>
|
|
||||||
</AdminShell>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
@ -1,5 +0,0 @@
|
||||||
import type { ReactNode } from "react";
|
|
||||||
|
|
||||||
export default function AdminRootLayout({ children }: { children: ReactNode }) {
|
|
||||||
return children;
|
|
||||||
}
|
|
||||||
|
|
@ -1,14 +0,0 @@
|
||||||
import type { Metadata } from "next";
|
|
||||||
import AdminLoginForm from "@/components/admin/AdminLoginForm";
|
|
||||||
import { adminConfigurationStatus } from "@/lib/runtime/config";
|
|
||||||
|
|
||||||
export const metadata: Metadata = {
|
|
||||||
title: "Admin Login | Funktechnik Schubert",
|
|
||||||
robots: { index: false, follow: false },
|
|
||||||
};
|
|
||||||
|
|
||||||
export default async function AdminLoginPage({ searchParams }: { searchParams: Promise<{ next?: string }> }) {
|
|
||||||
const params = await searchParams;
|
|
||||||
const nextPath = params.next?.startsWith("/admin") ? params.next : "/admin";
|
|
||||||
return <AdminLoginForm nextPath={nextPath} adminConfigured={adminConfigurationStatus() === "configured"} />;
|
|
||||||
}
|
|
||||||
|
|
@ -1,20 +0,0 @@
|
||||||
import { redirect } from "next/navigation";
|
|
||||||
import AdminShell from "@/components/admin/AdminShell";
|
|
||||||
import MediaManager from "@/components/admin/MediaManager";
|
|
||||||
import { requireAdminSession } from "@/lib/admin/auth";
|
|
||||||
import { listMediaFiles } from "@/lib/admin/media";
|
|
||||||
|
|
||||||
export default async function AdminMediaPage() {
|
|
||||||
if (!await requireAdminSession()) redirect("/admin/login");
|
|
||||||
const files = await listMediaFiles();
|
|
||||||
|
|
||||||
return (
|
|
||||||
<AdminShell>
|
|
||||||
<div className="admin-page-head">
|
|
||||||
<p className="eyebrow">Assets</p>
|
|
||||||
<h1>Medienverwaltung</h1>
|
|
||||||
</div>
|
|
||||||
<MediaManager initialFiles={files} />
|
|
||||||
</AdminShell>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
@ -1,68 +0,0 @@
|
||||||
import { redirect } from "next/navigation";
|
|
||||||
import AdminShell from "@/components/admin/AdminShell";
|
|
||||||
import { requireAdminSession } from "@/lib/admin/auth";
|
|
||||||
import { listMediaFiles } from "@/lib/admin/media";
|
|
||||||
import { getContactInquiries, getRepairInquiries } from "@/lib/admin/store";
|
|
||||||
import { getContentSummary } from "@/lib/content/service";
|
|
||||||
import { getSmtpSettings, isSmtpConfigured } from "@/lib/mail/config";
|
|
||||||
import { appVersion, checkStorage } from "@/lib/runtime/config";
|
|
||||||
|
|
||||||
function countNew<T extends { status: string }>(items: T[]) {
|
|
||||||
return items.filter((item) => item.status === "new").length;
|
|
||||||
}
|
|
||||||
|
|
||||||
export default async function AdminDashboardPage() {
|
|
||||||
if (!await requireAdminSession()) redirect("/admin/login");
|
|
||||||
let healthStatus = "OK";
|
|
||||||
|
|
||||||
try {
|
|
||||||
await checkStorage();
|
|
||||||
} catch {
|
|
||||||
healthStatus = "Fehler";
|
|
||||||
}
|
|
||||||
|
|
||||||
const [contacts, repairs, smtpSettings, mediaFiles, contentSummary] = await Promise.all([
|
|
||||||
getContactInquiries(),
|
|
||||||
getRepairInquiries(),
|
|
||||||
getSmtpSettings(),
|
|
||||||
listMediaFiles(),
|
|
||||||
getContentSummary(),
|
|
||||||
]);
|
|
||||||
const latest = [...contacts, ...repairs].sort((a, b) => b.createdAt.localeCompare(a.createdAt)).slice(0, 6);
|
|
||||||
const contentModified = contentSummary.lastModified ? new Date(contentSummary.lastModified).toLocaleString("de-DE") : "keine Änderung";
|
|
||||||
|
|
||||||
return (
|
|
||||||
<AdminShell>
|
|
||||||
<div className="admin-page-head">
|
|
||||||
<p className="eyebrow">Administration</p>
|
|
||||||
<h1>Dashboard</h1>
|
|
||||||
</div>
|
|
||||||
<div className="admin-kpis">
|
|
||||||
<div className="admin-kpi"><span>Kontaktanfragen</span><strong>{contacts.length}</strong><small>{countNew(contacts)} neu</small></div>
|
|
||||||
<div className="admin-kpi"><span>Reparaturanfragen</span><strong>{repairs.length}</strong><small>{countNew(repairs)} neu</small></div>
|
|
||||||
<div className="admin-kpi"><span>Medien</span><strong>{mediaFiles.length}</strong><small>im Storage</small></div>
|
|
||||||
<div className="admin-kpi"><span>Content</span><strong>{contentSummary.pageCount}</strong><small>{contentModified}</small></div>
|
|
||||||
<div className="admin-kpi"><span>SMTP</span><strong>{isSmtpConfigured(smtpSettings) ? "OK" : "Fehlt"}</strong><small>{smtpSettings.lastTestStatus ?? "kein Test"}</small></div>
|
|
||||||
<div className="admin-kpi"><span>Version</span><strong>v{appVersion}</strong><small>Website</small></div>
|
|
||||||
<div className="admin-kpi"><span>Health</span><strong>{healthStatus}</strong><small>Storage und Runtime</small></div>
|
|
||||||
</div>
|
|
||||||
<section className="admin-card">
|
|
||||||
<h2>Letzte Anfragen</h2>
|
|
||||||
<table className="admin-table">
|
|
||||||
<thead><tr><th>Datum</th><th>Name</th><th>Typ</th><th>Status</th></tr></thead>
|
|
||||||
<tbody>
|
|
||||||
{latest.map((item) => (
|
|
||||||
<tr key={item.id}>
|
|
||||||
<td>{new Date(item.createdAt).toLocaleDateString("de-DE")}</td>
|
|
||||||
<td>{item.name}</td>
|
|
||||||
<td>{"manufacturer" in item ? "Reparatur" : "Kontakt"}</td>
|
|
||||||
<td><span className={`status ${item.status}`}>{item.status}</span></td>
|
|
||||||
</tr>
|
|
||||||
))}
|
|
||||||
{latest.length === 0 && <tr><td colSpan={4}>Noch keine Anfragen vorhanden.</td></tr>}
|
|
||||||
</tbody>
|
|
||||||
</table>
|
|
||||||
</section>
|
|
||||||
</AdminShell>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
@ -1,51 +0,0 @@
|
||||||
import { redirect } from "next/navigation";
|
|
||||||
import AdminShell from "@/components/admin/AdminShell";
|
|
||||||
import StatusSelect from "@/components/admin/StatusSelect";
|
|
||||||
import { requireAdminSession } from "@/lib/admin/auth";
|
|
||||||
import { getRepairInquiries } from "@/lib/admin/store";
|
|
||||||
|
|
||||||
export default async function AdminRepairsPage() {
|
|
||||||
if (!await requireAdminSession()) redirect("/admin/login");
|
|
||||||
const inquiries = await getRepairInquiries();
|
|
||||||
|
|
||||||
return (
|
|
||||||
<AdminShell>
|
|
||||||
<div className="admin-page-head">
|
|
||||||
<p className="eyebrow">Anfragen</p>
|
|
||||||
<h1>Reparaturanfragen</h1>
|
|
||||||
</div>
|
|
||||||
<section className="admin-card">
|
|
||||||
<table className="admin-table">
|
|
||||||
<thead><tr><th>Datum</th><th>Name</th><th>Gerät</th><th>Hersteller</th><th>Modell</th><th>Status</th></tr></thead>
|
|
||||||
<tbody>
|
|
||||||
{inquiries.map((item) => (
|
|
||||||
<tr key={item.id}>
|
|
||||||
<td>{new Date(item.createdAt).toLocaleDateString("de-DE")}</td>
|
|
||||||
<td>{item.name}</td>
|
|
||||||
<td>{item.deviceType}</td>
|
|
||||||
<td>{item.manufacturer}</td>
|
|
||||||
<td>{item.model}</td>
|
|
||||||
<td><StatusSelect id={item.id} type="repair" status={item.status} /></td>
|
|
||||||
</tr>
|
|
||||||
))}
|
|
||||||
{inquiries.length === 0 && <tr><td colSpan={6}>Noch keine Reparaturanfragen vorhanden.</td></tr>}
|
|
||||||
</tbody>
|
|
||||||
</table>
|
|
||||||
</section>
|
|
||||||
<section className="admin-card">
|
|
||||||
<h2>Technische Details</h2>
|
|
||||||
<div className="admin-detail-grid">
|
|
||||||
{inquiries.slice(0, 6).map((item) => (
|
|
||||||
<article key={item.id} className="admin-detail">
|
|
||||||
<h3>{item.manufacturer} {item.model}</h3>
|
|
||||||
<p><strong>Fehler:</strong> {item.description}</p>
|
|
||||||
<p><strong>Zubehör:</strong> {item.accessories || "nicht angegeben"}</p>
|
|
||||||
<p><strong>Vorarbeiten:</strong> {item.previousWork || "nicht angegeben"}</p>
|
|
||||||
<button className="button light" type="button" disabled>In Olympus übernehmen</button>
|
|
||||||
</article>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</section>
|
|
||||||
</AdminShell>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
@ -1,24 +0,0 @@
|
||||||
import { redirect } from "next/navigation";
|
|
||||||
import AdminShell from "@/components/admin/AdminShell";
|
|
||||||
import { requireAdminSession } from "@/lib/admin/auth";
|
|
||||||
import { defaultTitle, siteName, siteUrl } from "@/app/seo";
|
|
||||||
|
|
||||||
export default async function AdminSeoPage() {
|
|
||||||
if (!await requireAdminSession()) redirect("/admin/login");
|
|
||||||
|
|
||||||
return (
|
|
||||||
<AdminShell>
|
|
||||||
<div className="admin-page-head">
|
|
||||||
<p className="eyebrow">Sichtbarkeit</p>
|
|
||||||
<h1>SEO Verwaltung</h1>
|
|
||||||
</div>
|
|
||||||
<section className="admin-card admin-form">
|
|
||||||
<label>Seitentitel<input readOnly value={defaultTitle} /></label>
|
|
||||||
<label>Site Name<input readOnly value={siteName} /></label>
|
|
||||||
<label>Basis-URL<input readOnly value={siteUrl} /></label>
|
|
||||||
<label>Meta Description<textarea readOnly value="Service, Reparatur, Diagnose und Abgleich von Funktechnik, CB-Funk, Amateurfunk und Kommunikationstechnik." /></label>
|
|
||||||
<p className="admin-muted">Live-Bearbeitung pro Seite ist vorbereitet und wird in einer späteren Publishing-Ausbaustufe aktiviert.</p>
|
|
||||||
</section>
|
|
||||||
</AdminShell>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
@ -1,20 +0,0 @@
|
||||||
import { redirect } from "next/navigation";
|
|
||||||
import AdminShell from "@/components/admin/AdminShell";
|
|
||||||
import SmtpSettingsForm from "@/components/admin/SmtpSettingsForm";
|
|
||||||
import { requireAdminSession } from "@/lib/admin/auth";
|
|
||||||
import { getSmtpSettings, isSmtpConfigured, toPublicSmtpSettings } from "@/lib/mail/config";
|
|
||||||
|
|
||||||
export default async function AdminSmtpPage() {
|
|
||||||
if (!await requireAdminSession()) redirect("/admin/login");
|
|
||||||
const settings = await getSmtpSettings();
|
|
||||||
|
|
||||||
return (
|
|
||||||
<AdminShell>
|
|
||||||
<div className="admin-page-head">
|
|
||||||
<p className="eyebrow">E-Mail</p>
|
|
||||||
<h1>SMTP Einstellungen</h1>
|
|
||||||
</div>
|
|
||||||
<SmtpSettingsForm initialSettings={toPublicSmtpSettings(settings)} configured={isSmtpConfigured(settings)} />
|
|
||||||
</AdminShell>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
@ -1,73 +0,0 @@
|
||||||
import { redirect } from "next/navigation";
|
|
||||||
import AdminShell from "@/components/admin/AdminShell";
|
|
||||||
import { requireAdminSession } from "@/lib/admin/auth";
|
|
||||||
import { adminConfigurationStatus, appVersion, checkStorage, configDirectory, dataDirectory, isDockerEnvironment, olympusStatus, uploadDirectory } from "@/lib/runtime/config";
|
|
||||||
import { getSmtpSettings, isSmtpConfigured } from "@/lib/mail/config";
|
|
||||||
import { getContentSystemStatus } from "@/lib/content/service";
|
|
||||||
|
|
||||||
export default async function AdminSystemPage() {
|
|
||||||
if (!await requireAdminSession()) redirect("/admin/login");
|
|
||||||
let storage = "ok";
|
|
||||||
|
|
||||||
try {
|
|
||||||
await checkStorage();
|
|
||||||
} catch {
|
|
||||||
storage = "error";
|
|
||||||
}
|
|
||||||
const [smtpSettings, contentStatus] = await Promise.all([getSmtpSettings(), getContentSystemStatus()]);
|
|
||||||
const smtpConfigured = isSmtpConfigured(smtpSettings);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<AdminShell>
|
|
||||||
<div className="admin-page-head">
|
|
||||||
<p className="eyebrow">Betrieb</p>
|
|
||||||
<h1>System</h1>
|
|
||||||
</div>
|
|
||||||
<section className="admin-card">
|
|
||||||
<h2>Runtime Informationen</h2>
|
|
||||||
<dl className="system-list">
|
|
||||||
<div><dt>Version</dt><dd>{appVersion}</dd></div>
|
|
||||||
<div><dt>Node Version</dt><dd>{process.version}</dd></div>
|
|
||||||
<div><dt>Docker Environment</dt><dd>{isDockerEnvironment() ? "ja" : "nein"}</dd></div>
|
|
||||||
<div><dt>Storage Status</dt><dd>{storage}</dd></div>
|
|
||||||
<div><dt>Data Directory</dt><dd>{dataDirectory}</dd></div>
|
|
||||||
<div><dt>Config Directory</dt><dd>{configDirectory}</dd></div>
|
|
||||||
<div><dt>Upload Directory</dt><dd>{uploadDirectory}</dd></div>
|
|
||||||
<div><dt>Admin Konfiguration</dt><dd>{adminConfigurationStatus()}</dd></div>
|
|
||||||
<div><dt>Olympus Verbindung</dt><dd>{olympusStatus()}</dd></div>
|
|
||||||
<div><dt>SMTP</dt><dd>{smtpConfigured ? "configured" : "missing"}</dd></div>
|
|
||||||
<div><dt>Letzte SMTP-Testmail</dt><dd>{smtpSettings.lastTestAt ? `${new Date(smtpSettings.lastTestAt).toLocaleString("de-DE")} (${smtpSettings.lastTestStatus})` : "keine"}</dd></div>
|
|
||||||
<div><dt>Letzter SMTP-Formularversand</dt><dd>{smtpSettings.lastDeliveryAt ? `${new Date(smtpSettings.lastDeliveryAt).toLocaleString("de-DE")} (${smtpSettings.lastDeliveryStatus})` : "keiner"}</dd></div>
|
|
||||||
<div><dt>Content Version</dt><dd>{contentStatus.version}</dd></div>
|
|
||||||
<div><dt>Letzte Content-Änderung</dt><dd>{contentStatus.lastModified ? new Date(contentStatus.lastModified).toLocaleString("de-DE") : "keine"}</dd></div>
|
|
||||||
<div><dt>Anzahl Seiten</dt><dd>{contentStatus.pageCount}</dd></div>
|
|
||||||
<div><dt>Anzahl Medien</dt><dd>{contentStatus.mediaCount}</dd></div>
|
|
||||||
</dl>
|
|
||||||
</section>
|
|
||||||
<section className="admin-card">
|
|
||||||
<h2>Betriebsregeln</h2>
|
|
||||||
<ul className="list">
|
|
||||||
<li>Runtime-Daten liegen unter <code>data/</code> und werden nicht versioniert.</li>
|
|
||||||
<li>Uploads liegen unter <code>storage/uploads/images/</code> und werden nicht versioniert.</li>
|
|
||||||
<li>SMTP-Versand ist serverseitig aktiv, sofern eine vollständige Konfiguration gespeichert ist.</li>
|
|
||||||
</ul>
|
|
||||||
</section>
|
|
||||||
<section className="admin-card">
|
|
||||||
<h2>Letzte Content-Backups</h2>
|
|
||||||
<table className="admin-table">
|
|
||||||
<thead><tr><th>Seite</th><th>Datum</th><th>Dateiname</th></tr></thead>
|
|
||||||
<tbody>
|
|
||||||
{contentStatus.backups.map((backup) => (
|
|
||||||
<tr key={backup.fileName}>
|
|
||||||
<td>{backup.page}</td>
|
|
||||||
<td>{new Date(backup.createdAt).toLocaleString("de-DE")}</td>
|
|
||||||
<td>{backup.fileName}</td>
|
|
||||||
</tr>
|
|
||||||
))}
|
|
||||||
{contentStatus.backups.length === 0 && <tr><td colSpan={3}>Noch keine Content-Backups vorhanden.</td></tr>}
|
|
||||||
</tbody>
|
|
||||||
</table>
|
|
||||||
</section>
|
|
||||||
</AdminShell>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
@ -1,21 +0,0 @@
|
||||||
import { redirect } from "next/navigation";
|
|
||||||
import AdminShell from "@/components/admin/AdminShell";
|
|
||||||
import ContentManager from "@/components/admin/ContentManager";
|
|
||||||
import { requireAdminSession } from "@/lib/admin/auth";
|
|
||||||
import { listMediaFiles } from "@/lib/admin/media";
|
|
||||||
import { getAllContent } from "@/lib/content/service";
|
|
||||||
|
|
||||||
export default async function AdminWebsitePage() {
|
|
||||||
if (!await requireAdminSession()) redirect("/admin/login");
|
|
||||||
const [content, mediaFiles] = await Promise.all([getAllContent(), listMediaFiles()]);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<AdminShell>
|
|
||||||
<div className="admin-page-head">
|
|
||||||
<p className="eyebrow">Website</p>
|
|
||||||
<h1>Website-Inhalte</h1>
|
|
||||||
</div>
|
|
||||||
<ContentManager initialContent={content} mediaFiles={mediaFiles} />
|
|
||||||
</AdminShell>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
@ -1,19 +0,0 @@
|
||||||
import { NextResponse } from "next/server";
|
|
||||||
import { requireAdminSession } from "@/lib/admin/auth";
|
|
||||||
import { updateContactStatus } from "@/lib/admin/store";
|
|
||||||
import type { InquiryStatus } from "@/lib/admin/types";
|
|
||||||
|
|
||||||
const statuses: InquiryStatus[] = ["new", "in_progress", "done"];
|
|
||||||
|
|
||||||
export async function PATCH(request: Request, { params }: { params: Promise<{ id: string }> }) {
|
|
||||||
if (!await requireAdminSession()) return NextResponse.json({ message: "Nicht autorisiert." }, { status: 401 });
|
|
||||||
|
|
||||||
const body = await request.json() as { status?: InquiryStatus };
|
|
||||||
if (!body.status || !statuses.includes(body.status)) {
|
|
||||||
return NextResponse.json({ message: "Ungültiger Status." }, { status: 400 });
|
|
||||||
}
|
|
||||||
|
|
||||||
const { id } = await params;
|
|
||||||
await updateContactStatus(id, body.status);
|
|
||||||
return NextResponse.json({ message: "Status aktualisiert." });
|
|
||||||
}
|
|
||||||
|
|
@ -1,27 +0,0 @@
|
||||||
import { NextResponse } from "next/server";
|
|
||||||
import { requireAdminSession } from "@/lib/admin/auth";
|
|
||||||
import { getAllContent, saveContent } from "@/lib/content/service";
|
|
||||||
import type { ContentDocuments, ContentKey } from "@/lib/content/types";
|
|
||||||
|
|
||||||
const keys: ContentKey[] = ["home", "services", "radio-service", "repair", "about", "contact", "settings"];
|
|
||||||
|
|
||||||
function isContentKey(value: unknown): value is ContentKey {
|
|
||||||
return typeof value === "string" && keys.includes(value as ContentKey);
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function GET() {
|
|
||||||
if (!await requireAdminSession()) return NextResponse.json({ message: "Nicht autorisiert." }, { status: 401 });
|
|
||||||
return NextResponse.json({ content: await getAllContent() });
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function PUT(request: Request) {
|
|
||||||
if (!await requireAdminSession()) return NextResponse.json({ message: "Nicht autorisiert." }, { status: 401 });
|
|
||||||
|
|
||||||
const body = await request.json() as { key?: unknown; content?: unknown };
|
|
||||||
if (!isContentKey(body.key) || !body.content) {
|
|
||||||
return NextResponse.json({ message: "Ungültige Content-Anfrage." }, { status: 400 });
|
|
||||||
}
|
|
||||||
|
|
||||||
const saved = await saveContent(body.key, body.content as ContentDocuments[typeof body.key]);
|
|
||||||
return NextResponse.json({ message: "Inhalt gespeichert.", key: body.key, content: saved });
|
|
||||||
}
|
|
||||||
|
|
@ -1,16 +0,0 @@
|
||||||
import { NextResponse } from "next/server";
|
|
||||||
import { adminCookieOptions, adminSessionCookie, createAdminSession, validateAdminCredentials } from "@/lib/admin/auth";
|
|
||||||
|
|
||||||
export async function POST(request: Request) {
|
|
||||||
const form = await request.formData();
|
|
||||||
const email = String(form.get("email") ?? "");
|
|
||||||
const password = String(form.get("password") ?? "");
|
|
||||||
|
|
||||||
if (!validateAdminCredentials(email, password)) {
|
|
||||||
return NextResponse.json({ message: "Ungültige Zugangsdaten." }, { status: 401 });
|
|
||||||
}
|
|
||||||
|
|
||||||
const response = NextResponse.json({ message: "Anmeldung erfolgreich." });
|
|
||||||
response.cookies.set(adminSessionCookie, createAdminSession(), adminCookieOptions());
|
|
||||||
return response;
|
|
||||||
}
|
|
||||||
|
|
@ -1,8 +0,0 @@
|
||||||
import { NextResponse } from "next/server";
|
|
||||||
import { adminSessionCookie } from "@/lib/admin/auth";
|
|
||||||
|
|
||||||
export async function POST() {
|
|
||||||
const response = NextResponse.json({ message: "Abgemeldet." });
|
|
||||||
response.cookies.set(adminSessionCookie, "", { path: "/", maxAge: 0 });
|
|
||||||
return response;
|
|
||||||
}
|
|
||||||
|
|
@ -1,39 +0,0 @@
|
||||||
import { NextResponse } from "next/server";
|
|
||||||
import { requireAdminSession } from "@/lib/admin/auth";
|
|
||||||
import { deleteMediaFile, listMediaFiles, saveMediaFile } from "@/lib/admin/media";
|
|
||||||
|
|
||||||
export async function GET() {
|
|
||||||
if (!await requireAdminSession()) return NextResponse.json({ message: "Nicht autorisiert." }, { status: 401 });
|
|
||||||
return NextResponse.json({ files: await listMediaFiles() });
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function POST(request: Request) {
|
|
||||||
if (!await requireAdminSession()) return NextResponse.json({ message: "Nicht autorisiert." }, { status: 401 });
|
|
||||||
const form = await request.formData();
|
|
||||||
const file = form.get("file");
|
|
||||||
|
|
||||||
if (!(file instanceof File)) {
|
|
||||||
return NextResponse.json({ message: "Bitte wählen Sie eine Datei aus." }, { status: 400 });
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
|
||||||
const result = await saveMediaFile(file);
|
|
||||||
if (result.error) return NextResponse.json({ message: result.error }, { status: 400 });
|
|
||||||
} catch {
|
|
||||||
return NextResponse.json({ message: "Die Datei konnte nicht gespeichert werden. Bitte Upload-Speicher und Rechte prüfen." }, { status: 500 });
|
|
||||||
}
|
|
||||||
|
|
||||||
return NextResponse.json({ message: "Datei erfolgreich hochgeladen.", files: await listMediaFiles() });
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function DELETE(request: Request) {
|
|
||||||
if (!await requireAdminSession()) return NextResponse.json({ message: "Nicht autorisiert." }, { status: 401 });
|
|
||||||
|
|
||||||
const body = await request.json() as { name?: string };
|
|
||||||
if (!body.name) return NextResponse.json({ message: "Dateiname fehlt." }, { status: 400 });
|
|
||||||
|
|
||||||
const result = await deleteMediaFile(body.name);
|
|
||||||
if (result.error) return NextResponse.json({ message: result.error }, { status: 400 });
|
|
||||||
|
|
||||||
return NextResponse.json({ message: "Datei gelöscht.", files: await listMediaFiles() });
|
|
||||||
}
|
|
||||||
|
|
@ -1,19 +0,0 @@
|
||||||
import { NextResponse } from "next/server";
|
|
||||||
import { requireAdminSession } from "@/lib/admin/auth";
|
|
||||||
import { updateRepairStatus } from "@/lib/admin/store";
|
|
||||||
import type { InquiryStatus } from "@/lib/admin/types";
|
|
||||||
|
|
||||||
const statuses: InquiryStatus[] = ["new", "in_progress", "done"];
|
|
||||||
|
|
||||||
export async function PATCH(request: Request, { params }: { params: Promise<{ id: string }> }) {
|
|
||||||
if (!await requireAdminSession()) return NextResponse.json({ message: "Nicht autorisiert." }, { status: 401 });
|
|
||||||
|
|
||||||
const body = await request.json() as { status?: InquiryStatus };
|
|
||||||
if (!body.status || !statuses.includes(body.status)) {
|
|
||||||
return NextResponse.json({ message: "Ungültiger Status." }, { status: 400 });
|
|
||||||
}
|
|
||||||
|
|
||||||
const { id } = await params;
|
|
||||||
await updateRepairStatus(id, body.status);
|
|
||||||
return NextResponse.json({ message: "Status aktualisiert." });
|
|
||||||
}
|
|
||||||
|
|
@ -1,9 +0,0 @@
|
||||||
import { NextResponse } from "next/server";
|
|
||||||
import { requireAdminSession } from "@/lib/admin/auth";
|
|
||||||
import { saveSiteSettings } from "@/lib/admin/store";
|
|
||||||
|
|
||||||
export async function POST(request: Request) {
|
|
||||||
if (!await requireAdminSession()) return NextResponse.json({ message: "Nicht autorisiert." }, { status: 401 });
|
|
||||||
await saveSiteSettings(await request.formData());
|
|
||||||
return new NextResponse(null, { status: 303, headers: { Location: "/admin/einstellungen?saved=1" } });
|
|
||||||
}
|
|
||||||
|
|
@ -1,26 +0,0 @@
|
||||||
import { NextResponse } from "next/server";
|
|
||||||
import { requireAdminSession } from "@/lib/admin/auth";
|
|
||||||
import { saveSmtpSettings, toPublicSmtpSettings } from "@/lib/mail/config";
|
|
||||||
import { sendTestMail } from "@/lib/mail/smtp";
|
|
||||||
|
|
||||||
export async function POST(request: Request) {
|
|
||||||
if (!await requireAdminSession()) return NextResponse.json({ message: "Nicht autorisiert." }, { status: 401 });
|
|
||||||
const form = await request.formData();
|
|
||||||
const action = String(form.get("action") ?? "save");
|
|
||||||
|
|
||||||
if (action === "test") {
|
|
||||||
const settings = await saveSmtpSettings(form);
|
|
||||||
const result = await sendTestMail();
|
|
||||||
return NextResponse.json({
|
|
||||||
message: result.ok ? "Test-E-Mail wurde gesendet." : result.error ?? "Test-E-Mail fehlgeschlagen.",
|
|
||||||
ok: result.ok,
|
|
||||||
settings: toPublicSmtpSettings(settings),
|
|
||||||
}, { status: result.ok ? 200 : 400 });
|
|
||||||
}
|
|
||||||
|
|
||||||
const settings = await saveSmtpSettings(form);
|
|
||||||
return NextResponse.json({
|
|
||||||
message: "SMTP-Konfiguration gespeichert.",
|
|
||||||
settings: toPublicSmtpSettings(settings),
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
@ -1,6 +1,4 @@
|
||||||
import { NextResponse } from "next/server";
|
import { NextResponse } from "next/server";
|
||||||
import { addContactInquiry } from "@/lib/admin/store";
|
|
||||||
import { sendContactInquiryMail } from "@/lib/mail/smtp";
|
|
||||||
|
|
||||||
function text(value: FormDataEntryValue | null) {
|
function text(value: FormDataEntryValue | null) {
|
||||||
return typeof value === "string" ? value.trim() : "";
|
return typeof value === "string" ? value.trim() : "";
|
||||||
|
|
@ -10,15 +8,16 @@ export async function POST(request: Request) {
|
||||||
const form = await request.formData();
|
const form = await request.formData();
|
||||||
const name = text(form.get("name"));
|
const name = text(form.get("name"));
|
||||||
const email = text(form.get("email"));
|
const email = text(form.get("email"));
|
||||||
const subject = text(form.get("subject"));
|
|
||||||
const message = text(form.get("message"));
|
const message = text(form.get("message"));
|
||||||
|
|
||||||
if (!name || !email.includes("@") || !subject || message.length < 10 || form.get("privacyAccepted") !== "on") {
|
if (!name || !email.includes("@") || message.length < 10 || form.get("privacy") !== "on") {
|
||||||
return NextResponse.json({ message: "Bitte füllen Sie alle Pflichtfelder aus." }, { status: 400 });
|
return NextResponse.json({ message: "Bitte füllen Sie alle Pflichtfelder aus." }, { status: 400 });
|
||||||
}
|
}
|
||||||
|
|
||||||
const inquiry = await addContactInquiry(form);
|
console.info("contact_request.received", {
|
||||||
await sendContactInquiryMail(inquiry);
|
messageLength: message.length,
|
||||||
|
hasEmail: true,
|
||||||
|
});
|
||||||
|
|
||||||
return NextResponse.json({
|
return NextResponse.json({
|
||||||
message: "Ihre Nachricht wurde erfasst. Sie erhalten eine Rückmeldung.",
|
message: "Ihre Nachricht wurde erfasst. Sie erhalten eine Rückmeldung.",
|
||||||
|
|
|
||||||
|
|
@ -1,7 +0,0 @@
|
||||||
import { NextResponse } from "next/server";
|
|
||||||
import { getContent } from "@/lib/content/service";
|
|
||||||
|
|
||||||
export async function GET() {
|
|
||||||
const settings = await getContent("settings");
|
|
||||||
return NextResponse.json({ settings });
|
|
||||||
}
|
|
||||||
|
|
@ -1,24 +0,0 @@
|
||||||
import { NextResponse } from "next/server";
|
|
||||||
import { adminConfigurationStatus, appVersion, checkStorage } from "@/lib/runtime/config";
|
|
||||||
import { getSmtpSettings, isSmtpConfigured } from "@/lib/mail/config";
|
|
||||||
|
|
||||||
export async function GET() {
|
|
||||||
let storage = "ok";
|
|
||||||
|
|
||||||
try {
|
|
||||||
await checkStorage();
|
|
||||||
} catch {
|
|
||||||
storage = "error";
|
|
||||||
}
|
|
||||||
|
|
||||||
const smtpSettings = await getSmtpSettings();
|
|
||||||
|
|
||||||
return NextResponse.json({
|
|
||||||
status: storage === "ok" ? "ok" : "error",
|
|
||||||
version: appVersion,
|
|
||||||
storage,
|
|
||||||
admin: adminConfigurationStatus(),
|
|
||||||
smtp: isSmtpConfigured(smtpSettings) ? "configured" : "missing",
|
|
||||||
timestamp: new Date().toISOString(),
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
@ -1,22 +0,0 @@
|
||||||
import { NextResponse } from "next/server";
|
|
||||||
import { readMediaFile } from "@/lib/admin/media";
|
|
||||||
|
|
||||||
export async function GET(_request: Request, { params }: { params: Promise<{ filename: string }> }) {
|
|
||||||
const { filename } = await params;
|
|
||||||
const media = await readMediaFile(decodeURIComponent(filename));
|
|
||||||
|
|
||||||
if (!media) {
|
|
||||||
return NextResponse.json({ message: "Datei nicht gefunden." }, { status: 404 });
|
|
||||||
}
|
|
||||||
|
|
||||||
return new NextResponse(media.data, {
|
|
||||||
status: 200,
|
|
||||||
headers: {
|
|
||||||
"Content-Type": media.mimeType,
|
|
||||||
"Content-Length": String(media.size),
|
|
||||||
"Content-Disposition": "inline",
|
|
||||||
"Cache-Control": "private, max-age=300",
|
|
||||||
"X-Content-Type-Options": "nosniff",
|
|
||||||
},
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
@ -1,6 +1,4 @@
|
||||||
import { NextResponse } from "next/server";
|
import { NextResponse } from "next/server";
|
||||||
import { addRepairInquiry } from "@/lib/admin/store";
|
|
||||||
import { sendRepairInquiryMail } from "@/lib/mail/smtp";
|
|
||||||
|
|
||||||
function required(value: FormDataEntryValue | null) {
|
function required(value: FormDataEntryValue | null) {
|
||||||
return typeof value === "string" && value.trim().length > 0;
|
return typeof value === "string" && value.trim().length > 0;
|
||||||
|
|
@ -14,14 +12,24 @@ export async function POST(request: Request) {
|
||||||
const model = form.get("model");
|
const model = form.get("model");
|
||||||
const deviceType = form.get("deviceType");
|
const deviceType = form.get("deviceType");
|
||||||
const description = form.get("description");
|
const description = form.get("description");
|
||||||
const privacyAccepted = form.get("privacyAccepted");
|
const privacy = form.get("privacy");
|
||||||
|
|
||||||
if (!required(name) || !required(email) || !required(manufacturer) || !required(model) || !required(deviceType) || !required(description) || privacyAccepted !== "on") {
|
if (!required(name) || !required(email) || !required(manufacturer) || !required(model) || !required(deviceType) || !required(description) || privacy !== "on") {
|
||||||
return NextResponse.json({ message: "Bitte füllen Sie alle Pflichtfelder aus." }, { status: 400 });
|
return NextResponse.json({ message: "Bitte füllen Sie alle Pflichtfelder aus." }, { status: 400 });
|
||||||
}
|
}
|
||||||
|
|
||||||
const inquiry = await addRepairInquiry(form);
|
const intakeUrl = process.env.OLYMPUS_INTAKE_API_URL;
|
||||||
await sendRepairInquiryMail(inquiry);
|
const hasIntegrationToken = Boolean(process.env.OLYMPUS_INTAKE_API_TOKEN);
|
||||||
|
|
||||||
|
console.info("repair_intake.received", {
|
||||||
|
hasIntakeIntegration: Boolean(intakeUrl && hasIntegrationToken),
|
||||||
|
manufacturer: String(manufacturer),
|
||||||
|
model: String(model),
|
||||||
|
deviceType: String(deviceType),
|
||||||
|
hasPhone: required(form.get("phone")),
|
||||||
|
hasSerialNumber: required(form.get("serialNumber")),
|
||||||
|
hasAccessories: required(form.get("accessories")),
|
||||||
|
});
|
||||||
|
|
||||||
return NextResponse.json({
|
return NextResponse.json({
|
||||||
message: "Ihre Reparaturanfrage wurde erfasst. Sie erhalten nach Prüfung eine Rückmeldung.",
|
message: "Ihre Reparaturanfrage wurde erfasst. Sie erhalten nach Prüfung eine Rückmeldung.",
|
||||||
|
|
|
||||||
|
|
@ -1,36 +0,0 @@
|
||||||
import { NextResponse } from "next/server";
|
|
||||||
import type { EstimateActionResult } from "@/lib/olympus/status";
|
|
||||||
|
|
||||||
export function estimateActionResponse(result: EstimateActionResult, successMessage: string) {
|
|
||||||
if (result.ok) {
|
|
||||||
return NextResponse.json({ message: successMessage });
|
|
||||||
}
|
|
||||||
|
|
||||||
if (result.reason === "invalid") {
|
|
||||||
return NextResponse.json(
|
|
||||||
{ message: "Dieser Statuslink ist ungültig oder nicht mehr aktiv." },
|
|
||||||
{ status: 404 },
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (result.reason === "unavailable") {
|
|
||||||
return NextResponse.json(
|
|
||||||
{ message: "Der Reparaturstatus ist momentan nicht abrufbar." },
|
|
||||||
{ status: 502 },
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
return NextResponse.json(
|
|
||||||
{ message: "Die Aktion konnte nicht abgeschlossen werden. Bitte versuchen Sie es erneut." },
|
|
||||||
{ status: 400 },
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function readOptionalMessage(request: Request) {
|
|
||||||
try {
|
|
||||||
const body = await request.json() as { message?: unknown };
|
|
||||||
return typeof body.message === "string" ? body.message : "";
|
|
||||||
} catch {
|
|
||||||
return "";
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -1,15 +0,0 @@
|
||||||
import { submitOlympusEstimateAction } from "@/lib/olympus/status";
|
|
||||||
import { estimateActionResponse } from "../action-response";
|
|
||||||
|
|
||||||
type RouteContext = {
|
|
||||||
params: Promise<{
|
|
||||||
token: string;
|
|
||||||
}>;
|
|
||||||
};
|
|
||||||
|
|
||||||
export async function POST(_request: Request, { params }: RouteContext) {
|
|
||||||
const { token } = await params;
|
|
||||||
const result = await submitOlympusEstimateAction(token, "approve");
|
|
||||||
|
|
||||||
return estimateActionResponse(result, "Kostenvoranschlag wurde freigegeben.");
|
|
||||||
}
|
|
||||||
|
|
@ -1,16 +0,0 @@
|
||||||
import { submitOlympusEstimateAction } from "@/lib/olympus/status";
|
|
||||||
import { estimateActionResponse, readOptionalMessage } from "../action-response";
|
|
||||||
|
|
||||||
type RouteContext = {
|
|
||||||
params: Promise<{
|
|
||||||
token: string;
|
|
||||||
}>;
|
|
||||||
};
|
|
||||||
|
|
||||||
export async function POST(request: Request, { params }: RouteContext) {
|
|
||||||
const { token } = await params;
|
|
||||||
const message = await readOptionalMessage(request);
|
|
||||||
const result = await submitOlympusEstimateAction(token, "decline", message);
|
|
||||||
|
|
||||||
return estimateActionResponse(result, "Kostenvoranschlag wurde abgelehnt.");
|
|
||||||
}
|
|
||||||
|
|
@ -1,16 +0,0 @@
|
||||||
import { submitOlympusEstimateAction } from "@/lib/olympus/status";
|
|
||||||
import { estimateActionResponse, readOptionalMessage } from "../action-response";
|
|
||||||
|
|
||||||
type RouteContext = {
|
|
||||||
params: Promise<{
|
|
||||||
token: string;
|
|
||||||
}>;
|
|
||||||
};
|
|
||||||
|
|
||||||
export async function POST(request: Request, { params }: RouteContext) {
|
|
||||||
const { token } = await params;
|
|
||||||
const message = await readOptionalMessage(request);
|
|
||||||
const result = await submitOlympusEstimateAction(token, "question", message);
|
|
||||||
|
|
||||||
return estimateActionResponse(result, "Ihre Rückfrage wurde gesendet.");
|
|
||||||
}
|
|
||||||
|
|
@ -10,15 +10,15 @@ export default function DatenschutzPage() {
|
||||||
<PageHero eyebrow="Rechtliches" title="Datenschutz">Informationen zur Verarbeitung personenbezogener Daten.</PageHero>
|
<PageHero eyebrow="Rechtliches" title="Datenschutz">Informationen zur Verarbeitung personenbezogener Daten.</PageHero>
|
||||||
<section className="section">
|
<section className="section">
|
||||||
<div className="container legal">
|
<div className="container legal">
|
||||||
<p className="notice">Datenschutzhinweise müssen vor produktiver Veröffentlichung durch den Betreiber ergänzt und rechtlich geprüft werden.</p>
|
<p className="notice">TODO: Rechtliche Angaben ergänzen</p>
|
||||||
<h2>Verantwortlicher</h2>
|
<h2>Verantwortlicher</h2>
|
||||||
<p>Die Angaben zum Verantwortlichen werden durch den Betreiber gepflegt.</p>
|
<p>TODO: Rechtliche Angaben ergänzen</p>
|
||||||
<h2>Kontakt- und Reparaturanfragen</h2>
|
<h2>Kontakt- und Reparaturanfragen</h2>
|
||||||
<p>Die Formulare erfassen Angaben, die zur Bearbeitung der jeweiligen Anfrage erforderlich sind. Eine produktive Datenschutzerklärung muss vor Veröffentlichung rechtlich geprüft und ergänzt werden.</p>
|
<p>Die Formulare erfassen Angaben, die zur Bearbeitung der jeweiligen Anfrage erforderlich sind. Eine produktive Datenschutzerklärung muss vor Veröffentlichung rechtlich geprüft und ergänzt werden.</p>
|
||||||
<h2>Hosting und Server-Logs</h2>
|
<h2>Hosting und Server-Logs</h2>
|
||||||
<p>Informationen zum Hosting und zur Verarbeitung technischer Serverdaten werden durch den Betreiber gepflegt.</p>
|
<p>TODO: Rechtliche Angaben ergänzen</p>
|
||||||
<h2>Betroffenenrechte</h2>
|
<h2>Betroffenenrechte</h2>
|
||||||
<p>Hinweise zu Betroffenenrechten werden durch den Betreiber rechtlich geprüft und gepflegt.</p>
|
<p>TODO: Rechtliche Angaben ergänzen</p>
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
</>
|
</>
|
||||||
|
|
|
||||||
|
|
@ -1,19 +0,0 @@
|
||||||
"use client";
|
|
||||||
|
|
||||||
import Link from "next/link";
|
|
||||||
|
|
||||||
export default function ErrorPage({ reset }: { error: Error & { digest?: string }; reset: () => void }) {
|
|
||||||
return (
|
|
||||||
<section className="error-page">
|
|
||||||
<div className="error-card">
|
|
||||||
<p className="eyebrow">500</p>
|
|
||||||
<h1>Die Seite konnte nicht geladen werden</h1>
|
|
||||||
<p className="lead">Es ist ein technischer Fehler aufgetreten. Bitte versuchen Sie es erneut.</p>
|
|
||||||
<div className="actions">
|
|
||||||
<button className="button" type="button" onClick={reset}>Erneut versuchen</button>
|
|
||||||
<Link className="button light" href="/">Zur Startseite</Link>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</section>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
@ -1,41 +1,33 @@
|
||||||
|
import type { Metadata } from "next";
|
||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
import PageHero from "@/components/PageHero";
|
import PageHero from "@/components/PageHero";
|
||||||
import { defaultContent } from "@/lib/content/defaults";
|
import { createMetadata } from "../seo";
|
||||||
import { getContent } from "@/lib/content/service";
|
|
||||||
import { createContentMetadata } from "../seo";
|
|
||||||
|
|
||||||
export const dynamic = "force-dynamic";
|
export const metadata: Metadata = createMetadata("Funkgeräte-Service", "Werkstattservice für CB-Funk, Amateurfunk und Funkgeräte-Abgleich.", "/funkgeraete-service");
|
||||||
|
|
||||||
export async function generateMetadata() {
|
|
||||||
const content = await getContent("radio-service");
|
|
||||||
return createContentMetadata(content.seo, defaultContent["radio-service"].seo);
|
|
||||||
}
|
|
||||||
|
|
||||||
export default async function RadioServicePage() {
|
|
||||||
const content = await getContent("radio-service");
|
|
||||||
|
|
||||||
|
export default function RadioServicePage() {
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<PageHero eyebrow={content.eyebrow} title={content.title}>
|
<PageHero eyebrow="Funkgeräte-Service" title="Prüfung, Diagnose und Abgleich">
|
||||||
{content.subtitle}
|
Funkgeräte zeigen Fehler oft erst im Zusammenspiel von Empfang, Sendeteil, Versorgung, Antennenanpassung, Bedienung und Abgleich. Der Service betrachtet diese Zusammenhänge strukturiert.
|
||||||
</PageHero>
|
</PageHero>
|
||||||
<section className="section">
|
<section className="section">
|
||||||
<div className="container split">
|
<div className="container split">
|
||||||
<div>
|
<div>
|
||||||
<h2>{content.listTitle}</h2>
|
<h2>Typische Fehlerbilder</h2>
|
||||||
<ul className="list">
|
<ul className="list">
|
||||||
{content.symptoms.map((symptom) => <li key={symptom}>{symptom}</li>)}
|
<li>Kein oder schwacher Empfang</li>
|
||||||
|
<li>Keine, leise oder verzerrte Modulation</li>
|
||||||
|
<li>Frequenzversatz, PLL-Rasten oder instabile Kanäle</li>
|
||||||
|
<li>Sendeprobleme, schwankende Leistung oder auffällige Erwärmung</li>
|
||||||
|
<li>Audio-/NF-Probleme, Displayfehler oder Aussetzer durch Bedienelemente</li>
|
||||||
|
<li>Unklare Vorarbeiten, fehlende Serviceunterlagen oder bereits geöffnete Geräte</li>
|
||||||
</ul>
|
</ul>
|
||||||
</div>
|
</div>
|
||||||
<div className="panel">
|
<div className="panel">
|
||||||
<h2>{content.cta.text}</h2>
|
<h2>Messung und Einordnung</h2>
|
||||||
<p className="lead">{content.intro}</p>
|
<p className="lead">Eine gute Reparatur beginnt mit vollständigen Angaben zu Gerät, Fehlerbild und Vorgeschichte. Danach folgen technische Einschätzung, Prüfung mit geeigneter Messtechnik und die weitere Abstimmung.</p>
|
||||||
<ul className="list">
|
<Link className="button" href="/reparatur">Reparatur anfragen</Link>
|
||||||
{content.measurements.map((measurement) => <li key={measurement}>{measurement}</li>)}
|
|
||||||
</ul>
|
|
||||||
<p>{content.alignment}</p>
|
|
||||||
<p>{content.repair}</p>
|
|
||||||
<Link className="button" href={content.cta.primary.href}>{content.cta.primary.label}</Link>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|
|
||||||
|
|
@ -1,18 +0,0 @@
|
||||||
"use client";
|
|
||||||
|
|
||||||
export default function GlobalErrorPage({ reset }: { error: Error & { digest?: string }; reset: () => void }) {
|
|
||||||
return (
|
|
||||||
<html lang="de">
|
|
||||||
<body>
|
|
||||||
<section className="error-page">
|
|
||||||
<div className="error-card">
|
|
||||||
<p className="eyebrow">Runtime</p>
|
|
||||||
<h1>Ein unerwarteter Fehler ist aufgetreten</h1>
|
|
||||||
<p className="lead">Die Anwendung konnte diesen Bereich nicht laden. Bitte versuchen Sie es erneut.</p>
|
|
||||||
<button className="button" type="button" onClick={reset}>Erneut versuchen</button>
|
|
||||||
</div>
|
|
||||||
</section>
|
|
||||||
</body>
|
|
||||||
</html>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
1174
app/globals.css
1174
app/globals.css
File diff suppressed because it is too large
Load diff
|
|
@ -10,13 +10,13 @@ export default function ImpressumPage() {
|
||||||
<PageHero eyebrow="Rechtliches" title="Impressum">Rechtliche Angaben für die öffentliche Firmenwebsite.</PageHero>
|
<PageHero eyebrow="Rechtliches" title="Impressum">Rechtliche Angaben für die öffentliche Firmenwebsite.</PageHero>
|
||||||
<section className="section">
|
<section className="section">
|
||||||
<div className="container legal">
|
<div className="container legal">
|
||||||
<p className="notice">Rechtliche Angaben müssen vor produktiver Veröffentlichung durch den Betreiber ergänzt und geprüft werden.</p>
|
<p className="notice">TODO: Rechtliche Angaben ergänzen</p>
|
||||||
<h2>Anbieterkennzeichnung</h2>
|
<h2>Anbieterkennzeichnung</h2>
|
||||||
<p>Die vollständige Anbieterkennzeichnung wird durch den Betreiber gepflegt.</p>
|
<p>TODO: Rechtliche Angaben ergänzen</p>
|
||||||
<h2>Kontakt</h2>
|
<h2>Kontakt</h2>
|
||||||
<p>Die rechtlich relevanten Kontaktdaten werden durch den Betreiber gepflegt.</p>
|
<p>TODO: Rechtliche Angaben ergänzen</p>
|
||||||
<h2>Umsatzsteuer / Registerangaben</h2>
|
<h2>Umsatzsteuer / Registerangaben</h2>
|
||||||
<p>Umsatzsteuer- und Registerangaben werden durch den Betreiber gepflegt, sofern sie erforderlich sind.</p>
|
<p>TODO: Rechtliche Angaben ergänzen</p>
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
</>
|
</>
|
||||||
|
|
|
||||||
|
|
@ -1,41 +1,21 @@
|
||||||
|
import type { Metadata } from "next";
|
||||||
import PageHero from "@/components/PageHero";
|
import PageHero from "@/components/PageHero";
|
||||||
import ContactForm from "@/components/ContactForm";
|
import ContactForm from "@/components/ContactForm";
|
||||||
import { defaultContent } from "@/lib/content/defaults";
|
import { createMetadata } from "../seo";
|
||||||
import { getContent } from "@/lib/content/service";
|
|
||||||
import { createContentMetadata } from "../seo";
|
|
||||||
|
|
||||||
export const dynamic = "force-dynamic";
|
export const metadata: Metadata = createMetadata("Kontakt", "Kontakt zu Funktechnik Schubert aufnehmen.", "/kontakt");
|
||||||
|
|
||||||
export async function generateMetadata() {
|
|
||||||
const content = await getContent("contact");
|
|
||||||
return createContentMetadata(content.seo, defaultContent.contact.seo);
|
|
||||||
}
|
|
||||||
|
|
||||||
export default async function ContactPage() {
|
|
||||||
const content = await getContent("contact");
|
|
||||||
const contactDetails = [
|
|
||||||
content.phone && `Telefon: ${content.phone}`,
|
|
||||||
content.email && `E-Mail: ${content.email}`,
|
|
||||||
content.address && `Adresse: ${content.address}`,
|
|
||||||
content.openingHours && `Öffnungszeiten: ${content.openingHours}`,
|
|
||||||
].filter(Boolean);
|
|
||||||
|
|
||||||
|
export default function ContactPage() {
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<PageHero eyebrow={content.eyebrow} title={content.title}>
|
<PageHero eyebrow="Kontakt" title="Kontakt aufnehmen">
|
||||||
{content.subtitle}
|
Beschreiben Sie kurz Ihr Anliegen. Für Reparaturen nutzen Sie idealerweise die strukturierte Reparaturannahme.
|
||||||
</PageHero>
|
</PageHero>
|
||||||
<section className="section">
|
<section className="section">
|
||||||
<div className="container split">
|
<div className="container split">
|
||||||
<div>
|
<div>
|
||||||
<h2>{content.cta.text}</h2>
|
<h2>Direkt und technisch</h2>
|
||||||
<p className="lead">{content.heroText}</p>
|
<p className="lead">Je genauer Gerät, Anliegen und gewünschte Unterstützung beschrieben werden, desto gezielter kann die Rückmeldung erfolgen.</p>
|
||||||
{contactDetails.length > 0 && (
|
|
||||||
<ul className="list">
|
|
||||||
{contactDetails.map((detail) => <li key={detail}>{detail}</li>)}
|
|
||||||
</ul>
|
|
||||||
)}
|
|
||||||
{content.googleMapsLink && <p><a className="button light" href={content.googleMapsLink}>Karte öffnen</a></p>}
|
|
||||||
</div>
|
</div>
|
||||||
<div className="panel">
|
<div className="panel">
|
||||||
<ContactForm />
|
<ContactForm />
|
||||||
|
|
|
||||||
|
|
@ -1,20 +1,21 @@
|
||||||
import type { Metadata } from "next";
|
import type { Metadata } from "next";
|
||||||
import SiteFrame from "@/components/SiteFrame";
|
import Header from "@/components/Header";
|
||||||
|
import Footer from "@/components/Footer";
|
||||||
import "./globals.css";
|
import "./globals.css";
|
||||||
import { defaultContent } from "@/lib/content/defaults";
|
import { createMetadata } from "./seo";
|
||||||
import { getContent } from "@/lib/content/service";
|
|
||||||
import { createContentMetadata } from "./seo";
|
|
||||||
|
|
||||||
export async function generateMetadata(): Promise<Metadata> {
|
export const metadata: Metadata = createMetadata(
|
||||||
const settings = await getContent("settings");
|
"Funktechnik Schubert",
|
||||||
return createContentMetadata(settings.seo, defaultContent.settings.seo);
|
"Service, Reparatur, Diagnose und Abgleich von Funktechnik, CB-Funk, Amateurfunk und Kommunikationstechnik.",
|
||||||
}
|
);
|
||||||
|
|
||||||
export default function RootLayout({ children }: Readonly<{ children: React.ReactNode }>) {
|
export default function RootLayout({ children }: Readonly<{ children: React.ReactNode }>) {
|
||||||
return (
|
return (
|
||||||
<html lang="de">
|
<html lang="de">
|
||||||
<body>
|
<body>
|
||||||
<SiteFrame>{children}</SiteFrame>
|
<Header />
|
||||||
|
<main>{children}</main>
|
||||||
|
<Footer />
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
);
|
);
|
||||||
|
|
|
||||||
|
|
@ -1,30 +1,27 @@
|
||||||
|
import type { Metadata } from "next";
|
||||||
import PageHero from "@/components/PageHero";
|
import PageHero from "@/components/PageHero";
|
||||||
import ServiceCard from "@/components/ServiceCard";
|
import ServiceCard from "@/components/ServiceCard";
|
||||||
import { defaultContent } from "@/lib/content/defaults";
|
import { createMetadata } from "../seo";
|
||||||
import { getContent } from "@/lib/content/service";
|
|
||||||
import { createContentMetadata } from "../seo";
|
|
||||||
|
|
||||||
export const dynamic = "force-dynamic";
|
export const metadata: Metadata = createMetadata("Leistungen", "Funkgeräte-Service, Diagnose, Abgleich und Dokumentation für Kommunikationstechnik.", "/leistungen");
|
||||||
|
|
||||||
export async function generateMetadata() {
|
const services = [
|
||||||
const content = await getContent("services");
|
["Funkgeräte-Service", "Service für CB-Funk, Amateurfunk, Betriebsfunk sowie ältere und moderne Funkgeräte. Dazu gehören Sichtprüfung, Funktionsprüfung und eine technische Einschätzung des Gerätezustands."],
|
||||||
return createContentMetadata(content.seo, defaultContent.services.seo);
|
["Fehlersuche & Reparatur", "Strukturierte Analyse bei fehlender Modulation, Frequenzabweichung, PLL-Problemen, Empfangs- oder Sendeproblemen, Audio-/NF-Fehlern und Anzeige- oder Displayfehlern."],
|
||||||
}
|
["Abgleich & Prüfung", "Frequenzabgleich, Hub- und Modulationsprüfung, Sendeleistung, Empfindlichkeit und Funktionsprüfung nach technischen Unterlagen und sinnvoller Werkstattpraxis."],
|
||||||
|
["Messtechnik & Diagnose", "Systematische Fehlersuche mit geeigneter Messtechnik wie Oszilloskop, Frequenzzähler, Signalgenerator, RF-Messung und Modulationsmessung."],
|
||||||
export default async function ServicesPage() {
|
["Serviceunterlagen", "Sorgfältige Einordnung von Schaltplänen, Service Manuals, Abgleichanleitungen, Dokumentation und Reparaturhinweisen für nachvollziehbare Arbeitsschritte."],
|
||||||
const content = await getContent("services");
|
] as const;
|
||||||
const services = content.services
|
|
||||||
.filter((service) => service.visible)
|
|
||||||
.sort((left, right) => left.sortOrder - right.sortOrder);
|
|
||||||
|
|
||||||
|
export default function ServicesPage() {
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<PageHero eyebrow={content.eyebrow} title={content.title}>
|
<PageHero eyebrow="Leistungen" title="Service für Funkgeräte und Kommunikationstechnik">
|
||||||
{content.subtitle}
|
Technische Unterstützung für Geräte, bei denen Diagnose, Messtechnik, Erfahrung und saubere Dokumentation zählen.
|
||||||
</PageHero>
|
</PageHero>
|
||||||
<section className="section">
|
<section className="section">
|
||||||
<div className="container grid service-grid">
|
<div className="container grid service-grid">
|
||||||
{services.map((service) => <ServiceCard key={service.title} title={service.title}>{service.description}</ServiceCard>)}
|
{services.map(([title, text]) => <ServiceCard key={title} title={title}>{text}</ServiceCard>)}
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
</>
|
</>
|
||||||
|
|
|
||||||
|
|
@ -1,17 +0,0 @@
|
||||||
import Link from "next/link";
|
|
||||||
|
|
||||||
export default function NotFoundPage() {
|
|
||||||
return (
|
|
||||||
<section className="error-page">
|
|
||||||
<div className="error-card">
|
|
||||||
<p className="eyebrow">404</p>
|
|
||||||
<h1>Seite nicht gefunden</h1>
|
|
||||||
<p className="lead">Die angeforderte Seite existiert nicht oder wurde verschoben.</p>
|
|
||||||
<div className="actions">
|
|
||||||
<Link className="button" href="/">Zur Startseite</Link>
|
|
||||||
<Link className="button light" href="/kontakt">Kontakt aufnehmen</Link>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</section>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
61
app/page.tsx
61
app/page.tsx
|
|
@ -1,36 +1,26 @@
|
||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
import ServiceCard from "@/components/ServiceCard";
|
import ServiceCard from "@/components/ServiceCard";
|
||||||
import { defaultContent } from "@/lib/content/defaults";
|
|
||||||
import { getContent } from "@/lib/content/service";
|
|
||||||
import { createContentMetadata } from "./seo";
|
|
||||||
|
|
||||||
export const dynamic = "force-dynamic";
|
|
||||||
|
|
||||||
export async function generateMetadata() {
|
|
||||||
const content = await getContent("home");
|
|
||||||
return createContentMetadata(content.seo, defaultContent.home.seo);
|
|
||||||
}
|
|
||||||
|
|
||||||
export default async function HomePage() {
|
|
||||||
const content = await getContent("home");
|
|
||||||
|
|
||||||
|
export default function HomePage() {
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<section
|
<section className="hero">
|
||||||
className="hero"
|
|
||||||
style={{ backgroundImage: `linear-gradient(90deg, rgba(6, 24, 52, 0.96), rgba(8, 42, 96, 0.78), rgba(8, 42, 96, 0.58)), url("${content.heroImage || "/workbench-signal.svg"}"), linear-gradient(135deg, #07172d, #0b3778)` }}
|
|
||||||
>
|
|
||||||
<div className="container hero-content">
|
<div className="container hero-content">
|
||||||
<p className="eyebrow">{content.eyebrow}</p>
|
<p className="eyebrow">Funktechnik, Messtechnik, Werkstattservice</p>
|
||||||
<h1>{content.title}</h1>
|
<h1>Funktechnik Schubert</h1>
|
||||||
<p className="lead">{content.subtitle}</p>
|
<p className="lead">Service, Reparatur und Diagnose für Funkgeräte, Messtechnik und Kommunikationstechnik.</p>
|
||||||
<p className="hero-copy">{content.heroText}</p>
|
<p className="hero-copy">Von CB-Funk und Amateurfunk bis zur professionellen Funktechnik: Wir unterstützen bei Fehlersuche, Abgleich, Modulationsproblemen, Frequenzproblemen und technischer Dokumentation.</p>
|
||||||
<div className="hero-badges" aria-label="Technische Schwerpunkte">
|
<div className="hero-badges" aria-label="Technische Schwerpunkte">
|
||||||
{content.badges.map((badge) => <span key={badge}>{badge}</span>)}
|
<span>CB-Funk</span>
|
||||||
|
<span>Amateurfunk</span>
|
||||||
|
<span>Messtechnik</span>
|
||||||
|
<span>Abgleich</span>
|
||||||
|
<span>Diagnose</span>
|
||||||
|
<span>Service Manuals</span>
|
||||||
</div>
|
</div>
|
||||||
<div className="hero-actions">
|
<div className="hero-actions">
|
||||||
<Link className="button" href={content.cta.primary.href}>{content.cta.primary.label}</Link>
|
<Link className="button" href="/reparatur">Reparatur anfragen</Link>
|
||||||
<Link className="button secondary" href={content.cta.secondary.href}>{content.cta.secondary.label}</Link>
|
<Link className="button secondary" href="/leistungen">Leistungen ansehen</Link>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|
@ -39,11 +29,13 @@ export default async function HomePage() {
|
||||||
<div className="container">
|
<div className="container">
|
||||||
<div className="section-head">
|
<div className="section-head">
|
||||||
<p className="eyebrow">Leistungsbereiche</p>
|
<p className="eyebrow">Leistungsbereiche</p>
|
||||||
<h2>{content.cta.text}</h2>
|
<h2>Werkstattservice für Funktechnik und Messtechnik</h2>
|
||||||
<p className="lead">{content.intro}</p>
|
<p className="lead">Von der ersten Fehlerbeschreibung bis zur dokumentierten Prüfung: Funktechnik Schubert verbindet technische Erfahrung, geeignete Messmittel und strukturierte Kommunikation.</p>
|
||||||
</div>
|
</div>
|
||||||
<div className="grid three">
|
<div className="grid three">
|
||||||
{content.features.map((feature) => <ServiceCard key={feature.title} title={feature.title}>{feature.text}</ServiceCard>)}
|
<ServiceCard title="Funkgeräte-Service">Sichtprüfung, Funktionsprüfung und technische Beurteilung für CB-Funk, Amateurfunk, Betriebsfunk sowie ältere und moderne Geräte.</ServiceCard>
|
||||||
|
<ServiceCard title="Fehlersuche & Reparatur">Systematische Diagnose bei Empfangsproblemen, Sendeproblemen, PLL-Fehlern, Frequenzabweichungen und Audio-/NF-Störungen.</ServiceCard>
|
||||||
|
<ServiceCard title="Abgleich & Messtechnik">Frequenzabgleich, Hub- und Modulationsprüfung, Sendeleistung, Empfindlichkeit und RF-Messung mit geeigneter Messtechnik.</ServiceCard>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|
@ -51,17 +43,20 @@ export default async function HomePage() {
|
||||||
<section className="section dark">
|
<section className="section dark">
|
||||||
<div className="container split">
|
<div className="container split">
|
||||||
<div>
|
<div>
|
||||||
<p className="eyebrow">{content.promise.eyebrow}</p>
|
<p className="eyebrow">Persönlicher Support</p>
|
||||||
<h2>{content.promise.title}</h2>
|
<h2>Direkte technische Einschätzung statt anonymer Abwicklung</h2>
|
||||||
<p className="lead">{content.promise.text}</p>
|
<p className="lead">Viele Fehlerbilder lassen sich bereits mit einer klaren Beschreibung und den richtigen Gerätedaten eingrenzen. Die Reparaturannahme fragt deshalb die wichtigsten technischen Details strukturiert ab.</p>
|
||||||
<div className="actions">
|
<div className="actions">
|
||||||
<Link className="button" href={content.promise.button.href}>{content.promise.button.label}</Link>
|
<Link className="button" href="/reparatur">Reparaturformular öffnen</Link>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="card">
|
<div className="card">
|
||||||
<h3>{content.promise.title}</h3>
|
<h3>Warum Funktechnik Schubert?</h3>
|
||||||
<ul className="list">
|
<ul className="list">
|
||||||
{content.promise.bullets.map((item) => <li key={item}>{item}</li>)}
|
<li>Technischer Fokus auf Funkgeräte und Kommunikationstechnik</li>
|
||||||
|
<li>Nachvollziehbare Diagnose mit Blick auf Messwerte und Fehlerbild</li>
|
||||||
|
<li>Sauberer Umgang mit Service Manuals, Schaltplänen und Abgleichanleitungen</li>
|
||||||
|
<li>Klare Rückmeldung zu Zustand, Aufwand und sinnvollen nächsten Schritten</li>
|
||||||
</ul>
|
</ul>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
|
|
@ -1,32 +1,27 @@
|
||||||
|
import type { Metadata } from "next";
|
||||||
import PageHero from "@/components/PageHero";
|
import PageHero from "@/components/PageHero";
|
||||||
import RepairForm from "@/components/RepairForm";
|
import RepairForm from "@/components/RepairForm";
|
||||||
import { defaultContent } from "@/lib/content/defaults";
|
import { createMetadata } from "../seo";
|
||||||
import { getContent } from "@/lib/content/service";
|
|
||||||
import { createContentMetadata } from "../seo";
|
|
||||||
|
|
||||||
export const dynamic = "force-dynamic";
|
export const metadata: Metadata = createMetadata("Reparaturannahme", "Reparaturanfrage für Funkgeräte strukturiert vorbereiten.", "/reparatur");
|
||||||
|
|
||||||
export async function generateMetadata() {
|
|
||||||
const content = await getContent("repair");
|
|
||||||
return createContentMetadata(content.seo, defaultContent.repair.seo);
|
|
||||||
}
|
|
||||||
|
|
||||||
export default async function RepairPage() {
|
|
||||||
const content = await getContent("repair");
|
|
||||||
const paragraph = content.paragraphs[0];
|
|
||||||
|
|
||||||
|
export default function RepairPage() {
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<PageHero eyebrow={content.eyebrow} title={content.title}>
|
<PageHero eyebrow="Reparaturannahme" title="Reparatur anfragen">
|
||||||
{content.subtitle}
|
Beschreiben Sie Gerät, Fehlerbild, Zubehör und bisherige Vorarbeiten. Die Anfrage wird strukturiert erfasst und für eine spätere technische Bearbeitung vorbereitet.
|
||||||
</PageHero>
|
</PageHero>
|
||||||
<section className="section">
|
<section className="section">
|
||||||
<div className="container split">
|
<div className="container split">
|
||||||
<div>
|
<div>
|
||||||
<h2>{paragraph?.title || content.cta.text}</h2>
|
<h2>Vor der Einsendung</h2>
|
||||||
<p className="lead">{paragraph?.text || content.intro}</p>
|
<p className="lead">Bitte senden Sie Geräte erst nach Rückmeldung ein. Vollständige Gerätedaten und eine präzise Fehlerbeschreibung helfen, den Aufwand besser einzuschätzen.</p>
|
||||||
<ul className="list">
|
<ul className="list">
|
||||||
{content.list.map((item) => <li key={item}>{item}</li>)}
|
<li>Hersteller, Modell, Geräteart und Seriennummer bereithalten, soweit vorhanden</li>
|
||||||
|
<li>Fehler möglichst konkret beschreiben: Empfang, Sendung, Modulation, Frequenz, Anzeige oder Versorgung</li>
|
||||||
|
<li>Zubehör wie Netzteil, Mikrofon, Antennenadapter oder Kabel angeben</li>
|
||||||
|
<li>Bereits geöffnete Geräte und durchgeführte Arbeiten ehrlich nennen</li>
|
||||||
|
<li>Fotos können später nach Rückmeldung ergänzt werden</li>
|
||||||
</ul>
|
</ul>
|
||||||
</div>
|
</div>
|
||||||
<div className="panel">
|
<div className="panel">
|
||||||
|
|
|
||||||
35
app/seo.ts
35
app/seo.ts
|
|
@ -1,9 +1,8 @@
|
||||||
import type { Metadata } from "next";
|
import type { Metadata } from "next";
|
||||||
import type { SeoContent } from "@/lib/content/types";
|
|
||||||
|
|
||||||
const siteName = "Funktechnik Schubert";
|
const siteName = "Funktechnik Schubert";
|
||||||
const defaultTitle = "Funktechnik Schubert | Funkgeräte-Service & Messtechnik";
|
const defaultTitle = "Funktechnik Schubert | Funkgeräte-Service & Messtechnik";
|
||||||
const siteUrl = process.env.NEXT_PUBLIC_SITE_URL ?? "http://127.0.0.1:3010";
|
const siteUrl = process.env.NEXT_PUBLIC_SITE_URL ?? "http://localhost:3010";
|
||||||
const keywords = [
|
const keywords = [
|
||||||
"Funktechnik Schubert",
|
"Funktechnik Schubert",
|
||||||
"Funkgeräte Reparatur",
|
"Funkgeräte Reparatur",
|
||||||
|
|
@ -60,36 +59,4 @@ export function createMetadata(title: string, description: string, path = "/"):
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
export function createContentMetadata(seo: SeoContent, fallbackSeo?: SeoContent): Metadata {
|
|
||||||
const resolvedSeo = {
|
|
||||||
...fallbackSeo,
|
|
||||||
...Object.fromEntries(Object.entries(seo).filter(([, value]) => value.trim() !== "")),
|
|
||||||
} as SeoContent;
|
|
||||||
const path = resolvedSeo.canonicalUrl || "/";
|
|
||||||
const url = new URL(path, siteUrl).toString();
|
|
||||||
const title = resolvedSeo.metaTitle || siteName;
|
|
||||||
const description = resolvedSeo.metaDescription || defaultTitle;
|
|
||||||
|
|
||||||
return {
|
|
||||||
...createMetadata(title, description, path),
|
|
||||||
keywords: resolvedSeo.keywords ? resolvedSeo.keywords.split(",").map((keyword) => keyword.trim()).filter(Boolean) : keywords,
|
|
||||||
openGraph: {
|
|
||||||
type: "website",
|
|
||||||
locale: "de_DE",
|
|
||||||
url,
|
|
||||||
siteName,
|
|
||||||
title: resolvedSeo.openGraphTitle || title,
|
|
||||||
description: resolvedSeo.openGraphDescription || description,
|
|
||||||
images: [
|
|
||||||
{
|
|
||||||
url: resolvedSeo.socialImage || "/funktechnik_schubert_logo.jpg",
|
|
||||||
width: 1200,
|
|
||||||
height: 630,
|
|
||||||
alt: resolvedSeo.openGraphTitle || title,
|
|
||||||
},
|
|
||||||
],
|
|
||||||
},
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
export { defaultTitle, siteName, siteUrl };
|
export { defaultTitle, siteName, siteUrl };
|
||||||
|
|
|
||||||
|
|
@ -1,268 +0,0 @@
|
||||||
import type { Metadata } from "next";
|
|
||||||
import Link from "next/link";
|
|
||||||
import { EstimateActions } from "@/components/status/EstimateActions";
|
|
||||||
import {
|
|
||||||
fetchOlympusRepairStatus,
|
|
||||||
type OlympusEstimate,
|
|
||||||
type OlympusRepairStatus,
|
|
||||||
} from "@/lib/olympus/status";
|
|
||||||
|
|
||||||
type PageProps = {
|
|
||||||
params: Promise<{
|
|
||||||
token: string;
|
|
||||||
}>;
|
|
||||||
};
|
|
||||||
|
|
||||||
export const dynamic = "force-dynamic";
|
|
||||||
|
|
||||||
export const metadata: Metadata = {
|
|
||||||
title: "Reparaturstatus | Funktechnik Schubert",
|
|
||||||
description: "Öffentlicher Reparaturstatus für Kunden von Funktechnik Schubert.",
|
|
||||||
robots: {
|
|
||||||
index: false,
|
|
||||||
follow: false,
|
|
||||||
},
|
|
||||||
};
|
|
||||||
|
|
||||||
function formatDate(value: string) {
|
|
||||||
return new Intl.DateTimeFormat("de-DE", {
|
|
||||||
dateStyle: "medium",
|
|
||||||
timeStyle: "short",
|
|
||||||
}).format(new Date(value));
|
|
||||||
}
|
|
||||||
|
|
||||||
function formatDateOnly(value: string | null) {
|
|
||||||
if (!value) {
|
|
||||||
return "Nicht angegeben";
|
|
||||||
}
|
|
||||||
|
|
||||||
return new Intl.DateTimeFormat("de-DE", {
|
|
||||||
dateStyle: "medium",
|
|
||||||
}).format(new Date(value));
|
|
||||||
}
|
|
||||||
|
|
||||||
function formatMoney(cents: number, currency: string) {
|
|
||||||
return new Intl.NumberFormat("de-DE", {
|
|
||||||
style: "currency",
|
|
||||||
currency: currency || "EUR",
|
|
||||||
}).format(cents / 100);
|
|
||||||
}
|
|
||||||
|
|
||||||
function estimateStatusLabel(status: string) {
|
|
||||||
const labels: Record<string, string> = {
|
|
||||||
draft: "In Vorbereitung",
|
|
||||||
sent: "Zur Freigabe",
|
|
||||||
approved: "Freigegeben",
|
|
||||||
declined: "Abgelehnt",
|
|
||||||
expired: "Abgelaufen",
|
|
||||||
cancelled: "Storniert",
|
|
||||||
};
|
|
||||||
|
|
||||||
return labels[status] ?? status;
|
|
||||||
}
|
|
||||||
|
|
||||||
function estimateStatusText(status: string) {
|
|
||||||
const texts: Record<string, string> = {
|
|
||||||
sent: "Bitte prüfen Sie den Kostenvoranschlag. Sie können ihn freigeben, ablehnen oder eine Rückfrage senden.",
|
|
||||||
approved: "Dieser Kostenvoranschlag wurde freigegeben.",
|
|
||||||
declined: "Dieser Kostenvoranschlag wurde abgelehnt.",
|
|
||||||
expired: "Dieser Kostenvoranschlag ist nicht mehr gültig.",
|
|
||||||
cancelled: "Dieser Kostenvoranschlag wurde storniert.",
|
|
||||||
};
|
|
||||||
|
|
||||||
return texts[status] ?? "Der Kostenvoranschlag ist aktuell nicht zur Bearbeitung freigegeben.";
|
|
||||||
}
|
|
||||||
|
|
||||||
function estimateItemTypeLabel(type: string) {
|
|
||||||
const labels: Record<string, string> = {
|
|
||||||
labor: "Arbeitszeit",
|
|
||||||
part: "Ersatzteil",
|
|
||||||
material: "Material",
|
|
||||||
service: "Service",
|
|
||||||
shipping: "Versand",
|
|
||||||
other: "Sonstiges",
|
|
||||||
};
|
|
||||||
|
|
||||||
return labels[type] ?? "Position";
|
|
||||||
}
|
|
||||||
|
|
||||||
function EstimateCard({ estimate, token }: { estimate: OlympusEstimate | null | undefined; token: string }) {
|
|
||||||
if (!estimate) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<section className="estimate-card" aria-labelledby="estimate-title">
|
|
||||||
<div className="estimate-header">
|
|
||||||
<div>
|
|
||||||
<p className="eyebrow">Kostenvoranschlag</p>
|
|
||||||
<h2 id="estimate-title">{estimate.title || "Kostenvoranschlag"}</h2>
|
|
||||||
<p className="status-muted">Nummer {estimate.estimate_number}</p>
|
|
||||||
</div>
|
|
||||||
<span className="estimate-status">{estimateStatusLabel(estimate.status)}</span>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{estimate.customer_message && <p className="estimate-message">{estimate.customer_message}</p>}
|
|
||||||
|
|
||||||
<dl className="estimate-meta">
|
|
||||||
<div>
|
|
||||||
<dt>Gültig bis</dt>
|
|
||||||
<dd>{formatDateOnly(estimate.valid_until)}</dd>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<dt>Status</dt>
|
|
||||||
<dd>{estimateStatusLabel(estimate.status)}</dd>
|
|
||||||
</div>
|
|
||||||
</dl>
|
|
||||||
|
|
||||||
<div className="estimate-items" aria-label="Positionen des Kostenvoranschlags">
|
|
||||||
{estimate.items.map((item, index) => (
|
|
||||||
<div className="estimate-item" key={`${item.title}-${index}`}>
|
|
||||||
<div>
|
|
||||||
<span className="estimate-item-type">{estimateItemTypeLabel(item.item_type)}</span>
|
|
||||||
<h3>{item.title}</h3>
|
|
||||||
{item.description && <p>{item.description}</p>}
|
|
||||||
</div>
|
|
||||||
<div className="estimate-item-price">
|
|
||||||
<span>
|
|
||||||
{item.quantity} {item.unit}
|
|
||||||
</span>
|
|
||||||
<strong>{formatMoney(item.total_cents, estimate.currency)}</strong>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<dl className="estimate-totals">
|
|
||||||
<div>
|
|
||||||
<dt>Zwischensumme</dt>
|
|
||||||
<dd>{formatMoney(estimate.subtotal_cents, estimate.currency)}</dd>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<dt>Mehrwertsteuer</dt>
|
|
||||||
<dd>{formatMoney(estimate.tax_cents, estimate.currency)}</dd>
|
|
||||||
</div>
|
|
||||||
<div className="estimate-total-highlight">
|
|
||||||
<dt>Gesamt</dt>
|
|
||||||
<dd>{formatMoney(estimate.total_cents, estimate.currency)}</dd>
|
|
||||||
</div>
|
|
||||||
</dl>
|
|
||||||
|
|
||||||
<p className="estimate-status-text">{estimateStatusText(estimate.status)}</p>
|
|
||||||
|
|
||||||
{estimate.status === "sent" && <EstimateActions token={token} />}
|
|
||||||
</section>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function StatusError({ title, text }: { title: string; text: string }) {
|
|
||||||
return (
|
|
||||||
<section className="status-page">
|
|
||||||
<div className="container status-page-inner">
|
|
||||||
<div className="status-card status-card-narrow">
|
|
||||||
<p className="eyebrow">Reparaturstatus</p>
|
|
||||||
<h1>{title}</h1>
|
|
||||||
<p className="lead">{text}</p>
|
|
||||||
<div className="actions">
|
|
||||||
<Link className="button" href="/reparatur">Reparatur anfragen</Link>
|
|
||||||
<Link className="button light" href="/kontakt">Kontakt aufnehmen</Link>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</section>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function StatusTimeline({ status }: { status: OlympusRepairStatus }) {
|
|
||||||
if (status.status_history_public.length === 0) {
|
|
||||||
return <p className="status-muted">Noch keine Statushistorie vorhanden.</p>;
|
|
||||||
}
|
|
||||||
|
|
||||||
const latestIndex = status.status_history_public.length - 1;
|
|
||||||
|
|
||||||
return (
|
|
||||||
<ol className="public-status-timeline">
|
|
||||||
{status.status_history_public.map((item, index) => (
|
|
||||||
<li key={`${item.status}-${item.created_at}-${index}`} className={index === latestIndex ? "is-current" : ""}>
|
|
||||||
<span className="timeline-dot" aria-hidden="true" />
|
|
||||||
<div className="timeline-content">
|
|
||||||
<div>
|
|
||||||
<p className="timeline-title">{item.status_label}</p>
|
|
||||||
{index === latestIndex && <span className="status-pill">Aktueller Status</span>}
|
|
||||||
</div>
|
|
||||||
<time dateTime={item.created_at}>{formatDate(item.created_at)}</time>
|
|
||||||
</div>
|
|
||||||
</li>
|
|
||||||
))}
|
|
||||||
</ol>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
export default async function RepairStatusPage({ params }: PageProps) {
|
|
||||||
const { token } = await params;
|
|
||||||
const result = await fetchOlympusRepairStatus(token);
|
|
||||||
|
|
||||||
if (!result.ok && result.reason === "invalid") {
|
|
||||||
return (
|
|
||||||
<StatusError
|
|
||||||
title="Statuslink nicht aktiv"
|
|
||||||
text="Dieser Statuslink ist ungültig oder nicht mehr aktiv."
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!result.ok) {
|
|
||||||
return (
|
|
||||||
<StatusError
|
|
||||||
title="Status momentan nicht abrufbar"
|
|
||||||
text="Der Reparaturstatus ist momentan nicht abrufbar. Bitte versuchen Sie es später erneut oder kontaktieren Sie uns direkt."
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
const status = result.status;
|
|
||||||
const device = `${status.device_manufacturer} ${status.device_model}`.trim();
|
|
||||||
|
|
||||||
return (
|
|
||||||
<section className="status-page">
|
|
||||||
<div className="container status-page-inner">
|
|
||||||
<div className="status-card">
|
|
||||||
<p className="eyebrow">Reparaturstatus</p>
|
|
||||||
<div className="status-title-row">
|
|
||||||
<div>
|
|
||||||
<h1>Reparaturstatus</h1>
|
|
||||||
<p className="lead">Hier sehen Sie den aktuellen Stand Ihrer Reparatur.</p>
|
|
||||||
</div>
|
|
||||||
<span className="status-badge">{status.public_status_label}</span>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<dl className="status-summary">
|
|
||||||
<div>
|
|
||||||
<dt>Reparaturnummer</dt>
|
|
||||||
<dd>{status.repair_number}</dd>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<dt>Gerät</dt>
|
|
||||||
<dd>{device || "Nicht angegeben"}</dd>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<dt>Aktueller Status</dt>
|
|
||||||
<dd>{status.public_status_label}</dd>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<dt>Letzte Aktualisierung</dt>
|
|
||||||
<dd>{formatDate(status.updated_at)}</dd>
|
|
||||||
</div>
|
|
||||||
</dl>
|
|
||||||
|
|
||||||
<EstimateCard estimate={status.estimate} token={token} />
|
|
||||||
|
|
||||||
<div className="status-timeline-section">
|
|
||||||
<h2>Statusverlauf</h2>
|
|
||||||
<StatusTimeline status={status} />
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</section>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
@ -1,35 +1,25 @@
|
||||||
|
import type { Metadata } from "next";
|
||||||
import PageHero from "@/components/PageHero";
|
import PageHero from "@/components/PageHero";
|
||||||
import { defaultContent } from "@/lib/content/defaults";
|
import { createMetadata } from "../seo";
|
||||||
import { getContent } from "@/lib/content/service";
|
|
||||||
import { createContentMetadata } from "../seo";
|
|
||||||
|
|
||||||
export const dynamic = "force-dynamic";
|
export const metadata: Metadata = createMetadata("Über uns", "Funktechnik Schubert steht für technischen Service und klare Kommunikation.", "/ueber-uns");
|
||||||
|
|
||||||
export async function generateMetadata() {
|
|
||||||
const content = await getContent("about");
|
|
||||||
return createContentMetadata(content.seo, defaultContent.about.seo);
|
|
||||||
}
|
|
||||||
|
|
||||||
export default async function AboutPage() {
|
|
||||||
const content = await getContent("about");
|
|
||||||
const blocks = content.paragraphs.length > 0 ? content.paragraphs : [
|
|
||||||
{ title: content.companyDescription, text: content.workshopDescription },
|
|
||||||
{ title: content.philosophy, text: content.intro },
|
|
||||||
];
|
|
||||||
|
|
||||||
|
export default function AboutPage() {
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<PageHero eyebrow={content.eyebrow} title={content.title}>
|
<PageHero eyebrow="Über uns" title="Technik verstehen, Fehler nachvollziehbar lösen">
|
||||||
{content.subtitle}
|
Funktechnik Schubert richtet sich an Kunden, die bei Funkgeräten und Kommunikationstechnik eine persönliche, technische Einschätzung suchen.
|
||||||
</PageHero>
|
</PageHero>
|
||||||
<section className="section">
|
<section className="section">
|
||||||
<div className="container grid two">
|
<div className="container grid two">
|
||||||
{blocks.map((block) => (
|
<article className="card">
|
||||||
<article key={block.title} className="card">
|
<h2>Arbeitsweise</h2>
|
||||||
<h2>{block.title}</h2>
|
<p>Im Mittelpunkt stehen saubere Diagnose, transparente Kommunikation und der respektvolle Umgang mit bestehenden Geräten und Serviceunterlagen.</p>
|
||||||
<p>{block.text}</p>
|
</article>
|
||||||
</article>
|
<article className="card">
|
||||||
))}
|
<h2>Fokus</h2>
|
||||||
|
<p>Der Schwerpunkt liegt auf Funktechnik, Elektronikservice, Abgleichfragen und technischer Unterstützung rund um Kommunikationstechnik.</p>
|
||||||
|
</article>
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
</>
|
</>
|
||||||
|
|
|
||||||
|
|
@ -1,9 +1,8 @@
|
||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { useRef, useState, type FormEvent } from "react";
|
import { useState, type FormEvent } from "react";
|
||||||
|
|
||||||
export default function ContactForm() {
|
export default function ContactForm() {
|
||||||
const formRef = useRef<HTMLFormElement>(null);
|
|
||||||
const [message, setMessage] = useState("");
|
const [message, setMessage] = useState("");
|
||||||
const [error, setError] = useState("");
|
const [error, setError] = useState("");
|
||||||
const [pending, setPending] = useState(false);
|
const [pending, setPending] = useState(false);
|
||||||
|
|
@ -15,11 +14,9 @@ export default function ContactForm() {
|
||||||
const form = new FormData(event.currentTarget);
|
const form = new FormData(event.currentTarget);
|
||||||
const name = String(form.get("name") ?? "").trim();
|
const name = String(form.get("name") ?? "").trim();
|
||||||
const email = String(form.get("email") ?? "").trim();
|
const email = String(form.get("email") ?? "").trim();
|
||||||
const subject = String(form.get("subject") ?? "").trim();
|
|
||||||
const text = String(form.get("message") ?? "").trim();
|
const text = String(form.get("message") ?? "").trim();
|
||||||
const privacyAccepted = form.get("privacyAccepted") === "on";
|
|
||||||
|
|
||||||
if (!name || !email.includes("@") || !subject || text.length < 10 || !privacyAccepted) {
|
if (!name || !email.includes("@") || text.length < 10 || form.get("privacy") !== "on") {
|
||||||
setError("Bitte füllen Sie alle Pflichtfelder aus und bestätigen Sie die Datenschutz-Hinweise.");
|
setError("Bitte füllen Sie alle Pflichtfelder aus und bestätigen Sie die Datenschutz-Hinweise.");
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
@ -29,17 +26,17 @@ export default function ContactForm() {
|
||||||
const response = await fetch("/api/contact", { method: "POST", body: form });
|
const response = await fetch("/api/contact", { method: "POST", body: form });
|
||||||
const result = await response.json() as { message?: string };
|
const result = await response.json() as { message?: string };
|
||||||
if (!response.ok) throw new Error(result.message ?? "Nachricht konnte nicht gesendet werden.");
|
if (!response.ok) throw new Error(result.message ?? "Nachricht konnte nicht gesendet werden.");
|
||||||
formRef.current?.reset();
|
event.currentTarget.reset();
|
||||||
setMessage(result.message ?? "Ihre Nachricht wurde vorbereitet.");
|
setMessage(result.message ?? "Ihre Nachricht wurde vorbereitet.");
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
setError(err instanceof Error ? err.message : "Nachricht konnte nicht gesendet werden. Bitte versuchen Sie es später erneut.");
|
setError(err instanceof Error ? err.message : "Nachricht konnte nicht gesendet werden.");
|
||||||
} finally {
|
} finally {
|
||||||
setPending(false);
|
setPending(false);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<form ref={formRef} className="form" onSubmit={submit} noValidate>
|
<form className="form" onSubmit={submit} noValidate>
|
||||||
<div className="field">
|
<div className="field">
|
||||||
<label htmlFor="name">Name</label>
|
<label htmlFor="name">Name</label>
|
||||||
<input id="name" name="name" />
|
<input id="name" name="name" />
|
||||||
|
|
@ -48,20 +45,12 @@ export default function ContactForm() {
|
||||||
<label htmlFor="email">E-Mail</label>
|
<label htmlFor="email">E-Mail</label>
|
||||||
<input id="email" name="email" type="email" />
|
<input id="email" name="email" type="email" />
|
||||||
</div>
|
</div>
|
||||||
<div className="field">
|
|
||||||
<label htmlFor="phone">Telefon</label>
|
|
||||||
<input id="phone" name="phone" />
|
|
||||||
</div>
|
|
||||||
<div className="field">
|
|
||||||
<label htmlFor="subject">Betreff</label>
|
|
||||||
<input id="subject" name="subject" />
|
|
||||||
</div>
|
|
||||||
<div className="field">
|
<div className="field">
|
||||||
<label htmlFor="message">Nachricht</label>
|
<label htmlFor="message">Nachricht</label>
|
||||||
<textarea id="message" name="message" />
|
<textarea id="message" name="message" />
|
||||||
</div>
|
</div>
|
||||||
<label className="checkbox">
|
<label className="checkbox">
|
||||||
<input type="checkbox" name="privacyAccepted" />
|
<input type="checkbox" name="privacy" />
|
||||||
<span>Ich stimme zu, dass meine Angaben zur Bearbeitung der Kontaktanfrage verarbeitet werden.</span>
|
<span>Ich stimme zu, dass meine Angaben zur Bearbeitung der Kontaktanfrage verarbeitet werden.</span>
|
||||||
</label>
|
</label>
|
||||||
{error && <p className="error">{error}</p>}
|
{error && <p className="error">{error}</p>}
|
||||||
|
|
|
||||||
|
|
@ -1,82 +1,32 @@
|
||||||
"use client";
|
|
||||||
|
|
||||||
import Image from "next/image";
|
import Image from "next/image";
|
||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
import { useEffect, useState } from "react";
|
|
||||||
import { appVersion } from "@/lib/runtime/version";
|
|
||||||
import type { SettingsContent } from "@/lib/content/types";
|
|
||||||
|
|
||||||
const fallbackSettings: SettingsContent = {
|
|
||||||
siteName: "Funktechnik Schubert",
|
|
||||||
logoUrl: "/funktechnik_schubert_logo.jpg",
|
|
||||||
footerShortText: "Service und technische Unterstützung für Funkgeräte, Messtechnik und Kommunikationstechnik.",
|
|
||||||
copyright: "© Funktechnik Schubert",
|
|
||||||
footerLinks: [
|
|
||||||
{ label: "Leistungen", href: "/leistungen" },
|
|
||||||
{ label: "Funkgeräte-Service", href: "/funkgeraete-service" },
|
|
||||||
{ label: "Reparatur", href: "/reparatur" },
|
|
||||||
{ label: "Kontakt", href: "/kontakt" },
|
|
||||||
],
|
|
||||||
legalLinks: [
|
|
||||||
{ label: "Impressum", href: "/impressum" },
|
|
||||||
{ label: "Datenschutz", href: "/datenschutz" },
|
|
||||||
],
|
|
||||||
headerCta: { label: "Reparatur anfragen", href: "/reparatur" },
|
|
||||||
seo: {
|
|
||||||
metaTitle: "Funktechnik Schubert",
|
|
||||||
metaDescription: "",
|
|
||||||
keywords: "",
|
|
||||||
openGraphTitle: "",
|
|
||||||
openGraphDescription: "",
|
|
||||||
socialImage: "/funktechnik_schubert_logo.jpg",
|
|
||||||
canonicalUrl: "/",
|
|
||||||
},
|
|
||||||
updatedAt: "",
|
|
||||||
};
|
|
||||||
|
|
||||||
export default function Footer() {
|
export default function Footer() {
|
||||||
const [settings, setSettings] = useState(fallbackSettings);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
let active = true;
|
|
||||||
|
|
||||||
async function loadSettings() {
|
|
||||||
try {
|
|
||||||
const response = await fetch("/api/content/settings");
|
|
||||||
const result = await response.json() as { settings?: SettingsContent };
|
|
||||||
if (active && response.ok && result.settings) setSettings(result.settings);
|
|
||||||
} catch {
|
|
||||||
if (active) setSettings(fallbackSettings);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
void loadSettings();
|
|
||||||
return () => {
|
|
||||||
active = false;
|
|
||||||
};
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<footer className="footer">
|
<footer className="footer">
|
||||||
<div className="container footer-grid">
|
<div className="container footer-grid">
|
||||||
<div>
|
<div>
|
||||||
<Image
|
<Image
|
||||||
src={settings.logoUrl || fallbackSettings.logoUrl}
|
src="/funktechnik_schubert_logo.jpg"
|
||||||
alt={settings.siteName}
|
alt="Funktechnik Schubert"
|
||||||
width={1672}
|
width={1672}
|
||||||
height={941}
|
height={941}
|
||||||
className="footer-logo"
|
className="footer-logo"
|
||||||
/>
|
/>
|
||||||
<p>{settings.footerShortText}</p>
|
<p>Funktechnik Schubert – Service und technische Unterstützung für Funkgeräte, Messtechnik und Kommunikationstechnik.</p>
|
||||||
<p className="copyright">{settings.copyright} · v{appVersion}</p>
|
<p className="copyright">© Funktechnik Schubert</p>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<h3>Website</h3>
|
<h3>Website</h3>
|
||||||
{settings.footerLinks.map((link) => <p key={`${link.href}-${link.label}`}><Link href={link.href}>{link.label}</Link></p>)}
|
<p><Link href="/leistungen">Leistungen</Link></p>
|
||||||
|
<p><Link href="/funkgeraete-service">Funkgeräte-Service</Link></p>
|
||||||
|
<p><Link href="/reparatur">Reparatur</Link></p>
|
||||||
|
<p><Link href="/kontakt">Kontakt</Link></p>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<h3>Rechtliches</h3>
|
<h3>Rechtliches</h3>
|
||||||
{settings.legalLinks.map((link) => <p key={`${link.href}-${link.label}`}><Link href={link.href}>{link.label}</Link></p>)}
|
<p><Link href="/impressum">Impressum</Link></p>
|
||||||
|
<p><Link href="/datenschutz">Datenschutz</Link></p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</footer>
|
</footer>
|
||||||
|
|
|
||||||
|
|
@ -3,8 +3,7 @@
|
||||||
import Image from "next/image";
|
import Image from "next/image";
|
||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
import { usePathname } from "next/navigation";
|
import { usePathname } from "next/navigation";
|
||||||
import { useEffect, useState } from "react";
|
import { useState } from "react";
|
||||||
import type { SettingsContent } from "@/lib/content/types";
|
|
||||||
|
|
||||||
const navigation = [
|
const navigation = [
|
||||||
["Start", "/"],
|
["Start", "/"],
|
||||||
|
|
@ -15,49 +14,17 @@ const navigation = [
|
||||||
["Kontakt", "/kontakt"],
|
["Kontakt", "/kontakt"],
|
||||||
] as const;
|
] as const;
|
||||||
|
|
||||||
const fallbackSettings = {
|
|
||||||
siteName: "Funktechnik Schubert",
|
|
||||||
logoUrl: "/funktechnik_schubert_logo.jpg",
|
|
||||||
headerCta: { label: "Reparatur anfragen", href: "/reparatur" },
|
|
||||||
};
|
|
||||||
|
|
||||||
export default function Header() {
|
export default function Header() {
|
||||||
const pathname = usePathname();
|
const pathname = usePathname();
|
||||||
const [menuOpen, setMenuOpen] = useState(false);
|
const [menuOpen, setMenuOpen] = useState(false);
|
||||||
const [settings, setSettings] = useState(fallbackSettings);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
let active = true;
|
|
||||||
|
|
||||||
async function loadSettings() {
|
|
||||||
try {
|
|
||||||
const response = await fetch("/api/content/settings");
|
|
||||||
const result = await response.json() as { settings?: SettingsContent };
|
|
||||||
if (active && response.ok && result.settings) {
|
|
||||||
setSettings({
|
|
||||||
siteName: result.settings.siteName,
|
|
||||||
logoUrl: result.settings.logoUrl || fallbackSettings.logoUrl,
|
|
||||||
headerCta: result.settings.headerCta,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
} catch {
|
|
||||||
if (active) setSettings(fallbackSettings);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
void loadSettings();
|
|
||||||
return () => {
|
|
||||||
active = false;
|
|
||||||
};
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<header className="site-header">
|
<header className="site-header">
|
||||||
<div className="container header-inner">
|
<div className="container header-inner">
|
||||||
<Link href="/" className="brand" aria-label={`${settings.siteName} Startseite`} onClick={() => setMenuOpen(false)}>
|
<Link href="/" className="brand" aria-label="Funktechnik Schubert Startseite" onClick={() => setMenuOpen(false)}>
|
||||||
<Image
|
<Image
|
||||||
src={settings.logoUrl}
|
src="/funktechnik_schubert_logo.jpg"
|
||||||
alt={settings.siteName}
|
alt="Funktechnik Schubert"
|
||||||
width={1672}
|
width={1672}
|
||||||
height={941}
|
height={941}
|
||||||
priority
|
priority
|
||||||
|
|
@ -80,7 +47,7 @@ export default function Header() {
|
||||||
</Link>
|
</Link>
|
||||||
))}
|
))}
|
||||||
</nav>
|
</nav>
|
||||||
<Link className="header-cta" href={settings.headerCta.href}>{settings.headerCta.label}</Link>
|
<Link className="header-cta" href="/reparatur">Reparatur anfragen</Link>
|
||||||
</div>
|
</div>
|
||||||
</header>
|
</header>
|
||||||
);
|
);
|
||||||
|
|
|
||||||
|
|
@ -1,21 +1,18 @@
|
||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { useRef, useState, type FormEvent } from "react";
|
import { useState, type FormEvent } from "react";
|
||||||
|
|
||||||
type ErrorField = "name" | "email" | "manufacturer" | "model" | "deviceType" | "description" | "privacyAccepted";
|
type ErrorField = "name" | "email" | "manufacturer" | "model" | "deviceType" | "description" | "privacy";
|
||||||
type Errors = Partial<Record<ErrorField, string>>;
|
type Errors = Partial<Record<ErrorField, string>>;
|
||||||
|
|
||||||
export default function RepairForm() {
|
export default function RepairForm() {
|
||||||
const formRef = useRef<HTMLFormElement>(null);
|
|
||||||
const [errors, setErrors] = useState<Errors>({});
|
const [errors, setErrors] = useState<Errors>({});
|
||||||
const [status, setStatus] = useState("");
|
const [status, setStatus] = useState("");
|
||||||
const [submitError, setSubmitError] = useState("");
|
|
||||||
const [pending, setPending] = useState(false);
|
const [pending, setPending] = useState(false);
|
||||||
|
|
||||||
async function submit(event: FormEvent<HTMLFormElement>) {
|
async function submit(event: FormEvent<HTMLFormElement>) {
|
||||||
event.preventDefault();
|
event.preventDefault();
|
||||||
setStatus("");
|
setStatus("");
|
||||||
setSubmitError("");
|
|
||||||
const form = new FormData(event.currentTarget);
|
const form = new FormData(event.currentTarget);
|
||||||
const nextErrors: Errors = {};
|
const nextErrors: Errors = {};
|
||||||
|
|
||||||
|
|
@ -25,7 +22,7 @@ export default function RepairForm() {
|
||||||
if (!String(form.get("model") ?? "").trim()) nextErrors.model = "Bitte nennen Sie das Modell.";
|
if (!String(form.get("model") ?? "").trim()) nextErrors.model = "Bitte nennen Sie das Modell.";
|
||||||
if (!String(form.get("deviceType") ?? "").trim()) nextErrors.deviceType = "Bitte wählen Sie die Geräteart aus.";
|
if (!String(form.get("deviceType") ?? "").trim()) nextErrors.deviceType = "Bitte wählen Sie die Geräteart aus.";
|
||||||
if (String(form.get("description") ?? "").trim().length < 20) nextErrors.description = "Bitte beschreiben Sie den Fehler mit mindestens 20 Zeichen.";
|
if (String(form.get("description") ?? "").trim().length < 20) nextErrors.description = "Bitte beschreiben Sie den Fehler mit mindestens 20 Zeichen.";
|
||||||
if (form.get("privacyAccepted") !== "on") nextErrors.privacyAccepted = "Bitte stimmen Sie der Verarbeitung Ihrer Angaben zu.";
|
if (form.get("privacy") !== "on") nextErrors.privacy = "Bitte stimmen Sie der Verarbeitung Ihrer Angaben zu.";
|
||||||
|
|
||||||
setErrors(nextErrors);
|
setErrors(nextErrors);
|
||||||
if (Object.keys(nextErrors).length > 0) return;
|
if (Object.keys(nextErrors).length > 0) return;
|
||||||
|
|
@ -38,18 +35,17 @@ export default function RepairForm() {
|
||||||
});
|
});
|
||||||
const result = await response.json() as { message?: string };
|
const result = await response.json() as { message?: string };
|
||||||
if (!response.ok) throw new Error(result.message ?? "Anfrage konnte nicht gesendet werden.");
|
if (!response.ok) throw new Error(result.message ?? "Anfrage konnte nicht gesendet werden.");
|
||||||
formRef.current?.reset();
|
event.currentTarget.reset();
|
||||||
setErrors({});
|
|
||||||
setStatus(result.message ?? "Ihre Reparaturanfrage wurde vorbereitet.");
|
setStatus(result.message ?? "Ihre Reparaturanfrage wurde vorbereitet.");
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
setSubmitError(error instanceof Error ? error.message : "Anfrage konnte nicht gesendet werden. Bitte versuchen Sie es später erneut.");
|
setStatus(error instanceof Error ? error.message : "Anfrage konnte nicht gesendet werden.");
|
||||||
} finally {
|
} finally {
|
||||||
setPending(false);
|
setPending(false);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<form ref={formRef} className="form" onSubmit={submit} noValidate>
|
<form className="form" onSubmit={submit} noValidate>
|
||||||
<div className="grid two">
|
<div className="grid two">
|
||||||
<Field label="Name" name="name" error={errors.name} />
|
<Field label="Name" name="name" error={errors.name} />
|
||||||
<Field label="E-Mail" name="email" type="email" error={errors.email} />
|
<Field label="E-Mail" name="email" type="email" error={errors.email} />
|
||||||
|
|
@ -94,11 +90,10 @@ export default function RepairForm() {
|
||||||
</div>
|
</div>
|
||||||
<p className="notice">Fotos vom Gerät, Typenschild oder Innenleben können später nach Rückmeldung ergänzt werden. Bitte senden Sie aktuell noch keine Dateien unaufgefordert.</p>
|
<p className="notice">Fotos vom Gerät, Typenschild oder Innenleben können später nach Rückmeldung ergänzt werden. Bitte senden Sie aktuell noch keine Dateien unaufgefordert.</p>
|
||||||
<label className="checkbox">
|
<label className="checkbox">
|
||||||
<input type="checkbox" name="privacyAccepted" />
|
<input type="checkbox" name="privacy" />
|
||||||
<span>Ich stimme zu, dass meine Angaben zur Bearbeitung der Reparaturanfrage verarbeitet werden.</span>
|
<span>Ich stimme zu, dass meine Angaben zur Bearbeitung der Reparaturanfrage verarbeitet werden.</span>
|
||||||
</label>
|
</label>
|
||||||
{errors.privacyAccepted && <span className="error">{errors.privacyAccepted}</span>}
|
{errors.privacy && <span className="error">{errors.privacy}</span>}
|
||||||
{submitError && <p className="error">{submitError}</p>}
|
|
||||||
{status && <p className="success">{status}</p>}
|
{status && <p className="success">{status}</p>}
|
||||||
<button className="button" type="submit" disabled={pending}>{pending ? "Wird gesendet..." : "Reparatur anfragen"}</button>
|
<button className="button" type="submit" disabled={pending}>{pending ? "Wird gesendet..." : "Reparatur anfragen"}</button>
|
||||||
</form>
|
</form>
|
||||||
|
|
|
||||||
|
|
@ -1,21 +0,0 @@
|
||||||
"use client";
|
|
||||||
|
|
||||||
import { usePathname } from "next/navigation";
|
|
||||||
import type { ReactNode } from "react";
|
|
||||||
import Header from "@/components/Header";
|
|
||||||
import Footer from "@/components/Footer";
|
|
||||||
|
|
||||||
export default function SiteFrame({ children }: { children: ReactNode }) {
|
|
||||||
const pathname = usePathname();
|
|
||||||
const isAdmin = pathname.startsWith("/admin");
|
|
||||||
|
|
||||||
if (isAdmin) return <>{children}</>;
|
|
||||||
|
|
||||||
return (
|
|
||||||
<>
|
|
||||||
<Header />
|
|
||||||
<main>{children}</main>
|
|
||||||
<Footer />
|
|
||||||
</>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
@ -1,55 +0,0 @@
|
||||||
"use client";
|
|
||||||
|
|
||||||
import Image from "next/image";
|
|
||||||
import { useRouter } from "next/navigation";
|
|
||||||
import { useState, type FormEvent } from "react";
|
|
||||||
|
|
||||||
export default function AdminLoginForm({ nextPath = "/admin", adminConfigured = true }: { nextPath?: string; adminConfigured?: boolean }) {
|
|
||||||
const router = useRouter();
|
|
||||||
const [error, setError] = useState("");
|
|
||||||
const [pending, setPending] = useState(false);
|
|
||||||
|
|
||||||
async function submit(event: FormEvent<HTMLFormElement>) {
|
|
||||||
event.preventDefault();
|
|
||||||
setError("");
|
|
||||||
setPending(true);
|
|
||||||
try {
|
|
||||||
const response = await fetch("/api/admin/login", {
|
|
||||||
method: "POST",
|
|
||||||
body: new FormData(event.currentTarget),
|
|
||||||
});
|
|
||||||
const result = await response.json() as { message?: string };
|
|
||||||
if (!response.ok) throw new Error(result.message ?? "Anmeldung fehlgeschlagen.");
|
|
||||||
router.push(nextPath);
|
|
||||||
router.refresh();
|
|
||||||
} catch (err) {
|
|
||||||
setError(err instanceof Error ? err.message : "Anmeldung fehlgeschlagen.");
|
|
||||||
} finally {
|
|
||||||
setPending(false);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<main className="admin-login-page">
|
|
||||||
<form className="admin-login-card" onSubmit={submit}>
|
|
||||||
<Image src="/funktechnik_schubert_logo.jpg" alt="Funktechnik Schubert" width={1672} height={941} priority />
|
|
||||||
<h1>Administration Login</h1>
|
|
||||||
{!adminConfigured && (
|
|
||||||
<p className="error">
|
|
||||||
Admin-Zugang ist nicht konfiguriert. Bitte ADMIN_EMAIL, ADMIN_PASSWORD und ADMIN_SESSION_SECRET in der Umgebung setzen.
|
|
||||||
</p>
|
|
||||||
)}
|
|
||||||
<label>
|
|
||||||
E-Mail
|
|
||||||
<input name="email" type="email" autoComplete="username" required />
|
|
||||||
</label>
|
|
||||||
<label>
|
|
||||||
Passwort
|
|
||||||
<input name="password" type="password" autoComplete="current-password" required />
|
|
||||||
</label>
|
|
||||||
{error && <p className="error">{error}</p>}
|
|
||||||
<button className="button" type="submit" disabled={pending || !adminConfigured}>{pending ? "Anmeldung..." : "Anmelden"}</button>
|
|
||||||
</form>
|
|
||||||
</main>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
@ -1,50 +0,0 @@
|
||||||
"use client";
|
|
||||||
|
|
||||||
import Image from "next/image";
|
|
||||||
import Link from "next/link";
|
|
||||||
import { usePathname, useRouter } from "next/navigation";
|
|
||||||
import type { ReactNode } from "react";
|
|
||||||
import { appVersion } from "@/lib/runtime/version";
|
|
||||||
|
|
||||||
const navItems = [
|
|
||||||
["Dashboard", "/admin"],
|
|
||||||
["Kontaktanfragen", "/admin/kontaktanfragen"],
|
|
||||||
["Reparaturanfragen", "/admin/reparaturanfragen"],
|
|
||||||
["Website-Inhalte", "/admin/website"],
|
|
||||||
["Medien", "/admin/medien"],
|
|
||||||
["Einstellungen", "/admin/einstellungen"],
|
|
||||||
["SMTP", "/admin/smtp"],
|
|
||||||
["SEO", "/admin/seo"],
|
|
||||||
["System", "/admin/system"],
|
|
||||||
] as const;
|
|
||||||
|
|
||||||
export default function AdminShell({ children }: { children: ReactNode }) {
|
|
||||||
const pathname = usePathname();
|
|
||||||
const router = useRouter();
|
|
||||||
|
|
||||||
async function logout() {
|
|
||||||
await fetch("/api/admin/logout", { method: "POST" });
|
|
||||||
router.push("/admin/login");
|
|
||||||
router.refresh();
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="admin-shell">
|
|
||||||
<aside className="admin-sidebar">
|
|
||||||
<Link className="admin-logo" href="/admin">
|
|
||||||
<Image src="/funktechnik_schubert_logo.jpg" alt="Funktechnik Schubert" width={1672} height={941} />
|
|
||||||
</Link>
|
|
||||||
<nav className="admin-nav" aria-label="Administration">
|
|
||||||
{navItems.map(([label, href]) => (
|
|
||||||
<Link key={href} href={href} aria-current={pathname === href ? "page" : undefined}>
|
|
||||||
{label}
|
|
||||||
</Link>
|
|
||||||
))}
|
|
||||||
</nav>
|
|
||||||
<p className="admin-version">v{appVersion}</p>
|
|
||||||
<button className="admin-logout" type="button" onClick={logout}>Abmelden</button>
|
|
||||||
</aside>
|
|
||||||
<div className="admin-main">{children}</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
@ -1,387 +0,0 @@
|
||||||
"use client";
|
|
||||||
|
|
||||||
/* eslint-disable @next/next/no-img-element */
|
|
||||||
|
|
||||||
import { useMemo, useState } from "react";
|
|
||||||
import type { MediaFile } from "@/lib/admin/media";
|
|
||||||
import type { ContentDocuments, ContentKey, LinkContent, PageContent, SeoContent, ServiceContent, TextBlockContent } from "@/lib/content/types";
|
|
||||||
|
|
||||||
type Viewport = "desktop" | "tablet" | "mobile";
|
|
||||||
type ContentTabId = "home" | "services" | "radio-service" | "about" | "contact" | "footer" | "seo";
|
|
||||||
type SeoPageKey = Exclude<ContentKey, "settings">;
|
|
||||||
|
|
||||||
const tabs: Array<{ id: ContentTabId; label: string }> = [
|
|
||||||
{ id: "home", label: "Startseite" },
|
|
||||||
{ id: "services", label: "Leistungen" },
|
|
||||||
{ id: "radio-service", label: "Funkgeräte-Service" },
|
|
||||||
{ id: "about", label: "Über uns" },
|
|
||||||
{ id: "contact", label: "Kontakt" },
|
|
||||||
{ id: "footer", label: "Footer" },
|
|
||||||
{ id: "seo", label: "SEO" },
|
|
||||||
];
|
|
||||||
|
|
||||||
const seoPages: Array<{ key: SeoPageKey; label: string }> = [
|
|
||||||
{ key: "home", label: "Startseite" },
|
|
||||||
{ key: "services", label: "Leistungen" },
|
|
||||||
{ key: "radio-service", label: "Funkgeräte-Service" },
|
|
||||||
{ key: "repair", label: "Reparatur" },
|
|
||||||
{ key: "about", label: "Über uns" },
|
|
||||||
{ key: "contact", label: "Kontakt" },
|
|
||||||
];
|
|
||||||
|
|
||||||
type Props = {
|
|
||||||
initialContent: ContentDocuments;
|
|
||||||
mediaFiles: MediaFile[];
|
|
||||||
};
|
|
||||||
|
|
||||||
type ApiResponse<K extends ContentKey> = {
|
|
||||||
message?: string;
|
|
||||||
content?: ContentDocuments[K];
|
|
||||||
};
|
|
||||||
|
|
||||||
function contentKeyForTab(tab: ContentTabId, seoPage: SeoPageKey): ContentKey {
|
|
||||||
if (tab === "footer") return "settings";
|
|
||||||
if (tab === "seo") return seoPage;
|
|
||||||
return tab;
|
|
||||||
}
|
|
||||||
|
|
||||||
function linesToText(lines: string[]) {
|
|
||||||
return lines.join("\n");
|
|
||||||
}
|
|
||||||
|
|
||||||
function textToLines(value: string) {
|
|
||||||
return value.split("\n").map((line) => line.trim()).filter(Boolean);
|
|
||||||
}
|
|
||||||
|
|
||||||
function updateLink(link: LinkContent, field: keyof LinkContent, value: string): LinkContent {
|
|
||||||
return { ...link, [field]: value };
|
|
||||||
}
|
|
||||||
|
|
||||||
function TextInput({ label, value, onChange }: { label: string; value: string; onChange: (value: string) => void }) {
|
|
||||||
return (
|
|
||||||
<label>
|
|
||||||
{label}
|
|
||||||
<input value={value} onChange={(event) => onChange(event.target.value)} />
|
|
||||||
</label>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function TextArea({ label, value, onChange, rows = 4 }: { label: string; value: string; rows?: number; onChange: (value: string) => void }) {
|
|
||||||
return (
|
|
||||||
<label>
|
|
||||||
{label}
|
|
||||||
<textarea rows={rows} value={value} onChange={(event) => onChange(event.target.value)} />
|
|
||||||
</label>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function SectionTitle({ children }: { children: string }) {
|
|
||||||
return <h3 className="content-editor-heading">{children}</h3>;
|
|
||||||
}
|
|
||||||
|
|
||||||
function ImagePicker({ label, value, mediaFiles, onChange, fallback = "" }: { label: string; value: string; mediaFiles: MediaFile[]; fallback?: string; onChange: (value: string) => void }) {
|
|
||||||
const images = mediaFiles.filter((file) => file.isImage);
|
|
||||||
const preview = value || fallback;
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="content-image-picker">
|
|
||||||
<label>
|
|
||||||
{label}
|
|
||||||
<select value={value} onChange={(event) => onChange(event.target.value)}>
|
|
||||||
<option value="">Standard verwenden</option>
|
|
||||||
<option value="/funktechnik_schubert_logo.jpg">Standard-Logo</option>
|
|
||||||
<option value="/workbench-signal.svg">Standard-Hintergrund</option>
|
|
||||||
{images.map((file) => <option key={file.name} value={file.url}>{file.name}</option>)}
|
|
||||||
</select>
|
|
||||||
</label>
|
|
||||||
{preview && (
|
|
||||||
<div className="content-image-preview">
|
|
||||||
<img src={preview} alt="" />
|
|
||||||
<button className="button light" type="button" onClick={() => onChange("")}>Bild entfernen</button>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function SaveActions({ pending, onSave }: { pending: boolean; onSave: () => void }) {
|
|
||||||
return (
|
|
||||||
<div className="content-save-actions">
|
|
||||||
<button className="button" type="button" onClick={onSave} disabled={pending}>{pending ? "Speichert..." : "Speichern"}</button>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function BlocksEditor({ blocks, onChange }: { blocks: TextBlockContent[]; onChange: (blocks: TextBlockContent[]) => void }) {
|
|
||||||
function update(index: number, field: keyof TextBlockContent, value: string) {
|
|
||||||
onChange(blocks.map((block, current) => current === index ? { ...block, [field]: value } : block));
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="content-repeaters">
|
|
||||||
{blocks.map((block, index) => (
|
|
||||||
<div key={index} className="content-repeater">
|
|
||||||
<TextInput label="Titel" value={block.title} onChange={(value) => update(index, "title", value)} />
|
|
||||||
<TextArea label="Text" value={block.text} onChange={(value) => update(index, "text", value)} />
|
|
||||||
<button className="button light" type="button" onClick={() => onChange(blocks.filter((_, current) => current !== index))}>Entfernen</button>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
<button className="button light" type="button" onClick={() => onChange([...blocks, { title: "", text: "" }])}>Block hinzufügen</button>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function PageFields({ page, onChange }: { page: PageContent; onChange: (page: PageContent) => void }) {
|
|
||||||
return (
|
|
||||||
<>
|
|
||||||
<SectionTitle>Hero</SectionTitle>
|
|
||||||
<div className="content-field-grid">
|
|
||||||
<TextInput label="Titel" value={page.title} onChange={(value) => onChange({ ...page, title: value })} />
|
|
||||||
<TextInput label="Untertitel" value={page.subtitle} onChange={(value) => onChange({ ...page, subtitle: value })} />
|
|
||||||
<TextInput label="Eyebrow" value={page.eyebrow} onChange={(value) => onChange({ ...page, eyebrow: value })} />
|
|
||||||
<TextArea label="Hero Text" value={page.heroText} onChange={(value) => onChange({ ...page, heroText: value })} />
|
|
||||||
</div>
|
|
||||||
<SectionTitle>Inhalt</SectionTitle>
|
|
||||||
<TextArea label="Einleitung" value={page.intro} onChange={(value) => onChange({ ...page, intro: value })} />
|
|
||||||
<TextArea label="Listenpunkte, eine Zeile pro Eintrag" value={linesToText(page.list)} onChange={(value) => onChange({ ...page, list: textToLines(value) })} />
|
|
||||||
<BlocksEditor blocks={page.paragraphs} onChange={(paragraphs) => onChange({ ...page, paragraphs })} />
|
|
||||||
<SectionTitle>Call-To-Action</SectionTitle>
|
|
||||||
<div className="content-field-grid">
|
|
||||||
<TextInput label="CTA Text" value={page.cta.text} onChange={(value) => onChange({ ...page, cta: { ...page.cta, text: value } })} />
|
|
||||||
<TextInput label="Button 1 Text" value={page.cta.primary.label} onChange={(value) => onChange({ ...page, cta: { ...page.cta, primary: updateLink(page.cta.primary, "label", value) } })} />
|
|
||||||
<TextInput label="Button 1 Link" value={page.cta.primary.href} onChange={(value) => onChange({ ...page, cta: { ...page.cta, primary: updateLink(page.cta.primary, "href", value) } })} />
|
|
||||||
<TextInput label="Button 2 Text" value={page.cta.secondary.label} onChange={(value) => onChange({ ...page, cta: { ...page.cta, secondary: updateLink(page.cta.secondary, "label", value) } })} />
|
|
||||||
<TextInput label="Button 2 Link" value={page.cta.secondary.href} onChange={(value) => onChange({ ...page, cta: { ...page.cta, secondary: updateLink(page.cta.secondary, "href", value) } })} />
|
|
||||||
</div>
|
|
||||||
</>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function SeoFields({ seo, mediaFiles, onChange }: { seo: SeoContent; mediaFiles: MediaFile[]; onChange: (seo: SeoContent) => void }) {
|
|
||||||
return (
|
|
||||||
<div className="content-field-grid">
|
|
||||||
<TextInput label="Meta Title" value={seo.metaTitle} onChange={(value) => onChange({ ...seo, metaTitle: value })} />
|
|
||||||
<TextInput label="Meta Description" value={seo.metaDescription} onChange={(value) => onChange({ ...seo, metaDescription: value })} />
|
|
||||||
<TextInput label="Keywords" value={seo.keywords} onChange={(value) => onChange({ ...seo, keywords: value })} />
|
|
||||||
<TextInput label="OpenGraph Title" value={seo.openGraphTitle} onChange={(value) => onChange({ ...seo, openGraphTitle: value })} />
|
|
||||||
<TextInput label="OpenGraph Description" value={seo.openGraphDescription} onChange={(value) => onChange({ ...seo, openGraphDescription: value })} />
|
|
||||||
<ImagePicker label="OpenGraph Image" value={seo.socialImage} mediaFiles={mediaFiles} fallback="/funktechnik_schubert_logo.jpg" onChange={(value) => onChange({ ...seo, socialImage: value })} />
|
|
||||||
<TextInput label="Canonical URL" value={seo.canonicalUrl} onChange={(value) => onChange({ ...seo, canonicalUrl: value })} />
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function ServicesEditor({ services, onChange }: { services: ServiceContent[]; onChange: (services: ServiceContent[]) => void }) {
|
|
||||||
function update(index: number, field: keyof ServiceContent, value: string | number | boolean) {
|
|
||||||
onChange(services.map((service, current) => current === index ? { ...service, [field]: value } : service));
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="content-repeaters">
|
|
||||||
{services.map((service, index) => (
|
|
||||||
<div key={index} className="content-repeater">
|
|
||||||
<div className="content-field-grid">
|
|
||||||
<TextInput label="Titel" value={service.title} onChange={(value) => update(index, "title", value)} />
|
|
||||||
<TextInput label="Icon" value={service.icon} onChange={(value) => update(index, "icon", value)} />
|
|
||||||
<label>
|
|
||||||
Sortierung
|
|
||||||
<input type="number" value={service.sortOrder} onChange={(event) => update(index, "sortOrder", Number(event.target.value))} />
|
|
||||||
</label>
|
|
||||||
<label className="admin-checkbox">
|
|
||||||
<input type="checkbox" checked={service.visible} onChange={(event) => update(index, "visible", event.target.checked)} />
|
|
||||||
Einblenden
|
|
||||||
</label>
|
|
||||||
</div>
|
|
||||||
<TextArea label="Beschreibung" value={service.description} onChange={(value) => update(index, "description", value)} />
|
|
||||||
<button className="button light" type="button" onClick={() => onChange(services.filter((_, current) => current !== index))}>Leistung entfernen</button>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
<button className="button light" type="button" onClick={() => onChange([...services, { title: "", description: "", icon: "radio", sortOrder: services.length * 10 + 10, visible: true }])}>Leistung hinzufügen</button>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
export default function ContentManager({ initialContent, mediaFiles }: Props) {
|
|
||||||
const [content, setContent] = useState(initialContent);
|
|
||||||
const [activeTab, setActiveTab] = useState<ContentTabId>("home");
|
|
||||||
const [seoPage, setSeoPage] = useState<SeoPageKey>("home");
|
|
||||||
const [viewport, setViewport] = useState<Viewport>("desktop");
|
|
||||||
const [message, setMessage] = useState("");
|
|
||||||
const [error, setError] = useState("");
|
|
||||||
const [pending, setPending] = useState(false);
|
|
||||||
|
|
||||||
const active = contentKeyForTab(activeTab, seoPage);
|
|
||||||
const current = content[active];
|
|
||||||
const activeLabel = useMemo(() => tabs.find((tab) => tab.id === activeTab)?.label ?? "Startseite", [activeTab]);
|
|
||||||
const pagePreview = "title" in current ? current : content.home;
|
|
||||||
|
|
||||||
function update<K extends ContentKey>(key: K, value: ContentDocuments[K]) {
|
|
||||||
setContent((previous) => ({ ...previous, [key]: value }));
|
|
||||||
}
|
|
||||||
|
|
||||||
async function save() {
|
|
||||||
setPending(true);
|
|
||||||
setMessage("");
|
|
||||||
setError("");
|
|
||||||
|
|
||||||
try {
|
|
||||||
const response = await fetch("/api/admin/content", {
|
|
||||||
method: "PUT",
|
|
||||||
headers: { "Content-Type": "application/json" },
|
|
||||||
body: JSON.stringify({ key: active, content: current }),
|
|
||||||
});
|
|
||||||
const result = await response.json() as ApiResponse<typeof active>;
|
|
||||||
if (!response.ok || !result.content) throw new Error("Inhalt konnte nicht gespeichert werden.");
|
|
||||||
update(active, result.content);
|
|
||||||
setMessage("Inhalt wurde gespeichert.");
|
|
||||||
} catch {
|
|
||||||
setError("Der Inhalt konnte nicht gespeichert werden. Bitte prüfen Sie die Eingaben und versuchen Sie es erneut.");
|
|
||||||
} finally {
|
|
||||||
setPending(false);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const heroBackground = content.home.heroImage || "/workbench-signal.svg";
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="content-manager">
|
|
||||||
{message && <p className="success admin-message">{message}</p>}
|
|
||||||
{error && <p className="error admin-message">{error}</p>}
|
|
||||||
<section className="admin-card content-editor-layout">
|
|
||||||
<aside className="content-tabs" aria-label="Website-Inhalte">
|
|
||||||
{tabs.map((tab) => (
|
|
||||||
<button key={tab.id} type="button" aria-current={activeTab === tab.id ? "page" : undefined} onClick={() => setActiveTab(tab.id)}>
|
|
||||||
{tab.label}
|
|
||||||
</button>
|
|
||||||
))}
|
|
||||||
</aside>
|
|
||||||
<div className="content-editor">
|
|
||||||
<div className="content-editor-top">
|
|
||||||
<div>
|
|
||||||
<p className="eyebrow">Editor</p>
|
|
||||||
<h2>{activeLabel}</h2>
|
|
||||||
</div>
|
|
||||||
<SaveActions pending={pending} onSave={save} />
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{activeTab === "home" && (
|
|
||||||
<>
|
|
||||||
<PageFields page={content.home} onChange={(value) => update("home", { ...content.home, ...value })} />
|
|
||||||
<SectionTitle>Medien</SectionTitle>
|
|
||||||
<ImagePicker label="Hero-Bild" value={content.home.heroImage} mediaFiles={mediaFiles} fallback="/workbench-signal.svg" onChange={(value) => update("home", { ...content.home, heroImage: value })} />
|
|
||||||
<SectionTitle>Homepage Builder</SectionTitle>
|
|
||||||
<TextArea label="Badges, eine Zeile pro Eintrag" value={linesToText(content.home.badges)} onChange={(value) => update("home", { ...content.home, badges: textToLines(value) })} />
|
|
||||||
<h4>3 Featureboxen</h4>
|
|
||||||
<BlocksEditor blocks={content.home.features} onChange={(features) => update("home", { ...content.home, features })} />
|
|
||||||
<h4>3 Leistungsboxen</h4>
|
|
||||||
<BlocksEditor blocks={content.home.services} onChange={(services) => update("home", { ...content.home, services })} />
|
|
||||||
<h4>Kundenversprechen</h4>
|
|
||||||
<TextInput label="Headline" value={content.home.promise.title} onChange={(value) => update("home", { ...content.home, promise: { ...content.home.promise, title: value } })} />
|
|
||||||
<TextArea label="Text" value={content.home.promise.text} onChange={(value) => update("home", { ...content.home, promise: { ...content.home.promise, text: value } })} />
|
|
||||||
<TextArea label="Punkte, eine Zeile pro Eintrag" value={linesToText(content.home.promise.bullets)} onChange={(value) => update("home", { ...content.home, promise: { ...content.home.promise, bullets: textToLines(value) } })} />
|
|
||||||
<TextArea label="Footer-Text" value={content.home.footerText} onChange={(value) => update("home", { ...content.home, footerText: value })} />
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{activeTab === "services" && (
|
|
||||||
<>
|
|
||||||
<PageFields page={content.services} onChange={(value) => update("services", { ...content.services, ...value })} />
|
|
||||||
<SectionTitle>Leistungen</SectionTitle>
|
|
||||||
<ServicesEditor services={content.services.services} onChange={(services) => update("services", { ...content.services, services })} />
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{activeTab === "radio-service" && (
|
|
||||||
<>
|
|
||||||
<PageFields page={content["radio-service"]} onChange={(value) => update("radio-service", { ...content["radio-service"], ...value })} />
|
|
||||||
<SectionTitle>Funkgeräte-Service</SectionTitle>
|
|
||||||
<TextArea label="Marken" value={linesToText(content["radio-service"].brands)} onChange={(value) => update("radio-service", { ...content["radio-service"], brands: textToLines(value) })} />
|
|
||||||
<TextArea label="Fehlerbilder" value={linesToText(content["radio-service"].symptoms)} onChange={(value) => update("radio-service", { ...content["radio-service"], symptoms: textToLines(value) })} />
|
|
||||||
<TextArea label="Ablauf" value={linesToText(content["radio-service"].workflow)} onChange={(value) => update("radio-service", { ...content["radio-service"], workflow: textToLines(value) })} />
|
|
||||||
<TextArea label="Messmöglichkeiten" value={linesToText(content["radio-service"].measurements)} onChange={(value) => update("radio-service", { ...content["radio-service"], measurements: textToLines(value) })} />
|
|
||||||
<TextArea label="Abgleich" value={content["radio-service"].alignment} onChange={(value) => update("radio-service", { ...content["radio-service"], alignment: value })} />
|
|
||||||
<TextArea label="Reparatur" value={content["radio-service"].repair} onChange={(value) => update("radio-service", { ...content["radio-service"], repair: value })} />
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{activeTab === "about" && (
|
|
||||||
<>
|
|
||||||
<PageFields page={content.about} onChange={(value) => update("about", { ...content.about, ...value })} />
|
|
||||||
<SectionTitle>Über uns</SectionTitle>
|
|
||||||
<TextArea label="Firmenbeschreibung" value={content.about.companyDescription} onChange={(value) => update("about", { ...content.about, companyDescription: value })} />
|
|
||||||
<TextArea label="Werkstattbeschreibung" value={content.about.workshopDescription} onChange={(value) => update("about", { ...content.about, workshopDescription: value })} />
|
|
||||||
<TextArea label="Philosophie" value={content.about.philosophy} onChange={(value) => update("about", { ...content.about, philosophy: value })} />
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{activeTab === "contact" && (
|
|
||||||
<>
|
|
||||||
<PageFields page={content.contact} onChange={(value) => update("contact", { ...content.contact, ...value })} />
|
|
||||||
<SectionTitle>Kontaktinformationen</SectionTitle>
|
|
||||||
<div className="content-field-grid">
|
|
||||||
<TextInput label="Telefon" value={content.contact.phone} onChange={(value) => update("contact", { ...content.contact, phone: value })} />
|
|
||||||
<TextInput label="E-Mail" value={content.contact.email} onChange={(value) => update("contact", { ...content.contact, email: value })} />
|
|
||||||
<TextInput label="Adresse" value={content.contact.address} onChange={(value) => update("contact", { ...content.contact, address: value })} />
|
|
||||||
<TextInput label="Öffnungszeiten" value={content.contact.openingHours} onChange={(value) => update("contact", { ...content.contact, openingHours: value })} />
|
|
||||||
<TextInput label="Google Maps Link" value={content.contact.googleMapsLink} onChange={(value) => update("contact", { ...content.contact, googleMapsLink: value })} />
|
|
||||||
</div>
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{activeTab === "footer" && (
|
|
||||||
<>
|
|
||||||
<SectionTitle>Footer und Header</SectionTitle>
|
|
||||||
<div className="content-field-grid">
|
|
||||||
<TextInput label="Site Name" value={content.settings.siteName} onChange={(value) => update("settings", { ...content.settings, siteName: value })} />
|
|
||||||
<ImagePicker label="Logo" value={content.settings.logoUrl} mediaFiles={mediaFiles} fallback="/funktechnik_schubert_logo.jpg" onChange={(value) => update("settings", { ...content.settings, logoUrl: value })} />
|
|
||||||
<TextInput label="Copyright" value={content.settings.copyright} onChange={(value) => update("settings", { ...content.settings, copyright: value })} />
|
|
||||||
<TextInput label="Header CTA Text" value={content.settings.headerCta.label} onChange={(value) => update("settings", { ...content.settings, headerCta: updateLink(content.settings.headerCta, "label", value) })} />
|
|
||||||
<TextInput label="Header CTA Link" value={content.settings.headerCta.href} onChange={(value) => update("settings", { ...content.settings, headerCta: updateLink(content.settings.headerCta, "href", value) })} />
|
|
||||||
</div>
|
|
||||||
<TextArea label="Footer-Kurztext" value={content.settings.footerShortText} onChange={(value) => update("settings", { ...content.settings, footerShortText: value })} />
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{activeTab === "seo" && (
|
|
||||||
<>
|
|
||||||
<SectionTitle>SEO pro Seite</SectionTitle>
|
|
||||||
<label>
|
|
||||||
Seite
|
|
||||||
<select value={seoPage} onChange={(event) => setSeoPage(event.target.value as SeoPageKey)}>
|
|
||||||
{seoPages.map((page) => <option key={page.key} value={page.key}>{page.label}</option>)}
|
|
||||||
</select>
|
|
||||||
</label>
|
|
||||||
<SeoFields seo={content[seoPage].seo} mediaFiles={mediaFiles} onChange={(seo) => update(seoPage, { ...content[seoPage], seo })} />
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
|
|
||||||
<SaveActions pending={pending} onSave={save} />
|
|
||||||
</div>
|
|
||||||
</section>
|
|
||||||
|
|
||||||
<section className="admin-card content-preview-card">
|
|
||||||
<div className="content-preview-toolbar">
|
|
||||||
<div>
|
|
||||||
<p className="eyebrow">Vorschau</p>
|
|
||||||
<h2>Live Preview</h2>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
{(["desktop", "tablet", "mobile"] as const).map((item) => (
|
|
||||||
<button key={item} type="button" aria-current={viewport === item ? "true" : undefined} onClick={() => setViewport(item)}>{item}</button>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div className={`content-preview ${viewport}`} style={{ backgroundImage: `linear-gradient(90deg, rgba(6, 24, 52, 0.95), rgba(8, 42, 96, 0.72)), url("${activeTab === "home" ? heroBackground : pagePreview.heroImage || "/workbench-signal.svg"}")` }}>
|
|
||||||
<p className="eyebrow">{activeTab === "seo" ? content[seoPage].seo.metaTitle || pagePreview.eyebrow : pagePreview.eyebrow}</p>
|
|
||||||
<h1>{pagePreview.title}</h1>
|
|
||||||
<p className="lead">{pagePreview.subtitle}</p>
|
|
||||||
<p>{pagePreview.heroText}</p>
|
|
||||||
<div className="hero-actions">
|
|
||||||
<span className="button">{pagePreview.cta.primary.label}</span>
|
|
||||||
<span className="button light">{pagePreview.cta.secondary.label}</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</section>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
@ -1,221 +0,0 @@
|
||||||
"use client";
|
|
||||||
|
|
||||||
/* eslint-disable @next/next/no-img-element */
|
|
||||||
|
|
||||||
import { useEffect, useMemo, useRef, useState, type ChangeEvent } from "react";
|
|
||||||
import type { MediaFile } from "@/lib/admin/media";
|
|
||||||
|
|
||||||
type SortKey = "date" | "name" | "size";
|
|
||||||
type FilterKey = "all" | "images" | "pdf";
|
|
||||||
|
|
||||||
type ApiResponse = {
|
|
||||||
message?: string;
|
|
||||||
files?: MediaFile[];
|
|
||||||
};
|
|
||||||
|
|
||||||
function formatBytes(size: number) {
|
|
||||||
if (size < 1024) return `${size} B`;
|
|
||||||
if (size < 1024 * 1024) return `${(size / 1024).toFixed(1)} KB`;
|
|
||||||
return `${(size / 1024 / 1024).toFixed(1)} MB`;
|
|
||||||
}
|
|
||||||
|
|
||||||
function sortFiles(files: MediaFile[], sort: SortKey) {
|
|
||||||
return [...files].sort((left, right) => {
|
|
||||||
if (sort === "name") return left.name.localeCompare(right.name);
|
|
||||||
if (sort === "size") return right.size - left.size;
|
|
||||||
return right.uploadedAt.localeCompare(left.uploadedAt);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
export default function MediaManager({ initialFiles }: { initialFiles: MediaFile[] }) {
|
|
||||||
const fileInputRef = useRef<HTMLInputElement>(null);
|
|
||||||
const [files, setFiles] = useState(initialFiles);
|
|
||||||
const [filter, setFilter] = useState<FilterKey>("all");
|
|
||||||
const [sort, setSort] = useState<SortKey>("date");
|
|
||||||
const [query, setQuery] = useState("");
|
|
||||||
const [toast, setToast] = useState("");
|
|
||||||
const [error, setError] = useState("");
|
|
||||||
const [pending, setPending] = useState(false);
|
|
||||||
const [preview, setPreview] = useState<MediaFile | null>(null);
|
|
||||||
const [zoom, setZoom] = useState(1);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
function closeOnEscape(event: KeyboardEvent) {
|
|
||||||
if (event.key === "Escape") {
|
|
||||||
setPreview(null);
|
|
||||||
setZoom(1);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
window.addEventListener("keydown", closeOnEscape);
|
|
||||||
return () => window.removeEventListener("keydown", closeOnEscape);
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
const visibleFiles = useMemo(() => {
|
|
||||||
const normalizedQuery = query.trim().toLowerCase();
|
|
||||||
const filtered = files.filter((file) => {
|
|
||||||
if (filter === "images" && !file.isImage) return false;
|
|
||||||
if (filter === "pdf" && !file.isPdf) return false;
|
|
||||||
if (!normalizedQuery) return true;
|
|
||||||
return file.name.toLowerCase().includes(normalizedQuery) || file.extension.toLowerCase().includes(normalizedQuery);
|
|
||||||
});
|
|
||||||
return sortFiles(filtered, sort);
|
|
||||||
}, [files, filter, query, sort]);
|
|
||||||
|
|
||||||
async function upload(event: ChangeEvent<HTMLInputElement>) {
|
|
||||||
const file = event.currentTarget.files?.[0];
|
|
||||||
if (!file) return;
|
|
||||||
|
|
||||||
setPending(true);
|
|
||||||
setToast("");
|
|
||||||
setError("");
|
|
||||||
|
|
||||||
const form = new FormData();
|
|
||||||
form.set("file", file);
|
|
||||||
|
|
||||||
try {
|
|
||||||
const response = await fetch("/api/admin/media", { method: "POST", body: form });
|
|
||||||
const result = await response.json() as ApiResponse;
|
|
||||||
if (!response.ok) throw new Error(result.message ?? "Upload fehlgeschlagen.");
|
|
||||||
if (result.files) setFiles(result.files);
|
|
||||||
setToast(result.message ?? "Datei erfolgreich hochgeladen.");
|
|
||||||
if (fileInputRef.current) fileInputRef.current.value = "";
|
|
||||||
} catch (err) {
|
|
||||||
setError(err instanceof Error ? err.message : "Upload fehlgeschlagen.");
|
|
||||||
} finally {
|
|
||||||
setPending(false);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async function refresh() {
|
|
||||||
const response = await fetch("/api/admin/media", { method: "GET" });
|
|
||||||
const result = await response.json() as ApiResponse;
|
|
||||||
if (response.ok && result.files) setFiles(result.files);
|
|
||||||
}
|
|
||||||
|
|
||||||
async function remove(file: MediaFile) {
|
|
||||||
if (!window.confirm(`Datei wirklich löschen?\n\n${file.name}`)) return;
|
|
||||||
setToast("");
|
|
||||||
setError("");
|
|
||||||
|
|
||||||
try {
|
|
||||||
const response = await fetch("/api/admin/media", {
|
|
||||||
method: "DELETE",
|
|
||||||
headers: { "Content-Type": "application/json" },
|
|
||||||
body: JSON.stringify({ name: file.name }),
|
|
||||||
});
|
|
||||||
const result = await response.json() as ApiResponse;
|
|
||||||
if (!response.ok) throw new Error(result.message ?? "Datei konnte nicht gelöscht werden.");
|
|
||||||
if (result.files) setFiles(result.files);
|
|
||||||
setToast(result.message ?? "Datei gelöscht.");
|
|
||||||
} catch (err) {
|
|
||||||
setError(err instanceof Error ? err.message : "Datei konnte nicht gelöscht werden.");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async function copyUrl(file: MediaFile) {
|
|
||||||
try {
|
|
||||||
await navigator.clipboard.writeText(file.url);
|
|
||||||
setToast("Relative URL wurde kopiert.");
|
|
||||||
setError("");
|
|
||||||
} catch {
|
|
||||||
setError("URL konnte nicht kopiert werden.");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function openPreview(file: MediaFile) {
|
|
||||||
if (file.isImage) {
|
|
||||||
setPreview(file);
|
|
||||||
setZoom(1);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
window.open(file.url, "_blank", "noopener,noreferrer");
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<>
|
|
||||||
{toast && <p className="success admin-message">{toast}</p>}
|
|
||||||
{error && <p className="error admin-message">{error}</p>}
|
|
||||||
|
|
||||||
<section className="admin-card media-toolbar">
|
|
||||||
<label>
|
|
||||||
Datei hochladen
|
|
||||||
<input ref={fileInputRef} type="file" accept="image/jpeg,image/png,image/webp,application/pdf" onChange={upload} disabled={pending} />
|
|
||||||
</label>
|
|
||||||
<label>
|
|
||||||
Suche
|
|
||||||
<input value={query} onChange={(event) => setQuery(event.target.value)} placeholder="Dateiname oder Typ" />
|
|
||||||
</label>
|
|
||||||
<label>
|
|
||||||
Filter
|
|
||||||
<select value={filter} onChange={(event) => setFilter(event.target.value as FilterKey)}>
|
|
||||||
<option value="all">Alle</option>
|
|
||||||
<option value="images">Bilder</option>
|
|
||||||
<option value="pdf">PDF</option>
|
|
||||||
</select>
|
|
||||||
</label>
|
|
||||||
<label>
|
|
||||||
Sortierung
|
|
||||||
<select value={sort} onChange={(event) => setSort(event.target.value as SortKey)}>
|
|
||||||
<option value="date">Datum</option>
|
|
||||||
<option value="name">Name</option>
|
|
||||||
<option value="size">Größe</option>
|
|
||||||
</select>
|
|
||||||
</label>
|
|
||||||
<button className="button light" type="button" onClick={refresh}>Aktualisieren</button>
|
|
||||||
</section>
|
|
||||||
|
|
||||||
<section className="admin-card">
|
|
||||||
<h2>Uploads</h2>
|
|
||||||
{visibleFiles.length === 0 ? (
|
|
||||||
<div className="media-empty">
|
|
||||||
<h3>Noch keine Medien vorhanden</h3>
|
|
||||||
<p>Hochgeladene Bilder und PDF-Dateien erscheinen hier mit Vorschau, Metadaten und Aktionen.</p>
|
|
||||||
</div>
|
|
||||||
) : (
|
|
||||||
<div className="media-card-grid">
|
|
||||||
{visibleFiles.map((file) => (
|
|
||||||
<article key={file.name} className="media-card">
|
|
||||||
<button className="media-card-preview" type="button" onClick={() => openPreview(file)}>
|
|
||||||
{file.isImage ? <img src={file.url} alt={file.name} /> : <span className="pdf-icon">PDF</span>}
|
|
||||||
</button>
|
|
||||||
<div className="media-card-body">
|
|
||||||
<h3 title={file.name}>{file.name}</h3>
|
|
||||||
<dl className="media-meta">
|
|
||||||
<div><dt>Typ</dt><dd>{file.extension}</dd></div>
|
|
||||||
<div><dt>Größe</dt><dd>{formatBytes(file.size)}</dd></div>
|
|
||||||
<div><dt>Upload</dt><dd>{new Date(file.uploadedAt).toLocaleString("de-DE")}</dd></div>
|
|
||||||
</dl>
|
|
||||||
<div className="media-actions">
|
|
||||||
<button type="button" onClick={() => openPreview(file)}>Vorschau</button>
|
|
||||||
<a href={file.url} download={file.name}>Download</a>
|
|
||||||
<button type="button" onClick={() => copyUrl(file)}>URL kopieren</button>
|
|
||||||
<button type="button" className="danger" onClick={() => remove(file)}>Löschen</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</article>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</section>
|
|
||||||
|
|
||||||
{preview && (
|
|
||||||
<div className="lightbox" role="dialog" aria-modal="true" aria-label={`Vorschau ${preview.name}`}>
|
|
||||||
<div className="lightbox-toolbar">
|
|
||||||
<strong>{preview.name}</strong>
|
|
||||||
<div>
|
|
||||||
<button type="button" onClick={() => setZoom((value) => Math.max(0.5, value - 0.25))}>Zoom -</button>
|
|
||||||
<button type="button" onClick={() => setZoom((value) => Math.min(3, value + 0.25))}>Zoom +</button>
|
|
||||||
<button type="button" onClick={() => { setPreview(null); setZoom(1); }}>Schließen</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<button className="lightbox-backdrop" type="button" aria-label="Vorschau schließen" onClick={() => { setPreview(null); setZoom(1); }} />
|
|
||||||
<div className="lightbox-stage">
|
|
||||||
<img src={preview.url} alt={preview.name} style={{ transform: `scale(${zoom})` }} />
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
@ -1,94 +0,0 @@
|
||||||
"use client";
|
|
||||||
|
|
||||||
import { useRef, useState, type FormEvent } from "react";
|
|
||||||
import type { PublicSmtpSettings } from "@/lib/mail/types";
|
|
||||||
|
|
||||||
type ApiResponse = {
|
|
||||||
message?: string;
|
|
||||||
ok?: boolean;
|
|
||||||
settings?: PublicSmtpSettings;
|
|
||||||
};
|
|
||||||
|
|
||||||
export default function SmtpSettingsForm({ initialSettings, configured }: { initialSettings: PublicSmtpSettings; configured: boolean }) {
|
|
||||||
const formRef = useRef<HTMLFormElement>(null);
|
|
||||||
const [settings, setSettings] = useState(initialSettings);
|
|
||||||
const [isConfigured, setIsConfigured] = useState(configured);
|
|
||||||
const [message, setMessage] = useState("");
|
|
||||||
const [error, setError] = useState("");
|
|
||||||
const [pending, setPending] = useState(false);
|
|
||||||
|
|
||||||
async function submitForm(form: HTMLFormElement, action: "save" | "test") {
|
|
||||||
setMessage("");
|
|
||||||
setError("");
|
|
||||||
setPending(true);
|
|
||||||
|
|
||||||
const formData = new FormData(form);
|
|
||||||
formData.set("action", action);
|
|
||||||
|
|
||||||
try {
|
|
||||||
const response = await fetch("/api/admin/smtp", { method: "POST", body: formData });
|
|
||||||
const result = await response.json() as ApiResponse;
|
|
||||||
if (result.settings) {
|
|
||||||
setSettings(result.settings);
|
|
||||||
setIsConfigured(Boolean(result.settings.host && result.settings.username && result.settings.hasPassword && result.settings.fromAddress && result.settings.recipientAddress));
|
|
||||||
}
|
|
||||||
if (!response.ok) throw new Error(result.message ?? "SMTP-Aktion fehlgeschlagen.");
|
|
||||||
setMessage(result.message ?? "Aktion erfolgreich.");
|
|
||||||
} catch (err) {
|
|
||||||
setError(err instanceof Error ? err.message : "SMTP-Aktion fehlgeschlagen.");
|
|
||||||
} finally {
|
|
||||||
setPending(false);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function submit(event: FormEvent<HTMLFormElement>) {
|
|
||||||
event.preventDefault();
|
|
||||||
void submitForm(event.currentTarget, "save");
|
|
||||||
}
|
|
||||||
|
|
||||||
function test() {
|
|
||||||
if (formRef.current) void submitForm(formRef.current, "test");
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<>
|
|
||||||
<section className="admin-card">
|
|
||||||
<h2>Status</h2>
|
|
||||||
<p className="admin-muted">SMTP ist {isConfigured ? "konfiguriert" : "nicht konfiguriert"}.</p>
|
|
||||||
{settings.lastTestAt && <p>Letzte Testmail: {new Date(settings.lastTestAt).toLocaleString("de-DE")} ({settings.lastTestStatus})</p>}
|
|
||||||
{settings.lastDeliveryAt && <p>Letzter Formularversand: {new Date(settings.lastDeliveryAt).toLocaleString("de-DE")} ({settings.lastDeliveryStatus})</p>}
|
|
||||||
{settings.lastError && <p className="error">Letzter Fehler: {settings.lastError}</p>}
|
|
||||||
<p className="notice">Für Apple Mail/iCloud bitte smtp.mail.me.com, Port 587, STARTTLS und ein app-spezifisches Passwort verwenden.</p>
|
|
||||||
</section>
|
|
||||||
|
|
||||||
{message && <p className="success admin-message">{message}</p>}
|
|
||||||
{error && <p className="error admin-message">{error}</p>}
|
|
||||||
|
|
||||||
<form ref={formRef} className="admin-card admin-form" onSubmit={submit}>
|
|
||||||
<label>SMTP Host<input name="host" defaultValue={settings.host} placeholder="smtp.mail.me.com" /></label>
|
|
||||||
<label>SMTP Port<input name="port" defaultValue={settings.port} inputMode="numeric" placeholder="587" /></label>
|
|
||||||
<label>SMTP Benutzername<input name="username" defaultValue={settings.username} placeholder="name@example.com" /></label>
|
|
||||||
<label>
|
|
||||||
SMTP Passwort
|
|
||||||
<input name="password" type="password" placeholder={settings.hasPassword ? "Passwort ist gesetzt" : "App-spezifisches Passwort"} autoComplete="new-password" />
|
|
||||||
</label>
|
|
||||||
<label>
|
|
||||||
Verschlüsselung
|
|
||||||
<select name="security" defaultValue={settings.security}>
|
|
||||||
<option value="starttls">STARTTLS</option>
|
|
||||||
<option value="tls">TLS</option>
|
|
||||||
<option value="none">Keine</option>
|
|
||||||
</select>
|
|
||||||
</label>
|
|
||||||
<label>Absenderadresse<input name="fromAddress" type="email" defaultValue={settings.fromAddress} /></label>
|
|
||||||
<label>Antwortadresse<input name="replyToAddress" type="email" defaultValue={settings.replyToAddress} /></label>
|
|
||||||
<label>Empfängeradresse<input name="recipientAddress" type="email" defaultValue={settings.recipientAddress} /></label>
|
|
||||||
<label>BCC optional<input name="bccAddress" type="email" defaultValue={settings.bccAddress} /></label>
|
|
||||||
<div className="actions">
|
|
||||||
<button className="button" type="submit" disabled={pending}>Speichern</button>
|
|
||||||
<button className="button light" type="button" disabled={pending} onClick={test}>Test-E-Mail senden</button>
|
|
||||||
</div>
|
|
||||||
</form>
|
|
||||||
</>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
@ -1,34 +0,0 @@
|
||||||
"use client";
|
|
||||||
|
|
||||||
import { useRouter } from "next/navigation";
|
|
||||||
import { useState } from "react";
|
|
||||||
import type { InquiryStatus } from "@/lib/admin/types";
|
|
||||||
|
|
||||||
type Props = {
|
|
||||||
id: string;
|
|
||||||
type: "contact" | "repair";
|
|
||||||
status: InquiryStatus;
|
|
||||||
};
|
|
||||||
|
|
||||||
export default function StatusSelect({ id, type, status }: Props) {
|
|
||||||
const router = useRouter();
|
|
||||||
const [value, setValue] = useState(status);
|
|
||||||
|
|
||||||
async function update(nextStatus: InquiryStatus) {
|
|
||||||
setValue(nextStatus);
|
|
||||||
const response = await fetch(`/api/admin/${type}/${id}`, {
|
|
||||||
method: "PATCH",
|
|
||||||
headers: { "Content-Type": "application/json" },
|
|
||||||
body: JSON.stringify({ status: nextStatus }),
|
|
||||||
});
|
|
||||||
if (response.ok) router.refresh();
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<select className="status-select" value={value} onChange={(event) => update(event.target.value as InquiryStatus)}>
|
|
||||||
<option value="new">Neu</option>
|
|
||||||
<option value="in_progress">In Prüfung</option>
|
|
||||||
<option value="done">Erledigt</option>
|
|
||||||
</select>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
@ -1,153 +0,0 @@
|
||||||
"use client";
|
|
||||||
|
|
||||||
import { useRouter } from "next/navigation";
|
|
||||||
import { useState } from "react";
|
|
||||||
|
|
||||||
type EstimateAction = "approve" | "decline" | "question";
|
|
||||||
|
|
||||||
type EstimateActionsProps = {
|
|
||||||
token: string;
|
|
||||||
};
|
|
||||||
|
|
||||||
type ActionResponse = {
|
|
||||||
message?: string;
|
|
||||||
};
|
|
||||||
|
|
||||||
const actionLabels: Record<EstimateAction, string> = {
|
|
||||||
approve: "Freigeben",
|
|
||||||
decline: "Ablehnen",
|
|
||||||
question: "Rückfrage senden",
|
|
||||||
};
|
|
||||||
|
|
||||||
async function readActionMessage(response: Response) {
|
|
||||||
try {
|
|
||||||
const body = await response.json() as ActionResponse;
|
|
||||||
return body.message || "Die Aktion konnte nicht abgeschlossen werden.";
|
|
||||||
} catch {
|
|
||||||
return "Die Aktion konnte nicht abgeschlossen werden.";
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export function EstimateActions({ token }: EstimateActionsProps) {
|
|
||||||
const router = useRouter();
|
|
||||||
const [activeForm, setActiveForm] = useState<Exclude<EstimateAction, "approve"> | null>(null);
|
|
||||||
const [message, setMessage] = useState("");
|
|
||||||
const [pendingAction, setPendingAction] = useState<EstimateAction | null>(null);
|
|
||||||
const [feedback, setFeedback] = useState<{ type: "success" | "error"; text: string } | null>(null);
|
|
||||||
|
|
||||||
async function submitAction(action: EstimateAction, actionMessage = "") {
|
|
||||||
setPendingAction(action);
|
|
||||||
setFeedback(null);
|
|
||||||
|
|
||||||
try {
|
|
||||||
const response = await fetch(`/api/status/${encodeURIComponent(token)}/estimate/${action}`, {
|
|
||||||
method: "POST",
|
|
||||||
headers: {
|
|
||||||
"Content-Type": "application/json",
|
|
||||||
},
|
|
||||||
body: JSON.stringify({ message: actionMessage.trim() || null }),
|
|
||||||
});
|
|
||||||
const responseMessage = await readActionMessage(response);
|
|
||||||
|
|
||||||
if (!response.ok) {
|
|
||||||
setFeedback({ type: "error", text: responseMessage });
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
setMessage("");
|
|
||||||
setActiveForm(null);
|
|
||||||
setFeedback({ type: "success", text: responseMessage });
|
|
||||||
router.refresh();
|
|
||||||
} catch {
|
|
||||||
setFeedback({
|
|
||||||
type: "error",
|
|
||||||
text: "Die Aktion konnte momentan nicht gesendet werden. Bitte versuchen Sie es erneut.",
|
|
||||||
});
|
|
||||||
} finally {
|
|
||||||
setPendingAction(null);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const isPending = pendingAction !== null;
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="estimate-actions-panel">
|
|
||||||
<div className="estimate-action-buttons">
|
|
||||||
<button
|
|
||||||
className="button"
|
|
||||||
disabled={isPending}
|
|
||||||
type="button"
|
|
||||||
onClick={() => void submitAction("approve")}
|
|
||||||
>
|
|
||||||
{pendingAction === "approve" ? "Wird freigegeben..." : actionLabels.approve}
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
className="button light"
|
|
||||||
disabled={isPending}
|
|
||||||
type="button"
|
|
||||||
onClick={() => {
|
|
||||||
setActiveForm("question");
|
|
||||||
setFeedback(null);
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
Rückfrage
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
className="button danger"
|
|
||||||
disabled={isPending}
|
|
||||||
type="button"
|
|
||||||
onClick={() => {
|
|
||||||
setActiveForm("decline");
|
|
||||||
setFeedback(null);
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
Ablehnen
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{activeForm && (
|
|
||||||
<form
|
|
||||||
className="estimate-action-form"
|
|
||||||
onSubmit={(event) => {
|
|
||||||
event.preventDefault();
|
|
||||||
void submitAction(activeForm, message);
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<label htmlFor={`estimate-${activeForm}-message`}>
|
|
||||||
{activeForm === "decline" ? "Nachricht zur Ablehnung" : "Ihre Rückfrage"}
|
|
||||||
</label>
|
|
||||||
<textarea
|
|
||||||
id={`estimate-${activeForm}-message`}
|
|
||||||
name="message"
|
|
||||||
rows={4}
|
|
||||||
value={message}
|
|
||||||
placeholder={activeForm === "decline" ? "Optionaler Hinweis für uns" : "Ihre Frage zum Kostenvoranschlag"}
|
|
||||||
onChange={(event) => setMessage(event.currentTarget.value)}
|
|
||||||
/>
|
|
||||||
<div className="estimate-form-actions">
|
|
||||||
<button className="button" disabled={isPending} type="submit">
|
|
||||||
{pendingAction === activeForm ? "Wird gesendet..." : actionLabels[activeForm]}
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
className="button light"
|
|
||||||
disabled={isPending}
|
|
||||||
type="button"
|
|
||||||
onClick={() => {
|
|
||||||
setActiveForm(null);
|
|
||||||
setMessage("");
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
Abbrechen
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</form>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{feedback && (
|
|
||||||
<p className={`estimate-feedback ${feedback.type === "error" ? "error" : "success"}`} role="status">
|
|
||||||
{feedback.text}
|
|
||||||
</p>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
@ -1 +0,0 @@
|
||||||
|
|
||||||
|
|
@ -1,12 +0,0 @@
|
||||||
[
|
|
||||||
{
|
|
||||||
"id": "contact_example",
|
|
||||||
"createdAt": "2026-07-03T00:00:00.000Z",
|
|
||||||
"status": "new",
|
|
||||||
"name": "Max Mustermann",
|
|
||||||
"email": "max@example.invalid",
|
|
||||||
"phone": "",
|
|
||||||
"subject": "Allgemeine Anfrage",
|
|
||||||
"message": "Beispieldatensatz fuer lokale Tests. Nicht fuer produktive Daten verwenden."
|
|
||||||
}
|
|
||||||
]
|
|
||||||
|
|
@ -1,18 +0,0 @@
|
||||||
[
|
|
||||||
{
|
|
||||||
"id": "repair_example",
|
|
||||||
"createdAt": "2026-07-03T00:00:00.000Z",
|
|
||||||
"status": "new",
|
|
||||||
"name": "Max Mustermann",
|
|
||||||
"email": "max@example.invalid",
|
|
||||||
"phone": "",
|
|
||||||
"manufacturer": "Beispielhersteller",
|
|
||||||
"model": "Beispielmodell",
|
|
||||||
"serialNumber": "",
|
|
||||||
"deviceType": "cb-radio",
|
|
||||||
"accessories": "Mikrofon",
|
|
||||||
"opened": "unknown",
|
|
||||||
"previousWork": "",
|
|
||||||
"description": "Beispieldatensatz fuer lokale Tests. Nicht fuer produktive Daten verwenden."
|
|
||||||
}
|
|
||||||
]
|
|
||||||
|
|
@ -1,30 +1,14 @@
|
||||||
services:
|
services:
|
||||||
funktechnik-website:
|
funktechnik-website:
|
||||||
container_name: funktechnik-website
|
|
||||||
build:
|
build:
|
||||||
context: .
|
context: .
|
||||||
dockerfile: Dockerfile
|
dockerfile: Dockerfile
|
||||||
ports:
|
container_name: funktechnik-website
|
||||||
- "3010:3010"
|
restart: unless-stopped
|
||||||
environment:
|
environment:
|
||||||
NEXT_PUBLIC_SITE_URL: ${NEXT_PUBLIC_SITE_URL:-http://127.0.0.1:3010}
|
NODE_ENV: production
|
||||||
NEXT_PUBLIC_APP_VERSION: "0.4.1"
|
NEXT_PUBLIC_SITE_URL: ${NEXT_PUBLIC_SITE_URL:-http://localhost:3010}
|
||||||
ADMIN_EMAIL: ${ADMIN_EMAIL:-}
|
|
||||||
ADMIN_PASSWORD: ${ADMIN_PASSWORD:-}
|
|
||||||
ADMIN_SESSION_SECRET: ${ADMIN_SESSION_SECRET:-}
|
|
||||||
AUTH_COOKIE_SECURE: ${AUTH_COOKIE_SECURE:-false}
|
|
||||||
OLYMPUS_INTAKE_API_URL: ${OLYMPUS_INTAKE_API_URL:-}
|
OLYMPUS_INTAKE_API_URL: ${OLYMPUS_INTAKE_API_URL:-}
|
||||||
OLYMPUS_INTAKE_API_TOKEN: ${OLYMPUS_INTAKE_API_TOKEN:-}
|
OLYMPUS_INTAKE_API_TOKEN: ${OLYMPUS_INTAKE_API_TOKEN:-}
|
||||||
OLYMPUS_PUBLIC_STATUS_API_URL: ${OLYMPUS_PUBLIC_STATUS_API_URL:-}
|
ports:
|
||||||
OLYMPUS_PUBLIC_STATUS_API_TOKEN: ${OLYMPUS_PUBLIC_STATUS_API_TOKEN:-}
|
- "3010:3010"
|
||||||
DOCKER_ENV: "true"
|
|
||||||
volumes:
|
|
||||||
- funktechnik-data:/app/data
|
|
||||||
- funktechnik-storage:/app/storage
|
|
||||||
- funktechnik-next-cache:/app/.next/cache
|
|
||||||
restart: unless-stopped
|
|
||||||
|
|
||||||
volumes:
|
|
||||||
funktechnik-data:
|
|
||||||
funktechnik-storage:
|
|
||||||
funktechnik-next-cache:
|
|
||||||
|
|
|
||||||
|
|
@ -1,69 +0,0 @@
|
||||||
import { createHmac, timingSafeEqual } from "crypto";
|
|
||||||
import { cookies } from "next/headers";
|
|
||||||
|
|
||||||
export const adminSessionCookie = "fs_admin_session";
|
|
||||||
|
|
||||||
const sessionMaxAgeSeconds = 60 * 60 * 8;
|
|
||||||
|
|
||||||
function secret() {
|
|
||||||
return process.env.ADMIN_SESSION_SECRET ?? "dev-admin-session-secret-change-me";
|
|
||||||
}
|
|
||||||
|
|
||||||
function adminEmail() {
|
|
||||||
return process.env.ADMIN_EMAIL;
|
|
||||||
}
|
|
||||||
|
|
||||||
function adminPassword() {
|
|
||||||
return process.env.ADMIN_PASSWORD;
|
|
||||||
}
|
|
||||||
|
|
||||||
function sign(value: string) {
|
|
||||||
return createHmac("sha256", secret()).update(value).digest("base64url");
|
|
||||||
}
|
|
||||||
|
|
||||||
function safeEqual(left: string, right: string) {
|
|
||||||
const leftBuffer = Buffer.from(left);
|
|
||||||
const rightBuffer = Buffer.from(right);
|
|
||||||
return leftBuffer.length === rightBuffer.length && timingSafeEqual(leftBuffer, rightBuffer);
|
|
||||||
}
|
|
||||||
|
|
||||||
export function validateAdminCredentials(email: string, password: string) {
|
|
||||||
const configuredEmail = adminEmail();
|
|
||||||
const configuredPassword = adminPassword();
|
|
||||||
if (!configuredEmail || !configuredPassword) return false;
|
|
||||||
return safeEqual(email.trim().toLowerCase(), configuredEmail.toLowerCase()) && safeEqual(password, configuredPassword);
|
|
||||||
}
|
|
||||||
|
|
||||||
export function createAdminSession() {
|
|
||||||
const expiresAt = Date.now() + sessionMaxAgeSeconds * 1000;
|
|
||||||
const payload = Buffer.from(JSON.stringify({ sub: adminEmail() ?? "admin", exp: expiresAt })).toString("base64url");
|
|
||||||
return `${payload}.${sign(payload)}`;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function verifyAdminSession(token?: string) {
|
|
||||||
if (!token) return false;
|
|
||||||
const [payload, signature] = token.split(".");
|
|
||||||
if (!payload || !signature || !safeEqual(signature, sign(payload))) return false;
|
|
||||||
|
|
||||||
try {
|
|
||||||
const session = JSON.parse(Buffer.from(payload, "base64url").toString("utf8")) as { exp?: number };
|
|
||||||
return typeof session.exp === "number" && session.exp > Date.now();
|
|
||||||
} catch {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function requireAdminSession() {
|
|
||||||
const cookieStore = await cookies();
|
|
||||||
return verifyAdminSession(cookieStore.get(adminSessionCookie)?.value);
|
|
||||||
}
|
|
||||||
|
|
||||||
export function adminCookieOptions() {
|
|
||||||
return {
|
|
||||||
httpOnly: true,
|
|
||||||
sameSite: "lax" as const,
|
|
||||||
secure: process.env.AUTH_COOKIE_SECURE === "true",
|
|
||||||
path: "/",
|
|
||||||
maxAge: sessionMaxAgeSeconds,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
@ -1,155 +0,0 @@
|
||||||
import { randomUUID } from "crypto";
|
|
||||||
import { mkdir, readFile, readdir, rm, stat, writeFile } from "fs/promises";
|
|
||||||
import path from "path";
|
|
||||||
import { migrateLegacyUploads, uploadDirectory } from "@/lib/runtime/config";
|
|
||||||
|
|
||||||
const maxUploadSize = 5 * 1024 * 1024;
|
|
||||||
|
|
||||||
const allowedMimeTypes = new Map([
|
|
||||||
["image/jpeg", [".jpg", ".jpeg"]],
|
|
||||||
["image/png", [".png"]],
|
|
||||||
["image/webp", [".webp"]],
|
|
||||||
["application/pdf", [".pdf"]],
|
|
||||||
]);
|
|
||||||
|
|
||||||
export type MediaFile = {
|
|
||||||
name: string;
|
|
||||||
url: string;
|
|
||||||
type: "image" | "pdf";
|
|
||||||
extension: string;
|
|
||||||
size: number;
|
|
||||||
uploadedAt: string;
|
|
||||||
isImage: boolean;
|
|
||||||
isPdf: boolean;
|
|
||||||
};
|
|
||||||
|
|
||||||
export function getMediaMimeType(fileName: string) {
|
|
||||||
const extension = path.extname(fileName).toLowerCase();
|
|
||||||
if (extension === ".jpg" || extension === ".jpeg") return "image/jpeg";
|
|
||||||
if (extension === ".png") return "image/png";
|
|
||||||
if (extension === ".webp") return "image/webp";
|
|
||||||
if (extension === ".pdf") return "application/pdf";
|
|
||||||
return "";
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function ensureUploadDirectory() {
|
|
||||||
await mkdir(uploadDirectory, { recursive: true });
|
|
||||||
const probe = path.join(uploadDirectory, `.write-check-${Date.now()}`);
|
|
||||||
await writeFile(probe, "ok", "utf8");
|
|
||||||
await rm(probe, { force: true });
|
|
||||||
}
|
|
||||||
|
|
||||||
export function validateUpload(file: File) {
|
|
||||||
const extensions = allowedMimeTypes.get(file.type);
|
|
||||||
const extension = path.extname(file.name).toLowerCase();
|
|
||||||
|
|
||||||
if (!extensions || !extensions.includes(extension)) {
|
|
||||||
return "Bitte eine JPG-, PNG-, WebP- oder PDF-Datei hochladen.";
|
|
||||||
}
|
|
||||||
|
|
||||||
if (file.size <= 0) {
|
|
||||||
return "Die Datei ist leer.";
|
|
||||||
}
|
|
||||||
|
|
||||||
if (file.size > maxUploadSize) {
|
|
||||||
return "Die Datei darf maximal 5 MB groß sein.";
|
|
||||||
}
|
|
||||||
|
|
||||||
return "";
|
|
||||||
}
|
|
||||||
|
|
||||||
export function createSafeUploadName(originalName: string) {
|
|
||||||
const extension = path.extname(originalName).toLowerCase();
|
|
||||||
const rawBaseName = path.basename(originalName, extension);
|
|
||||||
const safeBaseName = rawBaseName
|
|
||||||
.toLowerCase()
|
|
||||||
.normalize("NFKD")
|
|
||||||
.replace(/[\u0300-\u036f]/g, "")
|
|
||||||
.replace(/[^a-z0-9]+/g, "-")
|
|
||||||
.replace(/^-+|-+$/g, "")
|
|
||||||
.slice(0, 80) || "upload";
|
|
||||||
|
|
||||||
return `${Date.now()}-${randomUUID().slice(0, 8)}-${safeBaseName}${extension}`;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function resolveUploadTarget(fileName: string) {
|
|
||||||
const target = path.resolve(uploadDirectory, fileName);
|
|
||||||
const uploadRoot = path.resolve(uploadDirectory);
|
|
||||||
|
|
||||||
if (!target.startsWith(`${uploadRoot}${path.sep}`)) {
|
|
||||||
throw new Error("Ungültiger Upload-Pfad.");
|
|
||||||
}
|
|
||||||
|
|
||||||
return target;
|
|
||||||
}
|
|
||||||
|
|
||||||
function isAllowedStoredFile(fileName: string) {
|
|
||||||
const extension = path.extname(fileName).toLowerCase();
|
|
||||||
const allowedExtensions = [...allowedMimeTypes.values()].flat();
|
|
||||||
return !fileName.startsWith(".") && allowedExtensions.includes(extension);
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function listMediaFiles(): Promise<MediaFile[]> {
|
|
||||||
await migrateLegacyUploads();
|
|
||||||
await mkdir(uploadDirectory, { recursive: true });
|
|
||||||
const entries = await readdir(uploadDirectory);
|
|
||||||
const visibleEntries = entries.filter(isAllowedStoredFile);
|
|
||||||
const files = await Promise.all(visibleEntries.map(async (entry) => {
|
|
||||||
const fileStat = await stat(path.join(uploadDirectory, entry));
|
|
||||||
const extension = path.extname(entry).toLowerCase();
|
|
||||||
const isImage = [".jpg", ".jpeg", ".png", ".webp"].includes(extension);
|
|
||||||
const isPdf = extension === ".pdf";
|
|
||||||
|
|
||||||
return {
|
|
||||||
name: entry,
|
|
||||||
url: `/api/media/${encodeURIComponent(entry)}`,
|
|
||||||
type: isPdf ? "pdf" as const : "image" as const,
|
|
||||||
extension: extension.replace(".", "").toUpperCase() || "Datei",
|
|
||||||
size: fileStat.size,
|
|
||||||
uploadedAt: fileStat.birthtime.toISOString(),
|
|
||||||
isImage,
|
|
||||||
isPdf,
|
|
||||||
};
|
|
||||||
}));
|
|
||||||
|
|
||||||
return files.sort((left, right) => right.uploadedAt.localeCompare(left.uploadedAt));
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function saveMediaFile(file: File) {
|
|
||||||
const error = validateUpload(file);
|
|
||||||
if (error) return { error };
|
|
||||||
|
|
||||||
await ensureUploadDirectory();
|
|
||||||
const safeName = createSafeUploadName(file.name);
|
|
||||||
const target = resolveUploadTarget(safeName);
|
|
||||||
await writeFile(target, Buffer.from(await file.arrayBuffer()));
|
|
||||||
|
|
||||||
return { fileName: safeName };
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function readMediaFile(fileName: string) {
|
|
||||||
if (!isAllowedStoredFile(fileName) || path.basename(fileName) !== fileName) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
const mimeType = getMediaMimeType(fileName);
|
|
||||||
if (!mimeType) return null;
|
|
||||||
|
|
||||||
const target = resolveUploadTarget(fileName);
|
|
||||||
|
|
||||||
try {
|
|
||||||
const [data, fileStat] = await Promise.all([readFile(target), stat(target)]);
|
|
||||||
return { data, mimeType, size: fileStat.size, uploadedAt: fileStat.birthtime.toISOString() };
|
|
||||||
} catch {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function deleteMediaFile(fileName: string) {
|
|
||||||
if (!isAllowedStoredFile(fileName) || path.basename(fileName) !== fileName) {
|
|
||||||
return { error: "Ungültiger Dateiname." };
|
|
||||||
}
|
|
||||||
|
|
||||||
await rm(resolveUploadTarget(fileName), { force: true });
|
|
||||||
return {};
|
|
||||||
}
|
|
||||||
|
|
@ -1,119 +0,0 @@
|
||||||
import { mkdir, readFile, writeFile } from "fs/promises";
|
|
||||||
import path from "path";
|
|
||||||
import { configDirectory, dataDirectory } from "@/lib/runtime/config";
|
|
||||||
import type { ContactInquiry, InquiryStatus, RepairInquiry, SiteSettings } from "./types";
|
|
||||||
|
|
||||||
async function ensureDataDir() {
|
|
||||||
await mkdir(dataDirectory, { recursive: true });
|
|
||||||
}
|
|
||||||
|
|
||||||
async function readJson<T>(fileName: string, fallback: T): Promise<T> {
|
|
||||||
await ensureDataDir();
|
|
||||||
try {
|
|
||||||
const file = await readFile(path.join(dataDirectory, fileName), "utf8");
|
|
||||||
return JSON.parse(file) as T;
|
|
||||||
} catch {
|
|
||||||
return fallback;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async function writeJson<T>(fileName: string, value: T) {
|
|
||||||
await ensureDataDir();
|
|
||||||
await writeFile(path.join(dataDirectory, fileName), `${JSON.stringify(value, null, 2)}\n`, "utf8");
|
|
||||||
}
|
|
||||||
|
|
||||||
function id(prefix: string) {
|
|
||||||
return `${prefix}_${Date.now().toString(36)}_${Math.random().toString(36).slice(2, 8)}`;
|
|
||||||
}
|
|
||||||
|
|
||||||
function text(value: FormDataEntryValue | null) {
|
|
||||||
return typeof value === "string" ? value.trim() : "";
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function getContactInquiries() {
|
|
||||||
return readJson<ContactInquiry[]>("contact-inquiries.json", []);
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function getRepairInquiries() {
|
|
||||||
return readJson<RepairInquiry[]>("repair-inquiries.json", []);
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function addContactInquiry(form: FormData) {
|
|
||||||
const inquiries = await getContactInquiries();
|
|
||||||
const inquiry: ContactInquiry = {
|
|
||||||
id: id("contact"),
|
|
||||||
createdAt: new Date().toISOString(),
|
|
||||||
status: "new",
|
|
||||||
name: text(form.get("name")),
|
|
||||||
email: text(form.get("email")),
|
|
||||||
phone: text(form.get("phone")),
|
|
||||||
subject: text(form.get("subject")) || "Kontaktanfrage",
|
|
||||||
message: text(form.get("message")),
|
|
||||||
};
|
|
||||||
await writeJson("contact-inquiries.json", [inquiry, ...inquiries]);
|
|
||||||
return inquiry;
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function addRepairInquiry(form: FormData) {
|
|
||||||
const inquiries = await getRepairInquiries();
|
|
||||||
const inquiry: RepairInquiry = {
|
|
||||||
id: id("repair"),
|
|
||||||
createdAt: new Date().toISOString(),
|
|
||||||
status: "new",
|
|
||||||
name: text(form.get("name")),
|
|
||||||
email: text(form.get("email")),
|
|
||||||
phone: text(form.get("phone")),
|
|
||||||
manufacturer: text(form.get("manufacturer")),
|
|
||||||
model: text(form.get("model")),
|
|
||||||
serialNumber: text(form.get("serialNumber")),
|
|
||||||
deviceType: text(form.get("deviceType")),
|
|
||||||
accessories: text(form.get("accessories")),
|
|
||||||
opened: text(form.get("opened")),
|
|
||||||
previousWork: text(form.get("previousWork")),
|
|
||||||
description: text(form.get("description")),
|
|
||||||
};
|
|
||||||
await writeJson("repair-inquiries.json", [inquiry, ...inquiries]);
|
|
||||||
return inquiry;
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function updateContactStatus(id: string, status: InquiryStatus) {
|
|
||||||
const inquiries = await getContactInquiries();
|
|
||||||
await writeJson("contact-inquiries.json", inquiries.map((item) => item.id === id ? { ...item, status } : item));
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function updateRepairStatus(id: string, status: InquiryStatus) {
|
|
||||||
const inquiries = await getRepairInquiries();
|
|
||||||
await writeJson("repair-inquiries.json", inquiries.map((item) => item.id === id ? { ...item, status } : item));
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function getSiteSettings() {
|
|
||||||
return readJson<SiteSettings>("site-settings.json", {
|
|
||||||
companyName: "Funktechnik Schubert",
|
|
||||||
phone: "",
|
|
||||||
email: "info@funktechnik-schubert.de",
|
|
||||||
address: "",
|
|
||||||
openingHours: "",
|
|
||||||
googleMapsEmbedUrl: "",
|
|
||||||
socialLinks: "",
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function saveSiteSettings(form: FormData) {
|
|
||||||
const settings: SiteSettings = {
|
|
||||||
companyName: text(form.get("companyName")),
|
|
||||||
phone: text(form.get("phone")),
|
|
||||||
email: text(form.get("email")),
|
|
||||||
address: text(form.get("address")),
|
|
||||||
openingHours: text(form.get("openingHours")),
|
|
||||||
googleMapsEmbedUrl: text(form.get("googleMapsEmbedUrl")),
|
|
||||||
socialLinks: text(form.get("socialLinks")),
|
|
||||||
};
|
|
||||||
await writeJson("site-settings.json", settings);
|
|
||||||
return settings;
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function ensureConfigDir() {
|
|
||||||
await mkdir(configDirectory, { recursive: true });
|
|
||||||
}
|
|
||||||
|
|
||||||
export { text };
|
|
||||||
|
|
@ -1,56 +0,0 @@
|
||||||
export type InquiryStatus = "new" | "in_progress" | "done";
|
|
||||||
|
|
||||||
export type ContactInquiry = {
|
|
||||||
id: string;
|
|
||||||
createdAt: string;
|
|
||||||
status: InquiryStatus;
|
|
||||||
name: string;
|
|
||||||
email: string;
|
|
||||||
phone?: string;
|
|
||||||
subject?: string;
|
|
||||||
message: string;
|
|
||||||
};
|
|
||||||
|
|
||||||
export type RepairInquiry = {
|
|
||||||
id: string;
|
|
||||||
createdAt: string;
|
|
||||||
status: InquiryStatus;
|
|
||||||
name: string;
|
|
||||||
email: string;
|
|
||||||
phone?: string;
|
|
||||||
manufacturer: string;
|
|
||||||
model: string;
|
|
||||||
serialNumber?: string;
|
|
||||||
deviceType: string;
|
|
||||||
accessories?: string;
|
|
||||||
opened?: string;
|
|
||||||
previousWork?: string;
|
|
||||||
description: string;
|
|
||||||
};
|
|
||||||
|
|
||||||
export type SiteSettings = {
|
|
||||||
companyName: string;
|
|
||||||
phone: string;
|
|
||||||
email: string;
|
|
||||||
address: string;
|
|
||||||
openingHours: string;
|
|
||||||
googleMapsEmbedUrl: string;
|
|
||||||
socialLinks: string;
|
|
||||||
};
|
|
||||||
|
|
||||||
export type SmtpSettings = {
|
|
||||||
host: string;
|
|
||||||
port: string;
|
|
||||||
username: string;
|
|
||||||
password?: string;
|
|
||||||
security: "starttls" | "tls" | "none";
|
|
||||||
fromAddress: string;
|
|
||||||
replyToAddress: string;
|
|
||||||
recipientAddress: string;
|
|
||||||
bccAddress?: string;
|
|
||||||
lastTestAt?: string;
|
|
||||||
lastTestStatus?: "success" | "error";
|
|
||||||
lastDeliveryAt?: string;
|
|
||||||
lastDeliveryStatus?: "success" | "error";
|
|
||||||
lastError?: string;
|
|
||||||
};
|
|
||||||
|
|
@ -1,205 +0,0 @@
|
||||||
import type { ContentDocuments, SeoContent } from "./types";
|
|
||||||
|
|
||||||
const now = "2026-01-01T00:00:00.000Z";
|
|
||||||
|
|
||||||
function seo(title: string, description: string, path: string): SeoContent {
|
|
||||||
return {
|
|
||||||
metaTitle: title,
|
|
||||||
metaDescription: description,
|
|
||||||
keywords: "Funktechnik Schubert, Funkgeräte Reparatur, CB Funk Service, Amateurfunk Service, Funkgeräte Abgleich, Messtechnik",
|
|
||||||
openGraphTitle: title,
|
|
||||||
openGraphDescription: description,
|
|
||||||
socialImage: "/funktechnik_schubert_logo.jpg",
|
|
||||||
canonicalUrl: path,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
export const defaultContent: ContentDocuments = {
|
|
||||||
home: {
|
|
||||||
eyebrow: "Funktechnik, Messtechnik, Werkstattservice",
|
|
||||||
title: "Funktechnik Schubert",
|
|
||||||
subtitle: "Service, Reparatur und Diagnose für Funkgeräte, Messtechnik und Kommunikationstechnik.",
|
|
||||||
heroText: "Von CB-Funk und Amateurfunk bis zur professionellen Funktechnik: Wir unterstützen bei Fehlersuche, Abgleich, Modulationsproblemen, Frequenzproblemen und technischer Dokumentation.",
|
|
||||||
heroImage: "/workbench-signal.svg",
|
|
||||||
intro: "Von der ersten Fehlerbeschreibung bis zur dokumentierten Prüfung: Funktechnik Schubert verbindet technische Erfahrung, geeignete Messmittel und strukturierte Kommunikation.",
|
|
||||||
paragraphs: [],
|
|
||||||
cta: {
|
|
||||||
text: "Direkte technische Einschätzung statt anonymer Abwicklung",
|
|
||||||
primary: { label: "Reparatur anfragen", href: "/reparatur" },
|
|
||||||
secondary: { label: "Leistungen ansehen", href: "/leistungen" },
|
|
||||||
},
|
|
||||||
listTitle: "Technische Schwerpunkte",
|
|
||||||
list: ["CB-Funk", "Amateurfunk", "Messtechnik", "Abgleich", "Diagnose", "Service Manuals"],
|
|
||||||
badges: ["CB-Funk", "Amateurfunk", "Messtechnik", "Abgleich", "Diagnose", "Service Manuals"],
|
|
||||||
features: [
|
|
||||||
{ title: "Funkgeräte-Service", text: "Sichtprüfung, Funktionsprüfung und technische Beurteilung für CB-Funk, Amateurfunk, Betriebsfunk sowie ältere und moderne Geräte." },
|
|
||||||
{ title: "Fehlersuche & Reparatur", text: "Systematische Diagnose bei Empfangsproblemen, Sendeproblemen, PLL-Fehlern, Frequenzabweichungen und Audio-/NF-Störungen." },
|
|
||||||
{ title: "Abgleich & Messtechnik", text: "Frequenzabgleich, Hub- und Modulationsprüfung, Sendeleistung, Empfindlichkeit und RF-Messung mit geeigneter Messtechnik." },
|
|
||||||
],
|
|
||||||
services: [
|
|
||||||
{ title: "Funkgeräte-Service", text: "Prüfung und technische Einschätzung für CB-Funk, Amateurfunk und Kommunikationstechnik." },
|
|
||||||
{ title: "Messtechnik", text: "Messungen mit geeigneter Werkstattpraxis und nachvollziehbarer Dokumentation." },
|
|
||||||
{ title: "Serviceunterlagen", text: "Einordnung von Schaltplänen, Service Manuals und Abgleichhinweisen." },
|
|
||||||
],
|
|
||||||
promise: {
|
|
||||||
eyebrow: "Persönlicher Support",
|
|
||||||
title: "Direkte technische Einschätzung statt anonymer Abwicklung",
|
|
||||||
text: "Viele Fehlerbilder lassen sich bereits mit einer klaren Beschreibung und den richtigen Gerätedaten eingrenzen.",
|
|
||||||
bullets: [
|
|
||||||
"Technischer Fokus auf Funkgeräte und Kommunikationstechnik",
|
|
||||||
"Nachvollziehbare Diagnose mit Blick auf Messwerte und Fehlerbild",
|
|
||||||
"Sauberer Umgang mit Service Manuals, Schaltplänen und Abgleichanleitungen",
|
|
||||||
"Klare Rückmeldung zu Zustand, Aufwand und sinnvollen nächsten Schritten",
|
|
||||||
],
|
|
||||||
button: { label: "Reparaturformular öffnen", href: "/reparatur" },
|
|
||||||
},
|
|
||||||
footerText: "Service und technische Unterstützung für Funkgeräte, Messtechnik und Kommunikationstechnik.",
|
|
||||||
faq: [],
|
|
||||||
seo: seo("Funktechnik Schubert", "Service, Reparatur, Diagnose und Abgleich von Funktechnik, CB-Funk, Amateurfunk und Kommunikationstechnik.", "/"),
|
|
||||||
updatedAt: now,
|
|
||||||
},
|
|
||||||
services: {
|
|
||||||
eyebrow: "Leistungen",
|
|
||||||
title: "Service für Funkgeräte und Kommunikationstechnik",
|
|
||||||
subtitle: "Technische Unterstützung für Geräte, bei denen Diagnose, Messtechnik, Erfahrung und saubere Dokumentation zählen.",
|
|
||||||
heroText: "Leistungen für private und professionelle Funktechnik mit klarem Fokus auf Diagnose, Abgleich und Reparatur.",
|
|
||||||
heroImage: "/workbench-signal.svg",
|
|
||||||
intro: "Die Leistungen können im Adminbereich sortiert, erweitert und ein- oder ausgeblendet werden.",
|
|
||||||
paragraphs: [],
|
|
||||||
cta: { text: "Reparaturfall vorbereiten", primary: { label: "Reparatur anfragen", href: "/reparatur" }, secondary: { label: "Kontakt aufnehmen", href: "/kontakt" } },
|
|
||||||
listTitle: "Leistungen",
|
|
||||||
list: [],
|
|
||||||
services: [
|
|
||||||
{ title: "Funkgeräte-Service", description: "Service für CB-Funk, Amateurfunk, Betriebsfunk sowie ältere und moderne Funkgeräte.", icon: "radio", sortOrder: 10, visible: true },
|
|
||||||
{ title: "Fehlersuche & Reparatur", description: "Strukturierte Analyse bei Modulationsproblemen, Frequenzabweichung, PLL-Problemen und Empfangs- oder Sendeproblemen.", icon: "tools", sortOrder: 20, visible: true },
|
|
||||||
{ title: "Abgleich & Prüfung", description: "Frequenzabgleich, Hub- und Modulationsprüfung, Sendeleistung, Empfindlichkeit und Funktionsprüfung.", icon: "meter", sortOrder: 30, visible: true },
|
|
||||||
{ title: "Messtechnik & Diagnose", description: "Systematische Fehlersuche mit Oszilloskop, Frequenzzähler, Signalgenerator, RF-Messung und Modulationsmessung.", icon: "scope", sortOrder: 40, visible: true },
|
|
||||||
{ title: "Serviceunterlagen", description: "Einordnung von Schaltplänen, Service Manuals, Abgleichanleitungen und Reparaturhinweisen.", icon: "manual", sortOrder: 50, visible: true },
|
|
||||||
],
|
|
||||||
faq: [],
|
|
||||||
seo: seo("Leistungen", "Funkgeräte-Service, Diagnose, Abgleich und Dokumentation für Kommunikationstechnik.", "/leistungen"),
|
|
||||||
updatedAt: now,
|
|
||||||
},
|
|
||||||
"radio-service": {
|
|
||||||
eyebrow: "Funkgeräte-Service",
|
|
||||||
title: "Prüfung, Diagnose und Abgleich",
|
|
||||||
subtitle: "Funkgeräte zeigen Fehler oft erst im Zusammenspiel von Empfang, Sendeteil, Versorgung, Antennenanpassung, Bedienung und Abgleich.",
|
|
||||||
heroText: "Der Service betrachtet diese Zusammenhänge strukturiert und dokumentiert die technischen Schritte nachvollziehbar.",
|
|
||||||
heroImage: "/workbench-signal.svg",
|
|
||||||
intro: "Eine gute Reparatur beginnt mit vollständigen Angaben zu Gerät, Fehlerbild und Vorgeschichte.",
|
|
||||||
paragraphs: [],
|
|
||||||
cta: { text: "Reparaturprozess starten", primary: { label: "Reparatur anfragen", href: "/reparatur" }, secondary: { label: "Kontakt", href: "/kontakt" } },
|
|
||||||
listTitle: "Typische Fehlerbilder",
|
|
||||||
list: [],
|
|
||||||
brands: ["CB-Funk", "Amateurfunk", "Betriebsfunk"],
|
|
||||||
symptoms: [
|
|
||||||
"Kein oder schwacher Empfang",
|
|
||||||
"Keine, leise oder verzerrte Modulation",
|
|
||||||
"Frequenzversatz, PLL-Rasten oder instabile Kanäle",
|
|
||||||
"Sendeprobleme, schwankende Leistung oder auffällige Erwärmung",
|
|
||||||
"Audio-/NF-Probleme, Displayfehler oder Aussetzer durch Bedienelemente",
|
|
||||||
"Unklare Vorarbeiten, fehlende Serviceunterlagen oder bereits geöffnete Geräte",
|
|
||||||
],
|
|
||||||
workflow: ["Anfrage", "Technische Sichtung", "Messung", "Abstimmung", "Reparatur oder Rückgabe"],
|
|
||||||
measurements: ["Frequenz", "Sendeleistung", "Hub", "Modulation", "Empfindlichkeit"],
|
|
||||||
alignment: "Abgleich erfolgt anhand technischer Unterlagen und sinnvoller Messpunkte.",
|
|
||||||
repair: "Reparaturen werden vor Durchführung technisch eingeordnet und abgestimmt.",
|
|
||||||
faq: [],
|
|
||||||
seo: seo("Funkgeräte-Service", "Werkstattservice für CB-Funk, Amateurfunk und Funkgeräte-Abgleich.", "/funkgeraete-service"),
|
|
||||||
updatedAt: now,
|
|
||||||
},
|
|
||||||
repair: {
|
|
||||||
eyebrow: "Reparaturannahme",
|
|
||||||
title: "Reparatur anfragen",
|
|
||||||
subtitle: "Beschreiben Sie Gerät, Fehlerbild, Zubehör und bisherige Vorarbeiten.",
|
|
||||||
heroText: "Die Anfrage wird strukturiert erfasst und für eine spätere technische Bearbeitung vorbereitet.",
|
|
||||||
heroImage: "/workbench-signal.svg",
|
|
||||||
intro: "Bitte senden Sie Geräte erst nach Rückmeldung ein. Vollständige Gerätedaten und eine präzise Fehlerbeschreibung helfen, den Aufwand besser einzuschätzen.",
|
|
||||||
paragraphs: [
|
|
||||||
{ title: "Vor der Einsendung", text: "Bitte senden Sie Geräte erst nach Rückmeldung ein. Vollständige Gerätedaten und eine präzise Fehlerbeschreibung helfen, den Aufwand besser einzuschätzen." },
|
|
||||||
],
|
|
||||||
cta: { text: "Reparatur vorbereiten", primary: { label: "Formular ausfüllen", href: "/reparatur" }, secondary: { label: "Kontakt", href: "/kontakt" } },
|
|
||||||
listTitle: "Wichtige Angaben",
|
|
||||||
list: [
|
|
||||||
"Hersteller, Modell, Geräteart und Seriennummer bereithalten, soweit vorhanden",
|
|
||||||
"Fehler möglichst konkret beschreiben: Empfang, Sendung, Modulation, Frequenz, Anzeige oder Versorgung",
|
|
||||||
"Zubehör wie Netzteil, Mikrofon, Antennenadapter oder Kabel angeben",
|
|
||||||
"Bereits geöffnete Geräte und durchgeführte Arbeiten ehrlich nennen",
|
|
||||||
"Fotos können später nach Rückmeldung ergänzt werden",
|
|
||||||
],
|
|
||||||
faq: [],
|
|
||||||
seo: seo("Reparaturannahme", "Reparaturanfrage für Funkgeräte strukturiert vorbereiten.", "/reparatur"),
|
|
||||||
updatedAt: now,
|
|
||||||
},
|
|
||||||
about: {
|
|
||||||
eyebrow: "Über uns",
|
|
||||||
title: "Technik verstehen, Fehler nachvollziehbar lösen",
|
|
||||||
subtitle: "Funktechnik Schubert richtet sich an Kunden, die bei Funkgeräten und Kommunikationstechnik eine persönliche, technische Einschätzung suchen.",
|
|
||||||
heroText: "Im Mittelpunkt stehen saubere Diagnose, transparente Kommunikation und respektvoller Umgang mit bestehenden Geräten.",
|
|
||||||
heroImage: "/workbench-signal.svg",
|
|
||||||
intro: "Technischer Service lebt von Genauigkeit, Erfahrung und klarer Rückmeldung.",
|
|
||||||
paragraphs: [
|
|
||||||
{ title: "Arbeitsweise", text: "Im Mittelpunkt stehen saubere Diagnose, transparente Kommunikation und der respektvolle Umgang mit bestehenden Geräten und Serviceunterlagen." },
|
|
||||||
{ title: "Fokus", text: "Der Schwerpunkt liegt auf Funktechnik, Elektronikservice, Abgleichfragen und technischer Unterstützung rund um Kommunikationstechnik." },
|
|
||||||
],
|
|
||||||
cta: { text: "Mehr zur Werkstatt", primary: { label: "Kontakt aufnehmen", href: "/kontakt" }, secondary: { label: "Leistungen", href: "/leistungen" } },
|
|
||||||
listTitle: "Grundsätze",
|
|
||||||
list: ["Nachvollziehbare technische Einschätzung", "Klare Kommunikation", "Sorgfältiger Umgang mit Geräten"],
|
|
||||||
companyDescription: "Funktechnik Schubert bietet technischen Service für Funkgeräte, Messtechnik und Kommunikationstechnik.",
|
|
||||||
workshopDescription: "Die Werkstatt arbeitet strukturiert mit Messmitteln, Dokumentation und technischer Prüfung.",
|
|
||||||
philosophy: "Reparatur und Service sollen nachvollziehbar, ehrlich und technisch sauber bleiben.",
|
|
||||||
faq: [],
|
|
||||||
seo: seo("Über uns", "Funktechnik Schubert steht für technischen Service und klare Kommunikation.", "/ueber-uns"),
|
|
||||||
updatedAt: now,
|
|
||||||
},
|
|
||||||
contact: {
|
|
||||||
eyebrow: "Kontakt",
|
|
||||||
title: "Kontakt aufnehmen",
|
|
||||||
subtitle: "Beschreiben Sie kurz Ihr Anliegen. Für Reparaturen nutzen Sie idealerweise die strukturierte Reparaturannahme.",
|
|
||||||
heroText: "Je genauer Gerät, Anliegen und gewünschte Unterstützung beschrieben werden, desto gezielter kann die Rückmeldung erfolgen.",
|
|
||||||
heroImage: "/workbench-signal.svg",
|
|
||||||
intro: "Direkt und technisch: Nutzen Sie das Kontaktformular oder die Reparaturannahme.",
|
|
||||||
paragraphs: [],
|
|
||||||
cta: { text: "Anfrage senden", primary: { label: "Reparatur anfragen", href: "/reparatur" }, secondary: { label: "Leistungen", href: "/leistungen" } },
|
|
||||||
listTitle: "Kontaktinformationen",
|
|
||||||
list: [],
|
|
||||||
phone: "",
|
|
||||||
email: "",
|
|
||||||
address: "",
|
|
||||||
openingHours: "",
|
|
||||||
googleMapsLink: "",
|
|
||||||
socialMedia: [],
|
|
||||||
faq: [],
|
|
||||||
seo: seo("Kontakt", "Kontakt zu Funktechnik Schubert aufnehmen.", "/kontakt"),
|
|
||||||
updatedAt: now,
|
|
||||||
},
|
|
||||||
settings: {
|
|
||||||
siteName: "Funktechnik Schubert",
|
|
||||||
logoUrl: "/funktechnik_schubert_logo.jpg",
|
|
||||||
footerShortText: "Service und technische Unterstützung für Funkgeräte, Messtechnik und Kommunikationstechnik.",
|
|
||||||
copyright: "© Funktechnik Schubert",
|
|
||||||
footerLinks: [
|
|
||||||
{ label: "Leistungen", href: "/leistungen" },
|
|
||||||
{ label: "Funkgeräte-Service", href: "/funkgeraete-service" },
|
|
||||||
{ label: "Reparatur", href: "/reparatur" },
|
|
||||||
{ label: "Kontakt", href: "/kontakt" },
|
|
||||||
],
|
|
||||||
legalLinks: [
|
|
||||||
{ label: "Impressum", href: "/impressum" },
|
|
||||||
{ label: "Datenschutz", href: "/datenschutz" },
|
|
||||||
],
|
|
||||||
headerCta: { label: "Reparatur anfragen", href: "/reparatur" },
|
|
||||||
seo: seo("Funktechnik Schubert", "Service, Reparatur, Diagnose und Abgleich von Funktechnik, CB-Funk, Amateurfunk und Kommunikationstechnik.", "/"),
|
|
||||||
updatedAt: now,
|
|
||||||
},
|
|
||||||
};
|
|
||||||
|
|
||||||
export const contentFileNames = {
|
|
||||||
home: "home.json",
|
|
||||||
services: "services.json",
|
|
||||||
"radio-service": "radio-service.json",
|
|
||||||
repair: "repair.json",
|
|
||||||
about: "about.json",
|
|
||||||
contact: "contact.json",
|
|
||||||
settings: "settings.json",
|
|
||||||
} as const;
|
|
||||||
|
|
@ -1,176 +0,0 @@
|
||||||
import { existsSync } from "fs";
|
|
||||||
import { mkdir, readdir, readFile, rename, stat, unlink, writeFile } from "fs/promises";
|
|
||||||
import path from "path";
|
|
||||||
import { storageDirectory } from "@/lib/runtime/config";
|
|
||||||
import { listMediaFiles } from "@/lib/admin/media";
|
|
||||||
import { contentFileNames, defaultContent } from "./defaults";
|
|
||||||
import type { ContentBackup, ContentDocuments, ContentKey, ContentSummary } from "./types";
|
|
||||||
|
|
||||||
export const contentDirectory = path.join(storageDirectory, "content");
|
|
||||||
export const contentBackupDirectory = path.join(contentDirectory, "backups");
|
|
||||||
export const contentVersion = "0.4.1";
|
|
||||||
|
|
||||||
const contentKeys = Object.keys(contentFileNames) as ContentKey[];
|
|
||||||
|
|
||||||
function isRecord(value: unknown): value is Record<string, unknown> {
|
|
||||||
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
||||||
}
|
|
||||||
|
|
||||||
function sanitizeString(value: string) {
|
|
||||||
return value
|
|
||||||
.replace(/<script[\s\S]*?>[\s\S]*?<\/script>/gi, "")
|
|
||||||
.replace(/javascript:/gi, "")
|
|
||||||
.replace(/[<>]/g, "")
|
|
||||||
.trim();
|
|
||||||
}
|
|
||||||
|
|
||||||
function sanitizeValue(value: unknown): unknown {
|
|
||||||
if (typeof value === "string") return sanitizeString(value);
|
|
||||||
if (Array.isArray(value)) return value.map(sanitizeValue);
|
|
||||||
if (!isRecord(value)) return value;
|
|
||||||
|
|
||||||
return Object.fromEntries(Object.entries(value).map(([key, entry]) => [key, sanitizeValue(entry)]));
|
|
||||||
}
|
|
||||||
|
|
||||||
function mergeContent<T>(defaults: T, incoming: unknown): T {
|
|
||||||
if (Array.isArray(defaults)) return Array.isArray(incoming) ? sanitizeValue(incoming) as T : defaults;
|
|
||||||
if (!isRecord(defaults)) return sanitizeValue(incoming ?? defaults) as T;
|
|
||||||
if (!isRecord(incoming)) return defaults;
|
|
||||||
|
|
||||||
const merged: Record<string, unknown> = { ...defaults };
|
|
||||||
for (const [key, value] of Object.entries(defaults)) {
|
|
||||||
merged[key] = mergeContent(value, incoming[key]);
|
|
||||||
}
|
|
||||||
return sanitizeValue(merged) as T;
|
|
||||||
}
|
|
||||||
|
|
||||||
function filePathFor(key: ContentKey) {
|
|
||||||
return path.join(contentDirectory, contentFileNames[key]);
|
|
||||||
}
|
|
||||||
|
|
||||||
function backupStamp() {
|
|
||||||
const date = new Date();
|
|
||||||
const pad = (value: number) => String(value).padStart(2, "0");
|
|
||||||
return `${date.getFullYear()}${pad(date.getMonth() + 1)}${pad(date.getDate())}-${pad(date.getHours())}${pad(date.getMinutes())}${pad(date.getSeconds())}`;
|
|
||||||
}
|
|
||||||
|
|
||||||
async function pruneBackups(key: ContentKey) {
|
|
||||||
const prefix = `${key}.`;
|
|
||||||
const entries = await readdir(contentBackupDirectory).catch(() => []);
|
|
||||||
const backups = await Promise.all(entries
|
|
||||||
.filter((entry) => entry.startsWith(prefix) && entry.endsWith(".json"))
|
|
||||||
.map(async (entry) => {
|
|
||||||
const fullPath = path.join(contentBackupDirectory, entry);
|
|
||||||
const fileStat = await stat(fullPath);
|
|
||||||
return { entry, fullPath, modified: fileStat.mtimeMs };
|
|
||||||
}));
|
|
||||||
|
|
||||||
const obsolete = backups.sort((left, right) => right.modified - left.modified).slice(20);
|
|
||||||
await Promise.all(obsolete.map((entry) => unlink(entry.fullPath).catch(() => undefined)));
|
|
||||||
}
|
|
||||||
|
|
||||||
async function ensureContentDocument<K extends ContentKey>(key: K) {
|
|
||||||
await mkdir(contentDirectory, { recursive: true });
|
|
||||||
await mkdir(contentBackupDirectory, { recursive: true });
|
|
||||||
|
|
||||||
const target = filePathFor(key);
|
|
||||||
if (!existsSync(target)) {
|
|
||||||
await writeFile(target, `${JSON.stringify(defaultContent[key], null, 2)}\n`, "utf8");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function ensureContentStorage() {
|
|
||||||
await mkdir(contentDirectory, { recursive: true });
|
|
||||||
await mkdir(contentBackupDirectory, { recursive: true });
|
|
||||||
await Promise.all(contentKeys.map((key) => ensureContentDocument(key)));
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function getContent<K extends ContentKey>(key: K): Promise<ContentDocuments[K]> {
|
|
||||||
await ensureContentDocument(key);
|
|
||||||
|
|
||||||
try {
|
|
||||||
const raw = await readFile(filePathFor(key), "utf8");
|
|
||||||
const parsed = JSON.parse(raw) as unknown;
|
|
||||||
return mergeContent(defaultContent[key], parsed);
|
|
||||||
} catch {
|
|
||||||
return defaultContent[key];
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function getAllContent(): Promise<ContentDocuments> {
|
|
||||||
const entries = await Promise.all(contentKeys.map(async (key) => [key, await getContent(key)] as const));
|
|
||||||
return Object.fromEntries(entries) as ContentDocuments;
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function saveContent<K extends ContentKey>(key: K, content: ContentDocuments[K]) {
|
|
||||||
await ensureContentDocument(key);
|
|
||||||
const current = filePathFor(key);
|
|
||||||
const backup = path.join(contentBackupDirectory, `${key}.${backupStamp()}.json`);
|
|
||||||
|
|
||||||
if (existsSync(current)) await rename(current, backup);
|
|
||||||
|
|
||||||
const next = mergeContent(defaultContent[key], {
|
|
||||||
...content,
|
|
||||||
updatedAt: new Date().toISOString(),
|
|
||||||
});
|
|
||||||
|
|
||||||
await writeFile(current, `${JSON.stringify(next, null, 2)}\n`, "utf8");
|
|
||||||
await pruneBackups(key);
|
|
||||||
return next;
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function getContentSummary(): Promise<ContentSummary> {
|
|
||||||
await ensureContentStorage();
|
|
||||||
const documents = await Promise.all(contentKeys.map(async (key) => {
|
|
||||||
const content = await getContent(key);
|
|
||||||
return {
|
|
||||||
key,
|
|
||||||
fileName: contentFileNames[key],
|
|
||||||
title: "title" in content ? content.title : content.siteName,
|
|
||||||
updatedAt: content.updatedAt,
|
|
||||||
};
|
|
||||||
}));
|
|
||||||
const lastModified = documents
|
|
||||||
.map((document) => document.updatedAt)
|
|
||||||
.filter(Boolean)
|
|
||||||
.sort()
|
|
||||||
.at(-1) ?? null;
|
|
||||||
|
|
||||||
return {
|
|
||||||
version: contentVersion,
|
|
||||||
lastModified,
|
|
||||||
pageCount: documents.length,
|
|
||||||
documents,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function listContentBackups(limit = 20): Promise<ContentBackup[]> {
|
|
||||||
await ensureContentStorage();
|
|
||||||
const entries = await readdir(contentBackupDirectory).catch(() => []);
|
|
||||||
const backups = await Promise.all(entries
|
|
||||||
.filter((entry) => entry.endsWith(".json"))
|
|
||||||
.map(async (entry) => {
|
|
||||||
const page = entry.split(".")[0] as ContentKey;
|
|
||||||
const fullPath = path.join(contentBackupDirectory, entry);
|
|
||||||
const fileStat = await stat(fullPath);
|
|
||||||
return {
|
|
||||||
page,
|
|
||||||
fileName: entry,
|
|
||||||
createdAt: fileStat.mtime.toISOString(),
|
|
||||||
};
|
|
||||||
}));
|
|
||||||
|
|
||||||
return backups
|
|
||||||
.filter((backup) => contentKeys.includes(backup.page))
|
|
||||||
.sort((left, right) => right.createdAt.localeCompare(left.createdAt))
|
|
||||||
.slice(0, limit);
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function getContentSystemStatus() {
|
|
||||||
const [summary, media, backups] = await Promise.all([getContentSummary(), listMediaFiles(), listContentBackups()]);
|
|
||||||
return {
|
|
||||||
...summary,
|
|
||||||
mediaCount: media.length,
|
|
||||||
backups,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
@ -1,138 +0,0 @@
|
||||||
export type SeoContent = {
|
|
||||||
metaTitle: string;
|
|
||||||
metaDescription: string;
|
|
||||||
keywords: string;
|
|
||||||
openGraphTitle: string;
|
|
||||||
openGraphDescription: string;
|
|
||||||
socialImage: string;
|
|
||||||
canonicalUrl: string;
|
|
||||||
};
|
|
||||||
|
|
||||||
export type LinkContent = {
|
|
||||||
label: string;
|
|
||||||
href: string;
|
|
||||||
};
|
|
||||||
|
|
||||||
export type TextBlockContent = {
|
|
||||||
title: string;
|
|
||||||
text: string;
|
|
||||||
};
|
|
||||||
|
|
||||||
export type FaqContent = {
|
|
||||||
question: string;
|
|
||||||
answer: string;
|
|
||||||
};
|
|
||||||
|
|
||||||
export type ServiceContent = {
|
|
||||||
title: string;
|
|
||||||
description: string;
|
|
||||||
icon: string;
|
|
||||||
sortOrder: number;
|
|
||||||
visible: boolean;
|
|
||||||
};
|
|
||||||
|
|
||||||
export type PageContent = {
|
|
||||||
eyebrow: string;
|
|
||||||
title: string;
|
|
||||||
subtitle: string;
|
|
||||||
heroText: string;
|
|
||||||
heroImage: string;
|
|
||||||
intro: string;
|
|
||||||
paragraphs: TextBlockContent[];
|
|
||||||
cta: {
|
|
||||||
text: string;
|
|
||||||
primary: LinkContent;
|
|
||||||
secondary: LinkContent;
|
|
||||||
};
|
|
||||||
listTitle: string;
|
|
||||||
list: string[];
|
|
||||||
faq: FaqContent[];
|
|
||||||
seo: SeoContent;
|
|
||||||
updatedAt: string;
|
|
||||||
};
|
|
||||||
|
|
||||||
export type HomeContent = PageContent & {
|
|
||||||
badges: string[];
|
|
||||||
features: TextBlockContent[];
|
|
||||||
services: TextBlockContent[];
|
|
||||||
promise: {
|
|
||||||
eyebrow: string;
|
|
||||||
title: string;
|
|
||||||
text: string;
|
|
||||||
bullets: string[];
|
|
||||||
button: LinkContent;
|
|
||||||
};
|
|
||||||
footerText: string;
|
|
||||||
};
|
|
||||||
|
|
||||||
export type ServicesContent = PageContent & {
|
|
||||||
services: ServiceContent[];
|
|
||||||
};
|
|
||||||
|
|
||||||
export type RadioServiceContent = PageContent & {
|
|
||||||
brands: string[];
|
|
||||||
symptoms: string[];
|
|
||||||
workflow: string[];
|
|
||||||
measurements: string[];
|
|
||||||
alignment: string;
|
|
||||||
repair: string;
|
|
||||||
};
|
|
||||||
|
|
||||||
export type AboutContent = PageContent & {
|
|
||||||
companyDescription: string;
|
|
||||||
workshopDescription: string;
|
|
||||||
philosophy: string;
|
|
||||||
};
|
|
||||||
|
|
||||||
export type ContactContent = PageContent & {
|
|
||||||
phone: string;
|
|
||||||
email: string;
|
|
||||||
address: string;
|
|
||||||
openingHours: string;
|
|
||||||
googleMapsLink: string;
|
|
||||||
socialMedia: LinkContent[];
|
|
||||||
};
|
|
||||||
|
|
||||||
export type RepairContent = PageContent;
|
|
||||||
|
|
||||||
export type SettingsContent = {
|
|
||||||
siteName: string;
|
|
||||||
logoUrl: string;
|
|
||||||
footerShortText: string;
|
|
||||||
copyright: string;
|
|
||||||
footerLinks: LinkContent[];
|
|
||||||
legalLinks: LinkContent[];
|
|
||||||
headerCta: LinkContent;
|
|
||||||
seo: SeoContent;
|
|
||||||
updatedAt: string;
|
|
||||||
};
|
|
||||||
|
|
||||||
export type ContentDocuments = {
|
|
||||||
home: HomeContent;
|
|
||||||
services: ServicesContent;
|
|
||||||
"radio-service": RadioServiceContent;
|
|
||||||
repair: RepairContent;
|
|
||||||
about: AboutContent;
|
|
||||||
contact: ContactContent;
|
|
||||||
settings: SettingsContent;
|
|
||||||
};
|
|
||||||
|
|
||||||
export type ContentKey = keyof ContentDocuments;
|
|
||||||
|
|
||||||
export type ContentSummary = {
|
|
||||||
version: string;
|
|
||||||
lastModified: string | null;
|
|
||||||
pageCount: number;
|
|
||||||
documents: Array<{
|
|
||||||
key: ContentKey;
|
|
||||||
fileName: string;
|
|
||||||
title: string;
|
|
||||||
updatedAt: string;
|
|
||||||
}>;
|
|
||||||
};
|
|
||||||
|
|
||||||
export type ContentBackup = {
|
|
||||||
page: ContentKey;
|
|
||||||
fileName: string;
|
|
||||||
createdAt: string;
|
|
||||||
};
|
|
||||||
|
|
@ -1,109 +0,0 @@
|
||||||
import { mkdir, readFile, writeFile } from "fs/promises";
|
|
||||||
import path from "path";
|
|
||||||
import { configDirectory } from "@/lib/runtime/config";
|
|
||||||
import type { SmtpSettings } from "@/lib/admin/types";
|
|
||||||
import type { PublicSmtpSettings } from "./types";
|
|
||||||
|
|
||||||
const smtpConfigFile = path.join(configDirectory, "smtp.json");
|
|
||||||
|
|
||||||
const defaultSmtpSettings: SmtpSettings = {
|
|
||||||
host: "",
|
|
||||||
port: "587",
|
|
||||||
username: "",
|
|
||||||
password: "",
|
|
||||||
security: "starttls",
|
|
||||||
fromAddress: "",
|
|
||||||
replyToAddress: "",
|
|
||||||
recipientAddress: "",
|
|
||||||
bccAddress: "",
|
|
||||||
};
|
|
||||||
|
|
||||||
function text(value: FormDataEntryValue | null) {
|
|
||||||
return typeof value === "string" ? value.trim() : "";
|
|
||||||
}
|
|
||||||
|
|
||||||
function normalizeSecurity(value: string): SmtpSettings["security"] {
|
|
||||||
if (value === "tls" || value === "none") return value;
|
|
||||||
return "starttls";
|
|
||||||
}
|
|
||||||
|
|
||||||
async function ensureConfigDirectory() {
|
|
||||||
await mkdir(configDirectory, { recursive: true });
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function getSmtpSettings(): Promise<SmtpSettings> {
|
|
||||||
await ensureConfigDirectory();
|
|
||||||
try {
|
|
||||||
const file = await readFile(smtpConfigFile, "utf8");
|
|
||||||
return { ...defaultSmtpSettings, ...JSON.parse(file) as SmtpSettings };
|
|
||||||
} catch {
|
|
||||||
return defaultSmtpSettings;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export function toPublicSmtpSettings(settings: SmtpSettings): PublicSmtpSettings {
|
|
||||||
return {
|
|
||||||
host: settings.host,
|
|
||||||
port: settings.port,
|
|
||||||
username: settings.username,
|
|
||||||
security: settings.security,
|
|
||||||
fromAddress: settings.fromAddress,
|
|
||||||
replyToAddress: settings.replyToAddress,
|
|
||||||
recipientAddress: settings.recipientAddress,
|
|
||||||
bccAddress: settings.bccAddress,
|
|
||||||
lastTestAt: settings.lastTestAt,
|
|
||||||
lastTestStatus: settings.lastTestStatus,
|
|
||||||
lastError: settings.lastError,
|
|
||||||
hasPassword: Boolean(settings.password),
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
export function isSmtpConfigured(settings: SmtpSettings) {
|
|
||||||
return Boolean(
|
|
||||||
settings.host &&
|
|
||||||
settings.port &&
|
|
||||||
settings.username &&
|
|
||||||
settings.password &&
|
|
||||||
settings.fromAddress &&
|
|
||||||
settings.recipientAddress,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function saveSmtpSettings(form: FormData) {
|
|
||||||
const current = await getSmtpSettings();
|
|
||||||
const nextPassword = text(form.get("password"));
|
|
||||||
const settings: SmtpSettings = {
|
|
||||||
...current,
|
|
||||||
host: text(form.get("host")),
|
|
||||||
port: text(form.get("port")) || "587",
|
|
||||||
username: text(form.get("username")),
|
|
||||||
password: nextPassword || current.password || "",
|
|
||||||
security: normalizeSecurity(text(form.get("security"))),
|
|
||||||
fromAddress: text(form.get("fromAddress")),
|
|
||||||
replyToAddress: text(form.get("replyToAddress")),
|
|
||||||
recipientAddress: text(form.get("recipientAddress")),
|
|
||||||
bccAddress: text(form.get("bccAddress")),
|
|
||||||
};
|
|
||||||
|
|
||||||
await ensureConfigDirectory();
|
|
||||||
await writeFile(smtpConfigFile, `${JSON.stringify(settings, null, 2)}\n`, "utf8");
|
|
||||||
return settings;
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function updateSmtpTestStatus(status: Pick<SmtpSettings, "lastTestAt" | "lastTestStatus" | "lastError">) {
|
|
||||||
const current = await getSmtpSettings();
|
|
||||||
const settings: SmtpSettings = { ...current, ...status };
|
|
||||||
await ensureConfigDirectory();
|
|
||||||
await writeFile(smtpConfigFile, `${JSON.stringify(settings, null, 2)}\n`, "utf8");
|
|
||||||
return settings;
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function updateSmtpDeliveryStatus(status: Pick<SmtpSettings, "lastDeliveryAt" | "lastDeliveryStatus" | "lastError">) {
|
|
||||||
const current = await getSmtpSettings();
|
|
||||||
const settings: SmtpSettings = { ...current, ...status };
|
|
||||||
await ensureConfigDirectory();
|
|
||||||
await writeFile(smtpConfigFile, `${JSON.stringify(settings, null, 2)}\n`, "utf8");
|
|
||||||
return settings;
|
|
||||||
}
|
|
||||||
|
|
||||||
export { smtpConfigFile };
|
|
||||||
100
lib/mail/smtp.ts
100
lib/mail/smtp.ts
|
|
@ -1,100 +0,0 @@
|
||||||
import nodemailer from "nodemailer";
|
|
||||||
import type SMTPTransport from "nodemailer/lib/smtp-transport";
|
|
||||||
import type { SmtpSettings } from "@/lib/admin/types";
|
|
||||||
import { getSmtpSettings, isSmtpConfigured, updateSmtpDeliveryStatus, updateSmtpTestStatus } from "./config";
|
|
||||||
import { contactTemplate, repairTemplate, testTemplate } from "./templates";
|
|
||||||
import type { ContactMailInput, MailSendResult, RepairMailInput } from "./types";
|
|
||||||
|
|
||||||
function sanitizeMailError(error: unknown) {
|
|
||||||
const message = error instanceof Error ? error.message : "Unbekannter SMTP-Fehler";
|
|
||||||
return message
|
|
||||||
.replace(/AUTH PLAIN\s+\S+/gi, "AUTH PLAIN [redacted]")
|
|
||||||
.replace(/AUTH LOGIN\s+\S+/gi, "AUTH LOGIN [redacted]")
|
|
||||||
.replace(/password[=:]\S+/gi, "password=[redacted]")
|
|
||||||
.slice(0, 500);
|
|
||||||
}
|
|
||||||
|
|
||||||
function createTransport(settings: SmtpSettings) {
|
|
||||||
const secure = settings.security === "tls";
|
|
||||||
const requireTLS = settings.security === "starttls";
|
|
||||||
const options: SMTPTransport.Options = {
|
|
||||||
host: settings.host,
|
|
||||||
port: Number(settings.port),
|
|
||||||
secure,
|
|
||||||
requireTLS,
|
|
||||||
auth: {
|
|
||||||
user: settings.username,
|
|
||||||
pass: settings.password,
|
|
||||||
},
|
|
||||||
};
|
|
||||||
|
|
||||||
if (settings.security === "none") {
|
|
||||||
options.auth = settings.username && settings.password ? options.auth : undefined;
|
|
||||||
options.ignoreTLS = true;
|
|
||||||
}
|
|
||||||
|
|
||||||
return nodemailer.createTransport(options);
|
|
||||||
}
|
|
||||||
|
|
||||||
async function sendConfiguredMail(input: {
|
|
||||||
subject: string;
|
|
||||||
text: string;
|
|
||||||
html: string;
|
|
||||||
replyTo?: string;
|
|
||||||
}): Promise<MailSendResult> {
|
|
||||||
const settings = await getSmtpSettings();
|
|
||||||
if (!isSmtpConfigured(settings)) return { ok: false, error: "SMTP nicht konfiguriert." };
|
|
||||||
|
|
||||||
try {
|
|
||||||
const transport = createTransport(settings);
|
|
||||||
await transport.sendMail({
|
|
||||||
from: settings.fromAddress,
|
|
||||||
to: settings.recipientAddress,
|
|
||||||
bcc: settings.bccAddress || undefined,
|
|
||||||
replyTo: input.replyTo || settings.replyToAddress || undefined,
|
|
||||||
subject: input.subject,
|
|
||||||
text: input.text,
|
|
||||||
html: input.html,
|
|
||||||
});
|
|
||||||
return { ok: true };
|
|
||||||
} catch (error) {
|
|
||||||
return { ok: false, error: sanitizeMailError(error) };
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function sendTestMail() {
|
|
||||||
const template = testTemplate();
|
|
||||||
const result = await sendConfiguredMail(template);
|
|
||||||
await updateSmtpTestStatus({
|
|
||||||
lastTestAt: new Date().toISOString(),
|
|
||||||
lastTestStatus: result.ok ? "success" : "error",
|
|
||||||
lastError: result.ok ? "" : result.error,
|
|
||||||
});
|
|
||||||
return result;
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function sendContactInquiryMail(input: ContactMailInput) {
|
|
||||||
const template = contactTemplate(input);
|
|
||||||
const result = await sendConfiguredMail({ ...template, replyTo: input.email });
|
|
||||||
if (result.error !== "SMTP nicht konfiguriert.") {
|
|
||||||
await updateSmtpDeliveryStatus({
|
|
||||||
lastDeliveryAt: new Date().toISOString(),
|
|
||||||
lastDeliveryStatus: result.ok ? "success" : "error",
|
|
||||||
lastError: result.ok ? "" : result.error,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
return result;
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function sendRepairInquiryMail(input: RepairMailInput) {
|
|
||||||
const template = repairTemplate(input);
|
|
||||||
const result = await sendConfiguredMail({ ...template, replyTo: input.email });
|
|
||||||
if (result.error !== "SMTP nicht konfiguriert.") {
|
|
||||||
await updateSmtpDeliveryStatus({
|
|
||||||
lastDeliveryAt: new Date().toISOString(),
|
|
||||||
lastDeliveryStatus: result.ok ? "success" : "error",
|
|
||||||
lastError: result.ok ? "" : result.error,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
return result;
|
|
||||||
}
|
|
||||||
|
|
@ -1,74 +0,0 @@
|
||||||
import type { ContactMailInput, RepairMailInput } from "./types";
|
|
||||||
|
|
||||||
function escapeHtml(value?: string) {
|
|
||||||
return (value ?? "")
|
|
||||||
.replace(/&/g, "&")
|
|
||||||
.replace(/</g, "<")
|
|
||||||
.replace(/>/g, ">")
|
|
||||||
.replace(/"/g, """)
|
|
||||||
.replace(/'/g, "'");
|
|
||||||
}
|
|
||||||
|
|
||||||
function rows(entries: Array<[string, string | undefined]>) {
|
|
||||||
return entries
|
|
||||||
.filter(([, value]) => value)
|
|
||||||
.map(([label, value]) => `<tr><th align="left" style="padding:6px 12px;border-bottom:1px solid #d9dee6;">${escapeHtml(label)}</th><td style="padding:6px 12px;border-bottom:1px solid #d9dee6;">${escapeHtml(value)}</td></tr>`)
|
|
||||||
.join("");
|
|
||||||
}
|
|
||||||
|
|
||||||
function textRows(entries: Array<[string, string | undefined]>) {
|
|
||||||
return entries
|
|
||||||
.filter(([, value]) => value)
|
|
||||||
.map(([label, value]) => `${label}: ${value}`)
|
|
||||||
.join("\n");
|
|
||||||
}
|
|
||||||
|
|
||||||
export function contactTemplate(input: ContactMailInput) {
|
|
||||||
const subject = `Kontaktanfrage: ${input.subject ?? "Funktechnik Schubert"}`;
|
|
||||||
const entries: Array<[string, string | undefined]> = [
|
|
||||||
["Datum", new Date(input.createdAt).toLocaleString("de-DE")],
|
|
||||||
["Name", input.name],
|
|
||||||
["E-Mail", input.email],
|
|
||||||
["Telefon", input.phone],
|
|
||||||
["Betreff", input.subject],
|
|
||||||
["Nachricht", input.message],
|
|
||||||
];
|
|
||||||
|
|
||||||
return {
|
|
||||||
subject,
|
|
||||||
text: `Neue Kontaktanfrage\n\n${textRows(entries)}`,
|
|
||||||
html: `<h1>Neue Kontaktanfrage</h1><table cellspacing="0" cellpadding="0">${rows(entries)}</table>`,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
export function repairTemplate(input: RepairMailInput) {
|
|
||||||
const subject = `Reparaturanfrage: ${input.manufacturer} ${input.model}`;
|
|
||||||
const entries: Array<[string, string | undefined]> = [
|
|
||||||
["Datum", new Date(input.createdAt).toLocaleString("de-DE")],
|
|
||||||
["Name", input.name],
|
|
||||||
["E-Mail", input.email],
|
|
||||||
["Telefon", input.phone],
|
|
||||||
["Hersteller", input.manufacturer],
|
|
||||||
["Modell", input.model],
|
|
||||||
["Geräteart", input.deviceType],
|
|
||||||
["Seriennummer", input.serialNumber],
|
|
||||||
["Fehlerbeschreibung", input.description],
|
|
||||||
["Zubehör", input.accessories],
|
|
||||||
["Gerät geöffnet?", input.opened],
|
|
||||||
["Vorarbeiten", input.previousWork],
|
|
||||||
];
|
|
||||||
|
|
||||||
return {
|
|
||||||
subject,
|
|
||||||
text: `Neue Reparaturanfrage\n\n${textRows(entries)}`,
|
|
||||||
html: `<h1>Neue Reparaturanfrage</h1><table cellspacing="0" cellpadding="0">${rows(entries)}</table>`,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
export function testTemplate() {
|
|
||||||
return {
|
|
||||||
subject: "SMTP Testmail - Funktechnik Schubert",
|
|
||||||
text: "Diese Testmail wurde aus der Funktechnik Schubert Website-Administration gesendet.",
|
|
||||||
html: "<h1>SMTP Testmail</h1><p>Diese Testmail wurde aus der Funktechnik Schubert Website-Administration gesendet.</p>",
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
@ -1,13 +0,0 @@
|
||||||
import type { ContactInquiry, RepairInquiry, SmtpSettings } from "@/lib/admin/types";
|
|
||||||
|
|
||||||
export type PublicSmtpSettings = Omit<SmtpSettings, "password"> & {
|
|
||||||
hasPassword: boolean;
|
|
||||||
};
|
|
||||||
|
|
||||||
export type MailSendResult = {
|
|
||||||
ok: boolean;
|
|
||||||
error?: string;
|
|
||||||
};
|
|
||||||
|
|
||||||
export type ContactMailInput = ContactInquiry;
|
|
||||||
export type RepairMailInput = RepairInquiry;
|
|
||||||
|
|
@ -1,145 +0,0 @@
|
||||||
import "server-only";
|
|
||||||
|
|
||||||
export type OlympusRepairStatusHistoryItem = {
|
|
||||||
status: string;
|
|
||||||
status_label: string;
|
|
||||||
created_at: string;
|
|
||||||
};
|
|
||||||
|
|
||||||
export type OlympusEstimateItem = {
|
|
||||||
item_type: string;
|
|
||||||
title: string;
|
|
||||||
description: string | null;
|
|
||||||
quantity: string | number;
|
|
||||||
unit: string;
|
|
||||||
unit_price_cents: number;
|
|
||||||
total_cents: number;
|
|
||||||
};
|
|
||||||
|
|
||||||
export type OlympusEstimate = {
|
|
||||||
estimate_number: string;
|
|
||||||
status: "draft" | "sent" | "approved" | "declined" | "expired" | "cancelled" | string;
|
|
||||||
title: string;
|
|
||||||
customer_message: string | null;
|
|
||||||
subtotal_cents: number;
|
|
||||||
tax_cents: number;
|
|
||||||
total_cents: number;
|
|
||||||
currency: string;
|
|
||||||
valid_until: string | null;
|
|
||||||
items: OlympusEstimateItem[];
|
|
||||||
};
|
|
||||||
|
|
||||||
export type OlympusRepairStatus = {
|
|
||||||
repair_number: string;
|
|
||||||
public_status_label: string;
|
|
||||||
device_manufacturer: string;
|
|
||||||
device_model: string;
|
|
||||||
status_history_public: OlympusRepairStatusHistoryItem[];
|
|
||||||
updated_at: string;
|
|
||||||
estimate?: OlympusEstimate | null;
|
|
||||||
};
|
|
||||||
|
|
||||||
export type RepairStatusResult =
|
|
||||||
| { ok: true; status: OlympusRepairStatus }
|
|
||||||
| { ok: false; reason: "invalid" | "unavailable" };
|
|
||||||
|
|
||||||
export type EstimateAction = "approve" | "decline" | "question";
|
|
||||||
|
|
||||||
export type EstimateActionResult =
|
|
||||||
| { ok: true }
|
|
||||||
| { ok: false; reason: "invalid" | "unavailable" | "failed" };
|
|
||||||
|
|
||||||
function getStatusApiBaseUrl() {
|
|
||||||
return process.env.OLYMPUS_PUBLIC_STATUS_API_URL?.replace(/\/$/, "") ?? "";
|
|
||||||
}
|
|
||||||
|
|
||||||
function getStatusApiToken() {
|
|
||||||
return process.env.OLYMPUS_PUBLIC_STATUS_API_TOKEN ?? "";
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function fetchOlympusRepairStatus(token: string): Promise<RepairStatusResult> {
|
|
||||||
const baseUrl = getStatusApiBaseUrl();
|
|
||||||
|
|
||||||
if (!baseUrl || !token.trim()) {
|
|
||||||
return { ok: false, reason: "unavailable" };
|
|
||||||
}
|
|
||||||
|
|
||||||
const headers: HeadersInit = {
|
|
||||||
Accept: "application/json",
|
|
||||||
};
|
|
||||||
const apiToken = getStatusApiToken();
|
|
||||||
|
|
||||||
if (apiToken) {
|
|
||||||
headers.Authorization = `Bearer ${apiToken}`;
|
|
||||||
}
|
|
||||||
|
|
||||||
let response: Response;
|
|
||||||
|
|
||||||
try {
|
|
||||||
response = await fetch(`${baseUrl}/public/repairs/status/${encodeURIComponent(token)}`, {
|
|
||||||
headers,
|
|
||||||
cache: "no-store",
|
|
||||||
});
|
|
||||||
} catch {
|
|
||||||
return { ok: false, reason: "unavailable" };
|
|
||||||
}
|
|
||||||
|
|
||||||
if (response.status === 401 || response.status === 403 || response.status === 404) {
|
|
||||||
return { ok: false, reason: "invalid" };
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!response.ok) {
|
|
||||||
return { ok: false, reason: "unavailable" };
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
|
||||||
return { ok: true, status: await response.json() as OlympusRepairStatus };
|
|
||||||
} catch {
|
|
||||||
return { ok: false, reason: "unavailable" };
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function submitOlympusEstimateAction(
|
|
||||||
token: string,
|
|
||||||
action: EstimateAction,
|
|
||||||
message?: string,
|
|
||||||
): Promise<EstimateActionResult> {
|
|
||||||
const baseUrl = getStatusApiBaseUrl();
|
|
||||||
|
|
||||||
if (!baseUrl || !token.trim()) {
|
|
||||||
return { ok: false, reason: "unavailable" };
|
|
||||||
}
|
|
||||||
|
|
||||||
const headers: HeadersInit = {
|
|
||||||
Accept: "application/json",
|
|
||||||
"Content-Type": "application/json",
|
|
||||||
};
|
|
||||||
const apiToken = getStatusApiToken();
|
|
||||||
|
|
||||||
if (apiToken) {
|
|
||||||
headers.Authorization = `Bearer ${apiToken}`;
|
|
||||||
}
|
|
||||||
|
|
||||||
let response: Response;
|
|
||||||
|
|
||||||
try {
|
|
||||||
response = await fetch(`${baseUrl}/public/repairs/status/${encodeURIComponent(token)}/estimate/${action}`, {
|
|
||||||
method: "POST",
|
|
||||||
headers,
|
|
||||||
body: JSON.stringify({ message: message?.trim() || null }),
|
|
||||||
cache: "no-store",
|
|
||||||
});
|
|
||||||
} catch {
|
|
||||||
return { ok: false, reason: "unavailable" };
|
|
||||||
}
|
|
||||||
|
|
||||||
if (response.status === 401 || response.status === 403 || response.status === 404) {
|
|
||||||
return { ok: false, reason: "invalid" };
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!response.ok) {
|
|
||||||
return { ok: false, reason: "failed" };
|
|
||||||
}
|
|
||||||
|
|
||||||
return { ok: true };
|
|
||||||
}
|
|
||||||
|
|
@ -1,63 +0,0 @@
|
||||||
import { existsSync } from "fs";
|
|
||||||
import { access, copyFile, mkdir, readdir, rm, writeFile } from "fs/promises";
|
|
||||||
import path from "path";
|
|
||||||
import { appVersion } from "./version";
|
|
||||||
|
|
||||||
export const dataDirectory = path.join(process.cwd(), "data");
|
|
||||||
export const storageDirectory = path.join(process.cwd(), "storage");
|
|
||||||
export const configDirectory = path.join(storageDirectory, "config");
|
|
||||||
export const contentDirectory = path.join(storageDirectory, "content");
|
|
||||||
export const contentBackupDirectory = path.join(contentDirectory, "backups");
|
|
||||||
export const uploadDirectory = path.join(storageDirectory, "uploads", "images");
|
|
||||||
export const legacyPublicUploadDirectory = path.join(process.cwd(), "public", "uploads", "images");
|
|
||||||
|
|
||||||
export function adminConfigurationStatus() {
|
|
||||||
return process.env.ADMIN_EMAIL && process.env.ADMIN_PASSWORD && process.env.ADMIN_SESSION_SECRET ? "configured" : "missing";
|
|
||||||
}
|
|
||||||
|
|
||||||
export function isDockerEnvironment() {
|
|
||||||
return process.env.DOCKER_ENV === "true" || process.env.CONTAINER === "true" || existsSync("/.dockerenv");
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function ensureRuntimeDirectories() {
|
|
||||||
await mkdir(dataDirectory, { recursive: true });
|
|
||||||
await mkdir(configDirectory, { recursive: true });
|
|
||||||
await mkdir(contentBackupDirectory, { recursive: true });
|
|
||||||
await mkdir(uploadDirectory, { recursive: true });
|
|
||||||
await migrateLegacyUploads();
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function checkStorage() {
|
|
||||||
await ensureRuntimeDirectories();
|
|
||||||
const probe = path.join(dataDirectory, `.storage-check-${Date.now()}`);
|
|
||||||
await writeFile(probe, "ok", "utf8");
|
|
||||||
await rm(probe, { force: true });
|
|
||||||
await access(uploadDirectory);
|
|
||||||
return "ok";
|
|
||||||
}
|
|
||||||
|
|
||||||
export function olympusStatus() {
|
|
||||||
return process.env.OLYMPUS_INTAKE_API_URL && process.env.OLYMPUS_INTAKE_API_TOKEN ? "configured" : "not_configured";
|
|
||||||
}
|
|
||||||
|
|
||||||
export function smtpStatus() {
|
|
||||||
return "prepared";
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function migrateLegacyUploads() {
|
|
||||||
if (!existsSync(legacyPublicUploadDirectory)) return;
|
|
||||||
|
|
||||||
await mkdir(uploadDirectory, { recursive: true });
|
|
||||||
const entries = await readdir(legacyPublicUploadDirectory, { withFileTypes: true });
|
|
||||||
|
|
||||||
await Promise.all(entries.map(async (entry) => {
|
|
||||||
if (!entry.isFile() || entry.name.startsWith(".")) return;
|
|
||||||
|
|
||||||
const source = path.join(legacyPublicUploadDirectory, entry.name);
|
|
||||||
const target = path.join(uploadDirectory, entry.name);
|
|
||||||
|
|
||||||
if (!existsSync(target)) await copyFile(source, target);
|
|
||||||
}));
|
|
||||||
}
|
|
||||||
|
|
||||||
export { appVersion };
|
|
||||||
|
|
@ -1 +0,0 @@
|
||||||
export const appVersion = "0.4.1";
|
|
||||||
32
package-lock.json
generated
32
package-lock.json
generated
|
|
@ -1,16 +1,14 @@
|
||||||
{
|
{
|
||||||
"name": "funktechnik-schubert-website",
|
"name": "funktechnik-schubert-website",
|
||||||
"version": "0.4.1",
|
"version": "0.1.0",
|
||||||
"lockfileVersion": 3,
|
"lockfileVersion": 3,
|
||||||
"requires": true,
|
"requires": true,
|
||||||
"packages": {
|
"packages": {
|
||||||
"": {
|
"": {
|
||||||
"name": "funktechnik-schubert-website",
|
"name": "funktechnik-schubert-website",
|
||||||
"version": "0.4.1",
|
"version": "0.1.0",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@types/nodemailer": "^8.0.1",
|
|
||||||
"next": "16.2.10",
|
"next": "16.2.10",
|
||||||
"nodemailer": "^9.0.3",
|
|
||||||
"react": "19.2.3",
|
"react": "19.2.3",
|
||||||
"react-dom": "19.2.3"
|
"react-dom": "19.2.3"
|
||||||
},
|
},
|
||||||
|
|
@ -1345,20 +1343,12 @@
|
||||||
"version": "24.13.2",
|
"version": "24.13.2",
|
||||||
"resolved": "https://registry.npmjs.org/@types/node/-/node-24.13.2.tgz",
|
"resolved": "https://registry.npmjs.org/@types/node/-/node-24.13.2.tgz",
|
||||||
"integrity": "sha512-fRa09kZTgu8o71KFcDjUFuc7F+dEbZYZmkI0mg5YBTRs0yMKjYHsq/c0urDKeDb+D5qVgXOdFcuu+DZPKOITwA==",
|
"integrity": "sha512-fRa09kZTgu8o71KFcDjUFuc7F+dEbZYZmkI0mg5YBTRs0yMKjYHsq/c0urDKeDb+D5qVgXOdFcuu+DZPKOITwA==",
|
||||||
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"undici-types": "~7.18.0"
|
"undici-types": "~7.18.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@types/nodemailer": {
|
|
||||||
"version": "8.0.1",
|
|
||||||
"resolved": "https://registry.npmjs.org/@types/nodemailer/-/nodemailer-8.0.1.tgz",
|
|
||||||
"integrity": "sha512-PxpaInm8V1JQDd4j0ds5HfvWQk8JupS1C0Picb96QJsrrRDjBH+DlK7L4ZdNSqNULhiZRQHc40nLVShaGxXAMw==",
|
|
||||||
"license": "MIT",
|
|
||||||
"dependencies": {
|
|
||||||
"@types/node": "*"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/@types/react": {
|
"node_modules/@types/react": {
|
||||||
"version": "19.2.17",
|
"version": "19.2.17",
|
||||||
"resolved": "https://registry.npmjs.org/@types/react/-/react-19.2.17.tgz",
|
"resolved": "https://registry.npmjs.org/@types/react/-/react-19.2.17.tgz",
|
||||||
|
|
@ -2711,9 +2701,9 @@
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/electron-to-chromium": {
|
"node_modules/electron-to-chromium": {
|
||||||
"version": "1.5.387",
|
"version": "1.5.385",
|
||||||
"resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.387.tgz",
|
"resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.385.tgz",
|
||||||
"integrity": "sha512-TaxwufTFDufvPEoXdhwVrA3UdFWBeWGkYoJ1K8ldF1xe6gKfth6iRNS5lTQ5JPNOHdGQm8PT1QYKUqFLCiUefQ==",
|
"integrity": "sha512-78sa/M08MNAYHQfjoWMvOlKQqZ0ElhSm/L5HNUc96VZ3b+KvDVnngFm8sYQy0XrhTRgAhggHr5abA7yTvRdo4Q==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "ISC"
|
"license": "ISC"
|
||||||
},
|
},
|
||||||
|
|
@ -4679,15 +4669,6 @@
|
||||||
"node": ">=18"
|
"node": ">=18"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/nodemailer": {
|
|
||||||
"version": "9.0.3",
|
|
||||||
"resolved": "https://registry.npmjs.org/nodemailer/-/nodemailer-9.0.3.tgz",
|
|
||||||
"integrity": "sha512-n+YP+NKwR5zRWa60k3GiQ6Q3B4KXCoAw40dAKeCtYn020iNN74aWK2liXIC3ZEATeGql7we3tE3t8QwhY0eskw==",
|
|
||||||
"license": "MIT-0",
|
|
||||||
"engines": {
|
|
||||||
"node": ">=6.0.0"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/object-assign": {
|
"node_modules/object-assign": {
|
||||||
"version": "4.1.1",
|
"version": "4.1.1",
|
||||||
"resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz",
|
"resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz",
|
||||||
|
|
@ -5931,6 +5912,7 @@
|
||||||
"version": "7.18.2",
|
"version": "7.18.2",
|
||||||
"resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.18.2.tgz",
|
"resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.18.2.tgz",
|
||||||
"integrity": "sha512-AsuCzffGHJybSaRrmr5eHr81mwJU3kjw6M+uprWvCXiNeN9SOGwQ3Jn8jb8m3Z6izVgknn1R0FTCEAP2QrLY/w==",
|
"integrity": "sha512-AsuCzffGHJybSaRrmr5eHr81mwJU3kjw6M+uprWvCXiNeN9SOGwQ3Jn8jb8m3Z6izVgknn1R0FTCEAP2QrLY/w==",
|
||||||
|
"dev": true,
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
"node_modules/unrs-resolver": {
|
"node_modules/unrs-resolver": {
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
{
|
{
|
||||||
"name": "funktechnik-schubert-website",
|
"name": "funktechnik-schubert-website",
|
||||||
"version": "0.4.1",
|
"version": "0.1.0",
|
||||||
"private": true,
|
"private": true,
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "next dev -p 3010",
|
"dev": "next dev -p 3010",
|
||||||
|
|
@ -9,9 +9,7 @@
|
||||||
"lint": "eslint"
|
"lint": "eslint"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@types/nodemailer": "^8.0.1",
|
|
||||||
"next": "16.2.10",
|
"next": "16.2.10",
|
||||||
"nodemailer": "^9.0.3",
|
|
||||||
"react": "19.2.3",
|
"react": "19.2.3",
|
||||||
"react-dom": "19.2.3"
|
"react-dom": "19.2.3"
|
||||||
},
|
},
|
||||||
|
|
|
||||||
22
proxy.ts
22
proxy.ts
|
|
@ -1,22 +0,0 @@
|
||||||
import { NextResponse, type NextRequest } from "next/server";
|
|
||||||
|
|
||||||
const adminSessionCookie = "fs_admin_session";
|
|
||||||
|
|
||||||
export function proxy(request: NextRequest) {
|
|
||||||
const { pathname } = request.nextUrl;
|
|
||||||
if (!pathname.startsWith("/admin") || pathname === "/admin/login") return NextResponse.next();
|
|
||||||
|
|
||||||
if (!request.cookies.get(adminSessionCookie)?.value) {
|
|
||||||
const loginUrl = request.nextUrl.clone();
|
|
||||||
loginUrl.pathname = "/admin/login";
|
|
||||||
loginUrl.search = "";
|
|
||||||
loginUrl.searchParams.set("next", pathname);
|
|
||||||
return NextResponse.redirect(loginUrl);
|
|
||||||
}
|
|
||||||
|
|
||||||
return NextResponse.next();
|
|
||||||
}
|
|
||||||
|
|
||||||
export const config = {
|
|
||||||
matcher: ["/admin/:path*"],
|
|
||||||
};
|
|
||||||
Binary file not shown.
|
Before Width: | Height: | Size: 1.6 MiB |
|
|
@ -1,13 +0,0 @@
|
||||||
#!/usr/bin/env sh
|
|
||||||
set -eu
|
|
||||||
|
|
||||||
BACKUP_DIR="${BACKUP_DIR:-backups}"
|
|
||||||
STAMP="$(date +%Y%m%d-%H%M%S)"
|
|
||||||
TARGET="${BACKUP_DIR}/funktechnik-data-${STAMP}.tar.gz"
|
|
||||||
|
|
||||||
mkdir -p "$BACKUP_DIR"
|
|
||||||
tar -czf "$TARGET" data storage
|
|
||||||
|
|
||||||
echo "Backup erstellt: $TARGET"
|
|
||||||
echo "Hinweis: .env wird aus Sicherheitsgruenden nicht automatisch gesichert."
|
|
||||||
echo "Bitte .env separat und sicher ausserhalb des Repositories sichern."
|
|
||||||
|
|
@ -1,19 +0,0 @@
|
||||||
#!/usr/bin/env sh
|
|
||||||
set -eu
|
|
||||||
|
|
||||||
COMPOSE="${COMPOSE:-docker compose}"
|
|
||||||
BASE_URL="${BASE_URL:-http://127.0.0.1:3010}"
|
|
||||||
|
|
||||||
echo "Pruefe Docker Compose Konfiguration..."
|
|
||||||
$COMPOSE config >/dev/null
|
|
||||||
|
|
||||||
echo "Baue Container..."
|
|
||||||
$COMPOSE build
|
|
||||||
|
|
||||||
echo "Starte Container..."
|
|
||||||
$COMPOSE up -d
|
|
||||||
|
|
||||||
echo "Fuehre Healthcheck aus..."
|
|
||||||
BASE_URL="$BASE_URL" scripts/healthcheck.sh
|
|
||||||
|
|
||||||
echo "Deployment abgeschlossen."
|
|
||||||
|
|
@ -1,14 +0,0 @@
|
||||||
#!/usr/bin/env sh
|
|
||||||
set -eu
|
|
||||||
|
|
||||||
BASE_URL="${BASE_URL:-http://127.0.0.1:3010}"
|
|
||||||
HEALTH_URL="${BASE_URL%/}/api/health"
|
|
||||||
|
|
||||||
echo "Checking ${HEALTH_URL}"
|
|
||||||
response="$(curl -fsS "$HEALTH_URL")"
|
|
||||||
echo "$response"
|
|
||||||
|
|
||||||
case "$response" in
|
|
||||||
*'"status":"ok"'*) exit 0 ;;
|
|
||||||
*) echo "Healthcheck failed" >&2; exit 1 ;;
|
|
||||||
esac
|
|
||||||
|
|
@ -1,17 +0,0 @@
|
||||||
#!/usr/bin/env sh
|
|
||||||
set -eu
|
|
||||||
|
|
||||||
if [ "${1:-}" = "" ]; then
|
|
||||||
echo "Usage: scripts/restore.sh <backup.tar.gz>" >&2
|
|
||||||
exit 1
|
|
||||||
fi
|
|
||||||
|
|
||||||
BACKUP_FILE="$1"
|
|
||||||
|
|
||||||
if [ ! -f "$BACKUP_FILE" ]; then
|
|
||||||
echo "Backup nicht gefunden: $BACKUP_FILE" >&2
|
|
||||||
exit 1
|
|
||||||
fi
|
|
||||||
|
|
||||||
tar -xzf "$BACKUP_FILE"
|
|
||||||
echo "Restore abgeschlossen: $BACKUP_FILE"
|
|
||||||
|
|
@ -1,60 +0,0 @@
|
||||||
/* eslint-disable @typescript-eslint/no-require-imports */
|
|
||||||
const fs = require("fs");
|
|
||||||
const path = require("path");
|
|
||||||
|
|
||||||
const requiredEnv = ["ADMIN_EMAIL", "ADMIN_PASSWORD", "ADMIN_SESSION_SECRET"];
|
|
||||||
const requiredDirs = [
|
|
||||||
path.join(process.cwd(), "data"),
|
|
||||||
path.join(process.cwd(), "storage", "config"),
|
|
||||||
path.join(process.cwd(), "storage", "content"),
|
|
||||||
path.join(process.cwd(), "storage", "content", "backups"),
|
|
||||||
path.join(process.cwd(), "storage", "uploads", "images"),
|
|
||||||
path.join(process.cwd(), ".next", "cache"),
|
|
||||||
];
|
|
||||||
|
|
||||||
function fail(message) {
|
|
||||||
process.stderr.write(`[funktechnik-website] ${message}\n`);
|
|
||||||
process.exit(1);
|
|
||||||
}
|
|
||||||
|
|
||||||
function ensureWritableDirectory(directory) {
|
|
||||||
fs.mkdirSync(directory, { recursive: true });
|
|
||||||
const probe = path.join(directory, `.write-check-${Date.now()}`);
|
|
||||||
fs.writeFileSync(probe, "ok", "utf8");
|
|
||||||
fs.rmSync(probe, { force: true });
|
|
||||||
}
|
|
||||||
|
|
||||||
function migrateLegacyUploads() {
|
|
||||||
const legacyDirectory = path.join(process.cwd(), "public", "uploads", "images");
|
|
||||||
const storageDirectory = path.join(process.cwd(), "storage", "uploads", "images");
|
|
||||||
|
|
||||||
if (!fs.existsSync(legacyDirectory)) return;
|
|
||||||
|
|
||||||
fs.mkdirSync(storageDirectory, { recursive: true });
|
|
||||||
for (const fileName of fs.readdirSync(legacyDirectory)) {
|
|
||||||
if (fileName.startsWith(".")) continue;
|
|
||||||
|
|
||||||
const source = path.join(legacyDirectory, fileName);
|
|
||||||
const target = path.join(storageDirectory, fileName);
|
|
||||||
const sourceStat = fs.statSync(source);
|
|
||||||
|
|
||||||
if (sourceStat.isFile() && !fs.existsSync(target)) {
|
|
||||||
fs.copyFileSync(source, target);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const missingEnv = requiredEnv.filter((name) => !process.env[name]);
|
|
||||||
if (missingEnv.length > 0) {
|
|
||||||
fail(`Start abgebrochen: fehlende Umgebungsvariablen: ${missingEnv.join(", ")}. Bitte .env anhand von .env.example konfigurieren.`);
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
|
||||||
for (const directory of requiredDirs) ensureWritableDirectory(directory);
|
|
||||||
migrateLegacyUploads();
|
|
||||||
} catch (error) {
|
|
||||||
fail(`Start abgebrochen: Runtime-Verzeichnis ist nicht beschreibbar. Details: ${error instanceof Error ? error.message : "unbekannter Fehler"}`);
|
|
||||||
}
|
|
||||||
|
|
||||||
process.stdout.write(`[funktechnik-website] Runtime checks ok. Starting version ${process.env.NEXT_PUBLIC_APP_VERSION ?? "0.4.1"}.\n`);
|
|
||||||
require("./server.js");
|
|
||||||
|
|
@ -1 +0,0 @@
|
||||||
|
|
||||||
|
|
@ -1 +0,0 @@
|
||||||
|
|
||||||
|
|
@ -1 +0,0 @@
|
||||||
|
|
||||||
|
|
@ -1 +0,0 @@
|
||||||
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue