Compare commits

..

No commits in common. "main" and "v0.2.3" have entirely different histories.
main ... v0.2.3

66 changed files with 349 additions and 3762 deletions

View file

@ -6,10 +6,8 @@ npm-debug.log*
.git .git
README.md README.md
data/*.json data/*.json
storage/uploads/images/* public/uploads/images/*
storage/config/* !public/uploads/images/.gitkeep
!storage/config/.gitkeep
!storage/uploads/images/.gitkeep
*.tmp *.tmp
*.temp *.temp
.DS_Store .DS_Store

View file

@ -25,13 +25,3 @@ OLYMPUS_INTAKE_API_URL=
# Optional future server-side Olympus intake token. # Optional future server-side Olympus intake token.
# Leave empty until the Olympus integration is explicitly implemented. # 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=

14
.gitignore vendored
View file

@ -9,17 +9,9 @@ data/contact-inquiries.json
data/repair-inquiries.json data/repair-inquiries.json
data/site-settings.json data/site-settings.json
data/smtp-settings.json data/smtp-settings.json
storage/uploads/images/* public/uploads/images/*
storage/config/* !public/uploads/.gitkeep
storage/content/* !public/uploads/images/.gitkeep
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 .DS_Store
*.tmp *.tmp
*.temp *.temp

View file

@ -14,7 +14,7 @@ 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 NEXT_PUBLIC_APP_VERSION=0.2.2
ENV DOCKER_ENV=true ENV DOCKER_ENV=true
ENV PORT=3010 ENV PORT=3010
@ -26,8 +26,8 @@ COPY --from=builder --chown=nextjs:nodejs /app/.next/standalone ./
COPY --from=builder --chown=nextjs:nodejs /app/.next/static ./.next/static COPY --from=builder --chown=nextjs:nodejs /app/.next/static ./.next/static
COPY --from=builder --chown=nextjs:nodejs /app/scripts/runtime-start.js ./runtime-start.js 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 \ RUN mkdir -p /app/data /app/public/uploads/images /app/.next/cache \
&& chown -R nextjs:nodejs /app/data /app/storage /app/.next/cache && chown -R nextjs:nodejs /app/data /app/public/uploads /app/.next/cache
USER nextjs USER nextjs
EXPOSE 3010 EXPOSE 3010

160
README.md
View file

@ -8,12 +8,12 @@ Eigenständige öffentliche Firmenwebsite für Funktechnik Schubert. Dieses Proj
- 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` - Geschützter Admin-Bereich unter `/admin`
- Lokale Storage-Datenablage für Anfragen, Einstellungen, Medien und Website-Inhalte - Lokale Foundation-Datenablage für Anfragen und Einstellungen
- 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` Version: `0.2.2`
## Seiten ## Seiten
@ -23,7 +23,6 @@ Version: `0.4.1`
- `/reparatur` - `/reparatur`
- `/ueber-uns` - `/ueber-uns`
- `/kontakt` - `/kontakt`
- `/status/[token]`
- `/impressum` - `/impressum`
- `/datenschutz` - `/datenschutz`
- `/admin/login` - `/admin/login`
@ -88,7 +87,7 @@ Port:
Die Runtime-Daten werden über Docker-Volumes gespeichert: Die Runtime-Daten werden über Docker-Volumes gespeichert:
- `funktechnik-data``/app/data` - `funktechnik-data``/app/data`
- `funktechnik-storage` → `/app/storage` - `funktechnik-uploads` → `/app/public/uploads/images`
- `funktechnik-next-cache``/app/.next/cache` - `funktechnik-next-cache``/app/.next/cache`
Dadurch sind keine manuellen `chmod`- oder `chown`-Befehle notwendig. Dadurch sind keine manuellen `chmod`- oder `chown`-Befehle notwendig.
@ -110,12 +109,8 @@ Variablen:
- `AUTH_COOKIE_SECURE`: `true` in Produktion mit HTTPS, lokal `false` - `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_URL`: vorbereitet für spätere serverseitige Olympus-Anbindung
- `OLYMPUS_INTAKE_API_TOKEN`: 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. 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.
@ -130,62 +125,8 @@ Für lokale Entwicklung kann `AUTH_COOKIE_SECURE=false` bleiben. Produktiv muss
- `POST /api/admin/settings` - `POST /api/admin/settings`
- `POST /api/admin/smtp` - `POST /api/admin/smtp`
- `GET/POST /api/admin/media` - `GET/POST /api/admin/media`
- `GET/PUT /api/admin/content`
- `GET /api/content/settings`
- `GET /api/media/[filename]`
- `GET /api/health` - `GET /api/health`
## Öffentlicher Reparaturstatus
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. 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 ## Admin-Bereich
@ -197,14 +138,14 @@ Funktionen:
- Dashboard mit Kennzahlen - Dashboard mit Kennzahlen
- Kontaktanfragen verwalten - Kontaktanfragen verwalten
- Reparaturanfragen verwalten - Reparaturanfragen verwalten
- Website-Inhalte pflegen und sofort veröffentlichen - Website-Inhaltsverwaltung vorbereitet
- Firmendaten pflegen - Firmendaten pflegen
- SMTP-Konfiguration speichern und Testmail senden - SMTP-Konfiguration vorbereiten
- Medien hochladen - Medien hochladen
- SEO-Übersicht - SEO-Übersicht
- Systemü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. SMTP-Versand, Publishing von Website-Inhalten und Olympus-Übernahme sind bewusst noch nicht aktiv gekoppelt.
## Runtime Data ## Runtime Data
@ -222,51 +163,10 @@ Echte Runtime-Dateien werden nicht committed:
data/contact-inquiries.json data/contact-inquiries.json
data/repair-inquiries.json data/repair-inquiries.json
data/site-settings.json data/site-settings.json
data/smtp-settings.json
``` ```
SMTP-Konfiguration wird unter `storage/config/smtp.json` gespeichert und nicht committed. Das SMTP-Passwort wird nicht im Admin-Formular ausgegeben. Uploads unter `public/uploads/images/` werden ebenfalls nicht committed.
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 ## Healthcheck
@ -285,50 +185,13 @@ Antwort:
```json ```json
{ {
"status": "ok", "status": "ok",
"version": "0.4.1", "version": "0.2.2",
"storage": "ok", "storage": "ok",
"admin": "configured", "admin": "configured",
"smtp": "configured",
"timestamp": "..." "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 ## Deployment
```bash ```bash
@ -360,7 +223,7 @@ scripts/healthcheck.sh
scripts/backup.sh scripts/backup.sh
``` ```
Das Backup enthält `data/` und `storage/`. Die `.env` wird bewusst nicht automatisch gesichert. Sie muss separat sicher abgelegt werden. Das Backup enthält `data/` und `public/uploads/`. Die `.env` wird bewusst nicht automatisch gesichert. Sie muss separat sicher abgelegt werden.
## Restore ## Restore
@ -394,5 +257,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

View file

@ -1,12 +1,26 @@
import Image from "next/image";
import { redirect } from "next/navigation"; import { redirect } from "next/navigation";
import AdminShell from "@/components/admin/AdminShell"; import AdminShell from "@/components/admin/AdminShell";
import MediaManager from "@/components/admin/MediaManager";
import { requireAdminSession } from "@/lib/admin/auth"; import { requireAdminSession } from "@/lib/admin/auth";
import { listMediaFiles } from "@/lib/admin/media"; import { listMediaFiles } from "@/lib/admin/media";
export default async function AdminMediaPage() { 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 uploadErrorMessage(error?: string) {
if (!error) return "";
if (error === "missing-file") return "Bitte wählen Sie eine Datei aus.";
if (error === "storage") return "Die Datei konnte nicht gespeichert werden. Bitte Upload-Speicher und Rechte prüfen.";
return error;
}
export default async function AdminMediaPage({ searchParams }: { searchParams: Promise<{ uploaded?: string; error?: string }> }) {
if (!await requireAdminSession()) redirect("/admin/login"); if (!await requireAdminSession()) redirect("/admin/login");
const files = await listMediaFiles(); const [files, params] = await Promise.all([listMediaFiles(), searchParams]);
const error = uploadErrorMessage(params.error);
return ( return (
<AdminShell> <AdminShell>
@ -14,7 +28,41 @@ export default async function AdminMediaPage() {
<p className="eyebrow">Assets</p> <p className="eyebrow">Assets</p>
<h1>Medienverwaltung</h1> <h1>Medienverwaltung</h1>
</div> </div>
<MediaManager initialFiles={files} /> {params.uploaded === "1" && <p className="success">Datei wurde erfolgreich hochgeladen.</p>}
{error && <p className="error admin-message">Upload fehlgeschlagen: {error}</p>}
<form className="admin-card admin-upload" action="/api/admin/media" method="post" encType="multipart/form-data">
<label>Datei hochladen<input name="file" type="file" accept="image/jpeg,image/png,image/webp,application/pdf" /></label>
<button className="button" type="submit">Datei hochladen</button>
</form>
<section className="admin-card">
<h2>Uploads</h2>
{files.length === 0 ? (
<div className="media-empty">
<h3>Noch keine Medien vorhanden</h3>
<p>Hochgeladene Bilder und PDF-Dateien erscheinen hier mit Vorschau und Metadaten.</p>
</div>
) : (
<div className="media-list">
{files.map((file) => (
<article key={file.name} className="media-row">
<a className="media-preview" href={file.url} target="_blank" rel="noreferrer">
{file.isImage
? <Image src={file.url} alt={file.name} width={220} height={140} />
: <span>{file.type}</span>}
</a>
<div>
<h3>{file.name}</h3>
<dl className="media-meta">
<div><dt>Typ</dt><dd>{file.type}</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>
</article>
))}
</div>
)}
</section>
</AdminShell> </AdminShell>
); );
} }

View file

@ -1,11 +1,7 @@
import { redirect } from "next/navigation"; import { redirect } from "next/navigation";
import AdminShell from "@/components/admin/AdminShell"; import AdminShell from "@/components/admin/AdminShell";
import { requireAdminSession } from "@/lib/admin/auth"; import { requireAdminSession } from "@/lib/admin/auth";
import { listMediaFiles } from "@/lib/admin/media";
import { getContactInquiries, getRepairInquiries } from "@/lib/admin/store"; 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[]) { function countNew<T extends { status: string }>(items: T[]) {
return items.filter((item) => item.status === "new").length; return items.filter((item) => item.status === "new").length;
@ -13,23 +9,8 @@ function countNew<T extends { status: string }>(items: T[]) {
export default async function AdminDashboardPage() { export default async function AdminDashboardPage() {
if (!await requireAdminSession()) redirect("/admin/login"); if (!await requireAdminSession()) redirect("/admin/login");
let healthStatus = "OK"; const [contacts, repairs] = await Promise.all([getContactInquiries(), getRepairInquiries()]);
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 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 ( return (
<AdminShell> <AdminShell>
@ -38,13 +19,10 @@ export default async function AdminDashboardPage() {
<h1>Dashboard</h1> <h1>Dashboard</h1>
</div> </div>
<div className="admin-kpis"> <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>Kontaktanfragen</span><strong>{countNew(contacts)}</strong><small>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>Reparaturanfragen</span><strong>{countNew(repairs)}</strong><small>neu</small></div>
<div className="admin-kpi"><span>Medien</span><strong>{mediaFiles.length}</strong><small>im Storage</small></div> <div className="admin-kpi"><span>Website</span><strong>Online</strong><small>OK</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>Vorbereitet</strong><small>nicht aktiv</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> </div>
<section className="admin-card"> <section className="admin-card">
<h2>Letzte Anfragen</h2> <h2>Letzte Anfragen</h2>

View file

@ -1,8 +1,7 @@
import { redirect } from "next/navigation"; import { redirect } from "next/navigation";
import AdminShell from "@/components/admin/AdminShell"; import AdminShell from "@/components/admin/AdminShell";
import SmtpSettingsForm from "@/components/admin/SmtpSettingsForm";
import { requireAdminSession } from "@/lib/admin/auth"; import { requireAdminSession } from "@/lib/admin/auth";
import { getSmtpSettings, isSmtpConfigured, toPublicSmtpSettings } from "@/lib/mail/config"; import { getSmtpSettings } from "@/lib/admin/store";
export default async function AdminSmtpPage() { export default async function AdminSmtpPage() {
if (!await requireAdminSession()) redirect("/admin/login"); if (!await requireAdminSession()) redirect("/admin/login");
@ -14,7 +13,16 @@ export default async function AdminSmtpPage() {
<p className="eyebrow">E-Mail</p> <p className="eyebrow">E-Mail</p>
<h1>SMTP Einstellungen</h1> <h1>SMTP Einstellungen</h1>
</div> </div>
<SmtpSettingsForm initialSettings={toPublicSmtpSettings(settings)} configured={isSmtpConfigured(settings)} /> <form className="admin-card admin-form" action="/api/admin/smtp" method="post">
<label>SMTP Server<input name="host" defaultValue={settings.host} /></label>
<label>Port<input name="port" defaultValue={settings.port} inputMode="numeric" /></label>
<label>Benutzername<input name="username" defaultValue={settings.username} /></label>
<label>Passwort<input name="password" type="password" placeholder="wird in dieser Foundation noch nicht gespeichert" disabled /></label>
<label>Absenderadresse<input name="fromAddress" type="email" defaultValue={settings.fromAddress} /></label>
<label>Antwortadresse<input name="replyToAddress" type="email" defaultValue={settings.replyToAddress} /></label>
<label className="admin-checkbox"><input name="tls" type="checkbox" defaultChecked={settings.tls} /> TLS aktivieren</label>
<button className="button" type="submit">Speichern</button>
</form>
</AdminShell> </AdminShell>
); );
} }

View file

@ -1,9 +1,7 @@
import { redirect } from "next/navigation"; import { redirect } from "next/navigation";
import AdminShell from "@/components/admin/AdminShell"; import AdminShell from "@/components/admin/AdminShell";
import { requireAdminSession } from "@/lib/admin/auth"; import { requireAdminSession } from "@/lib/admin/auth";
import { adminConfigurationStatus, appVersion, checkStorage, configDirectory, dataDirectory, isDockerEnvironment, olympusStatus, uploadDirectory } from "@/lib/runtime/config"; import { adminConfigurationStatus, appVersion, checkStorage, dataDirectory, isDockerEnvironment, olympusStatus, smtpStatus, uploadDirectory } from "@/lib/runtime/config";
import { getSmtpSettings, isSmtpConfigured } from "@/lib/mail/config";
import { getContentSystemStatus } from "@/lib/content/service";
export default async function AdminSystemPage() { export default async function AdminSystemPage() {
if (!await requireAdminSession()) redirect("/admin/login"); if (!await requireAdminSession()) redirect("/admin/login");
@ -14,8 +12,6 @@ export default async function AdminSystemPage() {
} catch { } catch {
storage = "error"; storage = "error";
} }
const [smtpSettings, contentStatus] = await Promise.all([getSmtpSettings(), getContentSystemStatus()]);
const smtpConfigured = isSmtpConfigured(smtpSettings);
return ( return (
<AdminShell> <AdminShell>
@ -31,43 +27,20 @@ export default async function AdminSystemPage() {
<div><dt>Docker Environment</dt><dd>{isDockerEnvironment() ? "ja" : "nein"}</dd></div> <div><dt>Docker Environment</dt><dd>{isDockerEnvironment() ? "ja" : "nein"}</dd></div>
<div><dt>Storage Status</dt><dd>{storage}</dd></div> <div><dt>Storage Status</dt><dd>{storage}</dd></div>
<div><dt>Data Directory</dt><dd>{dataDirectory}</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>Upload Directory</dt><dd>{uploadDirectory}</dd></div>
<div><dt>Admin Konfiguration</dt><dd>{adminConfigurationStatus()}</dd></div> <div><dt>Admin Konfiguration</dt><dd>{adminConfigurationStatus()}</dd></div>
<div><dt>Olympus Verbindung</dt><dd>{olympusStatus()}</dd></div> <div><dt>Olympus Verbindung</dt><dd>{olympusStatus()}</dd></div>
<div><dt>SMTP</dt><dd>{smtpConfigured ? "configured" : "missing"}</dd></div> <div><dt>SMTP</dt><dd>{smtpStatus()}</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> </dl>
</section> </section>
<section className="admin-card"> <section className="admin-card">
<h2>Betriebsregeln</h2> <h2>Betriebsregeln</h2>
<ul className="list"> <ul className="list">
<li>Runtime-Daten liegen unter <code>data/</code> und werden nicht versioniert.</li> <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>Uploads liegen unter <code>public/uploads/images/</code> und werden nicht versioniert.</li>
<li>SMTP-Versand ist serverseitig aktiv, sofern eine vollständige Konfiguration gespeichert ist.</li> <li>Olympus- und SMTP-Integration sind vorbereitet, aber nicht aktiv implementiert.</li>
</ul> </ul>
</section> </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> </AdminShell>
); );
} }

View file

@ -1,13 +1,18 @@
import { redirect } from "next/navigation"; import { redirect } from "next/navigation";
import AdminShell from "@/components/admin/AdminShell"; import AdminShell from "@/components/admin/AdminShell";
import ContentManager from "@/components/admin/ContentManager";
import { requireAdminSession } from "@/lib/admin/auth"; import { requireAdminSession } from "@/lib/admin/auth";
import { listMediaFiles } from "@/lib/admin/media";
import { getAllContent } from "@/lib/content/service"; const editablePages = [
["Startseite", "Hero, Vorteile, Textblöcke"],
["Leistungen", "Funktechnik, Messtechnik, Servicebereiche"],
["Funkgeräte-Service", "Diagnose, Abgleich, Fehlerbilder"],
["Reparatur", "Reparaturannahme und Hinweise"],
["Über uns", "Profil und Werkstattbeschreibung"],
["Kontakt", "Kontakttext und Hinweise"],
] as const;
export default async function AdminWebsitePage() { export default async function AdminWebsitePage() {
if (!await requireAdminSession()) redirect("/admin/login"); if (!await requireAdminSession()) redirect("/admin/login");
const [content, mediaFiles] = await Promise.all([getAllContent(), listMediaFiles()]);
return ( return (
<AdminShell> <AdminShell>
@ -15,7 +20,19 @@ export default async function AdminWebsitePage() {
<p className="eyebrow">Website</p> <p className="eyebrow">Website</p>
<h1>Website-Inhalte</h1> <h1>Website-Inhalte</h1>
</div> </div>
<ContentManager initialContent={content} mediaFiles={mediaFiles} /> <section className="admin-card">
<h2>Editierbare Inhalte</h2>
<p className="admin-muted">Diese Foundation bereitet die Inhaltsverwaltung vor. Die öffentliche Website bleibt bis zur finalen Publishing-Funktion weiterhin quellcodebasiert.</p>
<div className="admin-detail-grid">
{editablePages.map(([title, description]) => (
<article key={title} className="admin-detail">
<h3>{title}</h3>
<p>{description}</p>
<button className="button light" type="button" disabled>Bearbeiten vorbereitet</button>
</article>
))}
</div>
</section>
</AdminShell> </AdminShell>
); );
} }

View file

@ -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 });
}

View file

@ -1,6 +1,13 @@
import { NextResponse } from "next/server"; import { NextResponse } from "next/server";
import { requireAdminSession } from "@/lib/admin/auth"; import { requireAdminSession } from "@/lib/admin/auth";
import { deleteMediaFile, listMediaFiles, saveMediaFile } from "@/lib/admin/media"; import { listMediaFiles, saveMediaFile } from "@/lib/admin/media";
function relativeRedirect(target: string) {
return new NextResponse(null, {
status: 303,
headers: { Location: target },
});
}
export async function GET() { export async function GET() {
if (!await requireAdminSession()) return NextResponse.json({ message: "Nicht autorisiert." }, { status: 401 }); if (!await requireAdminSession()) return NextResponse.json({ message: "Nicht autorisiert." }, { status: 401 });
@ -13,27 +20,15 @@ export async function POST(request: Request) {
const file = form.get("file"); const file = form.get("file");
if (!(file instanceof File)) { if (!(file instanceof File)) {
return NextResponse.json({ message: "Bitte wählen Sie eine Datei aus." }, { status: 400 }); return relativeRedirect("/admin/medien?error=missing-file");
} }
try { try {
const result = await saveMediaFile(file); const result = await saveMediaFile(file);
if (result.error) return NextResponse.json({ message: result.error }, { status: 400 }); if (result.error) return relativeRedirect(`/admin/medien?error=${encodeURIComponent(result.error)}`);
} catch { } catch {
return NextResponse.json({ message: "Die Datei konnte nicht gespeichert werden. Bitte Upload-Speicher und Rechte prüfen." }, { status: 500 }); return relativeRedirect("/admin/medien?error=storage");
} }
return NextResponse.json({ message: "Datei erfolgreich hochgeladen.", files: await listMediaFiles() }); return relativeRedirect("/admin/medien?uploaded=1");
}
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() });
} }

View file

@ -1,26 +1,9 @@
import { NextResponse } from "next/server"; import { NextResponse } from "next/server";
import { requireAdminSession } from "@/lib/admin/auth"; import { requireAdminSession } from "@/lib/admin/auth";
import { saveSmtpSettings, toPublicSmtpSettings } from "@/lib/mail/config"; import { saveSmtpSettings } from "@/lib/admin/store";
import { sendTestMail } from "@/lib/mail/smtp";
export async function POST(request: Request) { export async function POST(request: Request) {
if (!await requireAdminSession()) return NextResponse.json({ message: "Nicht autorisiert." }, { status: 401 }); if (!await requireAdminSession()) return NextResponse.json({ message: "Nicht autorisiert." }, { status: 401 });
const form = await request.formData(); await saveSmtpSettings(await request.formData());
const action = String(form.get("action") ?? "save"); return new NextResponse(null, { status: 303, headers: { Location: "/admin/smtp?saved=1" } });
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),
});
} }

View file

@ -1,6 +1,5 @@
import { NextResponse } from "next/server"; import { NextResponse } from "next/server";
import { addContactInquiry } from "@/lib/admin/store"; 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() : "";
@ -13,12 +12,11 @@ export async function POST(request: Request) {
const subject = text(form.get("subject")); 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("@") || !subject || 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); await addContactInquiry(form);
await sendContactInquiryMail(inquiry);
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.",

View file

@ -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 });
}

View file

@ -1,6 +1,5 @@
import { NextResponse } from "next/server"; import { NextResponse } from "next/server";
import { adminConfigurationStatus, appVersion, checkStorage } from "@/lib/runtime/config"; import { adminConfigurationStatus, appVersion, checkStorage } from "@/lib/runtime/config";
import { getSmtpSettings, isSmtpConfigured } from "@/lib/mail/config";
export async function GET() { export async function GET() {
let storage = "ok"; let storage = "ok";
@ -11,14 +10,11 @@ export async function GET() {
storage = "error"; storage = "error";
} }
const smtpSettings = await getSmtpSettings();
return NextResponse.json({ return NextResponse.json({
status: storage === "ok" ? "ok" : "error", status: storage === "ok" ? "ok" : "error",
version: appVersion, version: appVersion,
storage, storage,
admin: adminConfigurationStatus(), admin: adminConfigurationStatus(),
smtp: isSmtpConfigured(smtpSettings) ? "configured" : "missing",
timestamp: new Date().toISOString(), timestamp: new Date().toISOString(),
}); });
} }

View file

@ -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",
},
});
}

View file

@ -1,6 +1,5 @@
import { NextResponse } from "next/server"; import { NextResponse } from "next/server";
import { addRepairInquiry } from "@/lib/admin/store"; 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 +13,13 @@ 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); await addRepairInquiry(form);
await sendRepairInquiryMail(inquiry);
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.",

View file

@ -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 "";
}
}

View file

@ -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.");
}

View file

@ -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.");
}

View file

@ -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.");
}

View file

@ -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>
</> </>

View file

@ -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>

View file

@ -488,372 +488,6 @@ h3 {
line-height: 1; line-height: 1;
} }
.status-page {
min-height: calc(100vh - 78px);
padding: 72px 0;
background:
linear-gradient(135deg, rgba(244, 246, 248, 0.96), rgba(232, 238, 246, 0.94)),
url("/workbench-signal.svg");
background-position: center;
background-size: cover;
}
.status-page-inner {
display: grid;
align-items: start;
}
.status-card {
border: 1px solid rgba(8, 42, 96, 0.12);
border-radius: var(--radius);
background: rgba(255, 255, 255, 0.97);
padding: 34px;
box-shadow: var(--shadow);
}
.status-card-narrow {
width: min(760px, 100%);
margin: 0 auto;
}
.status-card h1 {
margin-bottom: 10px;
color: var(--ink);
font-size: clamp(36px, 5vw, 58px);
line-height: 1;
}
.status-title-row {
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: 24px;
margin-bottom: 26px;
}
.status-badge,
.status-pill {
display: inline-flex;
min-height: 32px;
align-items: center;
border-radius: 999px;
border: 1px solid rgba(30, 159, 184, 0.24);
background: rgba(30, 159, 184, 0.1);
color: var(--navy);
font-size: 13px;
font-weight: 800;
padding: 0 12px;
white-space: nowrap;
}
.status-summary {
display: grid;
grid-template-columns: repeat(4, minmax(0, 1fr));
gap: 14px;
margin: 0 0 34px;
}
.status-summary div {
border: 1px solid var(--line);
border-radius: var(--radius);
background: #f8fafc;
padding: 16px;
}
.status-summary dt {
margin-bottom: 8px;
color: var(--muted);
font-size: 12px;
font-weight: 800;
text-transform: uppercase;
}
.status-summary dd {
margin: 0;
color: var(--ink);
font-size: 17px;
font-weight: 800;
}
.status-timeline-section h2 {
margin-bottom: 20px;
font-size: clamp(26px, 3vw, 34px);
}
.public-status-timeline {
display: grid;
gap: 0;
margin: 0;
padding: 0;
list-style: none;
}
.public-status-timeline li {
position: relative;
border-left: 2px solid #d7e0ea;
padding: 0 0 18px 26px;
}
.public-status-timeline li:last-child {
padding-bottom: 0;
}
.timeline-dot {
position: absolute;
left: -8px;
top: 15px;
width: 14px;
height: 14px;
border: 3px solid #fff;
border-radius: 999px;
background: var(--steel);
box-shadow: 0 0 0 1px rgba(8, 42, 96, 0.12);
}
.public-status-timeline li.is-current .timeline-dot {
background: var(--orange);
}
.timeline-content {
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: 18px;
border: 1px solid var(--line);
border-radius: var(--radius);
background: #fff;
padding: 16px;
}
.timeline-title {
margin-bottom: 8px;
color: var(--ink);
font-size: 17px;
font-weight: 800;
}
.timeline-content time,
.status-muted {
color: var(--muted);
font-size: 14px;
}
.estimate-card {
display: grid;
gap: 22px;
border: 1px solid rgba(8, 42, 96, 0.12);
border-radius: var(--radius);
background: #f8fafc;
margin: 0 0 34px;
padding: 24px;
}
.estimate-header,
.estimate-item,
.estimate-totals div,
.estimate-form-actions {
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: 18px;
}
.estimate-header h2 {
margin: 0 0 8px;
color: var(--ink);
font-size: clamp(26px, 3vw, 34px);
}
.estimate-status {
display: inline-flex;
min-height: 32px;
align-items: center;
border: 1px solid rgba(232, 124, 46, 0.28);
border-radius: 999px;
background: rgba(232, 124, 46, 0.12);
color: #8b420d;
font-size: 13px;
font-weight: 800;
padding: 0 12px;
white-space: nowrap;
}
.estimate-message,
.estimate-status-text {
border-left: 4px solid var(--steel);
background: #fff;
color: var(--ink);
margin: 0;
padding: 14px 16px;
}
.estimate-meta {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 12px;
margin: 0;
}
.estimate-meta div {
border: 1px solid var(--line);
border-radius: 8px;
background: #fff;
padding: 14px;
}
.estimate-meta dt,
.estimate-totals dt {
color: var(--muted);
font-size: 12px;
font-weight: 800;
text-transform: uppercase;
}
.estimate-meta dd,
.estimate-totals dd {
margin: 6px 0 0;
color: var(--ink);
font-weight: 800;
}
.estimate-items {
display: grid;
gap: 12px;
}
.estimate-item {
border: 1px solid var(--line);
border-radius: 8px;
background: #fff;
padding: 16px;
}
.estimate-item h3 {
margin: 6px 0;
color: var(--ink);
font-size: 18px;
}
.estimate-item p {
margin: 0;
color: var(--muted);
}
.estimate-item-type {
color: var(--steel);
font-size: 12px;
font-weight: 800;
text-transform: uppercase;
}
.estimate-item-price {
display: grid;
gap: 6px;
min-width: 150px;
text-align: right;
}
.estimate-item-price span {
color: var(--muted);
font-size: 14px;
}
.estimate-item-price strong {
color: var(--ink);
font-size: 18px;
}
.estimate-totals {
display: grid;
gap: 8px;
width: min(380px, 100%);
justify-self: end;
margin: 0;
}
.estimate-totals div {
border-bottom: 1px solid var(--line);
padding: 8px 0;
}
.estimate-totals .estimate-total-highlight {
border-bottom: 0;
color: var(--ink);
font-size: 20px;
}
.estimate-actions-panel {
display: grid;
gap: 16px;
border-top: 1px solid var(--line);
padding-top: 18px;
}
.estimate-action-buttons {
display: flex;
flex-wrap: wrap;
gap: 12px;
}
.button.danger {
border-color: #a33a2d;
background: #a33a2d;
color: #fff;
}
.button.danger:hover {
background: #842f25;
}
.button:disabled {
cursor: not-allowed;
opacity: 0.64;
}
.estimate-action-form {
display: grid;
gap: 10px;
border: 1px solid var(--line);
border-radius: 8px;
background: #fff;
padding: 16px;
}
.estimate-action-form label {
color: var(--ink);
font-weight: 800;
}
.estimate-action-form textarea {
width: 100%;
border: 1px solid #cfd8e5;
border-radius: 8px;
color: var(--ink);
padding: 12px;
resize: vertical;
}
.estimate-form-actions {
justify-content: flex-start;
flex-wrap: wrap;
}
.estimate-feedback {
border: 1px solid rgba(46, 125, 50, 0.24);
border-radius: 8px;
background: rgba(46, 125, 50, 0.08);
color: #1b5e20;
font-weight: 800;
margin: 0;
padding: 12px 14px;
}
.estimate-feedback.error {
border-color: rgba(163, 58, 45, 0.26);
background: rgba(163, 58, 45, 0.08);
color: #842f25;
}
.admin-login-page { .admin-login-page {
min-height: 100vh; min-height: 100vh;
display: grid; display: grid;
@ -996,7 +630,7 @@ h3 {
.admin-kpis { .admin-kpis {
display: grid; display: grid;
grid-template-columns: repeat(auto-fit, minmax(180px, 1fr)); grid-template-columns: repeat(4, minmax(0, 1fr));
gap: 14px; gap: 14px;
margin-bottom: 18px; margin-bottom: 18px;
} }
@ -1120,29 +754,26 @@ h3 {
margin-bottom: 18px; margin-bottom: 18px;
} }
.media-toolbar { .media-grid {
display: grid; display: grid;
grid-template-columns: minmax(220px, 1.2fr) minmax(180px, 1fr) 150px 150px auto; grid-template-columns: repeat(4, minmax(0, 1fr));
gap: 14px; gap: 12px;
align-items: end;
} }
.media-toolbar label { .media-item {
display: grid; display: grid;
gap: 7px; min-height: 130px;
color: var(--ink); place-items: center;
font-size: 14px; border: 1px solid var(--line);
font-weight: 800;
}
.media-toolbar input,
.media-toolbar select {
width: 100%;
border: 1px solid #cfd8e5;
border-radius: 8px; border-radius: 8px;
background: #fff; background: #f8fafc;
padding: 11px 12px; overflow: hidden;
color: var(--ink); }
.media-item img {
width: 100%;
height: 150px;
object-fit: cover;
} }
.media-empty { .media-empty {
@ -1156,81 +787,47 @@ h3 {
color: var(--muted); color: var(--muted);
} }
.media-card-grid { .media-list {
display: grid; display: grid;
grid-template-columns: repeat(3, minmax(0, 1fr)); gap: 14px;
gap: 16px;
} }
.media-card { .media-row {
display: grid; display: grid;
grid-template-columns: 180px minmax(0, 1fr);
gap: 16px;
align-items: center;
border: 1px solid var(--line); border: 1px solid var(--line);
border-radius: 8px; border-radius: 8px;
background: #fff; background: #fff;
overflow: hidden; padding: 12px;
box-shadow: 0 10px 26px rgba(8, 42, 96, 0.06);
} }
.media-card-preview { .media-preview {
display: grid; display: grid;
width: 100%; min-height: 110px;
aspect-ratio: 16 / 10;
place-items: center; place-items: center;
border: 0; border: 1px solid var(--line);
border-bottom: 1px solid var(--line); border-radius: 8px;
background: #f8fafc; background: #f8fafc;
color: var(--navy); color: var(--navy);
font-weight: 800; font-weight: 800;
overflow: hidden; overflow: hidden;
cursor: pointer;
padding: 0;
} }
.media-card-preview img { .media-preview img {
width: 100%; width: 100%;
height: 100%; height: 110px;
object-fit: cover; object-fit: cover;
} }
.pdf-icon {
display: grid;
width: 72px;
height: 92px;
place-items: center;
border: 2px solid var(--navy);
border-radius: 6px;
background: #fff;
color: var(--navy);
font-size: 18px;
font-weight: 900;
}
.media-card-body {
display: grid;
gap: 14px;
padding: 16px;
}
.media-card-body h3 {
overflow: hidden;
margin: 0;
text-overflow: ellipsis;
white-space: nowrap;
}
.media-meta { .media-meta {
display: grid; display: grid;
grid-template-columns: 1fr; grid-template-columns: repeat(3, minmax(0, 1fr));
gap: 10px; gap: 10px;
margin: 0; margin: 0;
} }
.media-meta div {
display: grid;
grid-template-columns: 72px minmax(0, 1fr);
gap: 8px;
}
.media-meta dt { .media-meta dt {
color: var(--muted); color: var(--muted);
font-size: 13px; font-size: 13px;
@ -1242,91 +839,6 @@ h3 {
overflow-wrap: anywhere; overflow-wrap: anywhere;
} }
.media-actions {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 8px;
}
.media-actions button,
.media-actions a {
display: inline-flex;
min-height: 36px;
align-items: center;
justify-content: center;
border: 1px solid var(--line);
border-radius: 8px;
background: #fff;
color: var(--navy);
font-weight: 800;
padding: 0 10px;
text-align: center;
}
.media-actions .danger {
border-color: #f1b5ad;
color: #b42318;
}
.lightbox {
position: fixed;
inset: 0;
z-index: 100;
display: grid;
grid-template-rows: auto minmax(0, 1fr);
background: rgba(5, 16, 32, 0.9);
color: #fff;
}
.lightbox-toolbar {
position: relative;
z-index: 2;
display: flex;
align-items: center;
justify-content: space-between;
gap: 16px;
border-bottom: 1px solid rgba(255, 255, 255, 0.12);
background: rgba(5, 16, 32, 0.96);
padding: 12px 18px;
}
.lightbox-toolbar div {
display: flex;
gap: 8px;
}
.lightbox-toolbar button {
border: 1px solid rgba(255, 255, 255, 0.2);
border-radius: 8px;
background: rgba(255, 255, 255, 0.08);
color: #fff;
font-weight: 800;
padding: 8px 10px;
}
.lightbox-backdrop {
position: fixed;
inset: 0;
border: 0;
background: transparent;
}
.lightbox-stage {
position: relative;
z-index: 1;
display: grid;
place-items: center;
overflow: auto;
padding: 28px;
}
.lightbox-stage img {
max-width: min(92vw, 1400px);
max-height: 82vh;
transform-origin: center;
transition: transform 140ms ease;
}
.system-list { .system-list {
display: grid; display: grid;
gap: 0; gap: 0;
@ -1351,201 +863,6 @@ h3 {
overflow-wrap: anywhere; overflow-wrap: anywhere;
} }
.content-manager {
display: grid;
gap: 18px;
}
.content-editor-layout {
display: grid;
grid-template-columns: 220px minmax(0, 1fr);
gap: 18px;
align-items: start;
}
.content-tabs {
position: sticky;
top: 18px;
display: grid;
gap: 6px;
}
.content-tabs button,
.content-preview-toolbar button {
border: 1px solid var(--line);
border-radius: 8px;
background: #fff;
color: var(--navy);
font-weight: 800;
padding: 10px 12px;
text-align: left;
}
.content-tabs button[aria-current="page"],
.content-preview-toolbar button[aria-current="true"] {
border-color: var(--navy);
background: var(--navy);
color: #fff;
}
.content-editor {
display: grid;
gap: 16px;
min-width: 0;
}
.content-editor-top {
display: flex;
align-items: center;
justify-content: space-between;
gap: 16px;
}
.content-editor-top h2 {
margin-bottom: 0;
}
.content-save-actions {
display: flex;
justify-content: flex-end;
}
.content-editor-heading {
margin: 14px 0 0;
border-top: 1px solid var(--line);
padding-top: 16px;
color: var(--navy);
}
.content-field-grid {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 14px;
}
.content-field-grid label,
.content-editor label {
display: grid;
gap: 7px;
color: var(--ink);
font-size: 14px;
font-weight: 800;
}
.content-field-grid input,
.content-field-grid select,
.content-field-grid textarea,
.content-editor input,
.content-editor select,
.content-editor textarea {
width: 100%;
border: 1px solid #cfd8e5;
border-radius: 8px;
background: #fff;
padding: 11px 12px;
color: var(--ink);
}
.content-editor textarea {
min-height: 110px;
resize: vertical;
}
.content-repeaters {
display: grid;
gap: 12px;
}
.content-repeater {
display: grid;
gap: 12px;
border: 1px solid var(--line);
border-radius: 8px;
background: #f8fafc;
padding: 14px;
}
.content-image-picker {
display: grid;
gap: 10px;
}
.content-image-preview {
display: grid;
grid-template-columns: minmax(140px, 240px) auto;
gap: 12px;
align-items: end;
}
.content-image-preview img {
width: 100%;
aspect-ratio: 16 / 9;
border: 1px solid var(--line);
border-radius: 8px;
background: #f8fafc;
object-fit: cover;
}
.content-preview-card {
overflow: hidden;
}
.content-preview-toolbar {
display: flex;
align-items: center;
justify-content: space-between;
gap: 16px;
margin-bottom: 18px;
}
.content-preview-toolbar h2 {
margin-bottom: 0;
}
.content-preview-toolbar .eyebrow {
margin-bottom: 4px;
}
.content-preview-toolbar div {
display: flex;
flex-wrap: wrap;
gap: 8px;
}
.content-preview {
width: 100%;
max-width: 100%;
min-height: 320px;
border: 1px solid var(--line);
border-radius: 8px;
background:
linear-gradient(90deg, rgba(6, 24, 52, 0.95), rgba(8, 42, 96, 0.72)),
url("/workbench-signal.svg"),
#07172d;
background-size: cover;
color: #fff;
padding: 34px;
}
.content-preview.tablet {
max-width: 760px;
margin: 0 auto;
}
.content-preview.mobile {
max-width: 390px;
margin: 0 auto;
}
.content-preview h1 {
font-size: clamp(30px, 5vw, 52px);
}
.content-preview .lead,
.content-preview p {
color: rgba(255, 255, 255, 0.82);
}
@media (max-width: 1080px) and (min-width: 861px) { @media (max-width: 1080px) and (min-width: 861px) {
.brand-logo { .brand-logo {
width: 236px; width: 236px;
@ -1629,44 +946,10 @@ h3 {
.admin-shell, .admin-shell,
.admin-kpis, .admin-kpis,
.admin-detail-grid, .admin-detail-grid,
.media-grid, .media-grid {
.status-summary {
grid-template-columns: 1fr; grid-template-columns: 1fr;
} }
.status-page {
padding: 38px 0;
}
.status-card {
padding: 22px;
}
.status-title-row,
.timeline-content,
.estimate-header,
.estimate-item {
display: grid;
}
.status-badge,
.estimate-status {
width: fit-content;
}
.estimate-meta {
grid-template-columns: 1fr;
}
.estimate-item-price {
min-width: 0;
text-align: left;
}
.estimate-totals {
justify-self: stretch;
}
.admin-sidebar { .admin-sidebar {
position: static; position: static;
height: auto; height: auto;
@ -1680,8 +963,7 @@ h3 {
display: grid; display: grid;
} }
.media-toolbar, .media-row,
.media-card-grid,
.media-meta { .media-meta {
grid-template-columns: 1fr; grid-template-columns: 1fr;
} }
@ -1690,29 +972,4 @@ h3 {
grid-template-columns: 1fr; grid-template-columns: 1fr;
gap: 4px; gap: 4px;
} }
.content-editor-layout {
grid-template-columns: 1fr;
}
.content-tabs {
position: static;
grid-template-columns: repeat(2, minmax(0, 1fr));
}
.content-field-grid {
grid-template-columns: 1fr;
}
.content-editor-top,
.content-preview-toolbar,
.content-image-preview {
grid-template-columns: 1fr;
align-items: stretch;
}
.content-editor-top,
.content-preview-toolbar {
display: grid;
}
} }

View file

@ -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>
</> </>

View file

@ -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 />

View file

@ -1,14 +1,12 @@
import type { Metadata } from "next"; import type { Metadata } from "next";
import SiteFrame from "@/components/SiteFrame"; import SiteFrame from "@/components/SiteFrame";
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 (

View file

@ -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>
</> </>

View file

@ -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>

View file

@ -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">

View file

@ -1,5 +1,4 @@
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";
@ -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 };

View file

@ -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>
);
}

View file

@ -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>
</> </>

View file

@ -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);
@ -17,9 +16,8 @@ export default function ContactForm() {
const email = String(form.get("email") ?? "").trim(); const email = String(form.get("email") ?? "").trim();
const subject = String(form.get("subject") ?? "").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("@") || !subject || 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 +27,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" />
@ -61,7 +59,7 @@ export default function ContactForm() {
<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>}

View file

@ -1,82 +1,33 @@
"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 { 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 · v{appVersion}</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>

View file

@ -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>
); );

View file

@ -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>

View file

@ -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>
);
}

View file

@ -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>
)}
</>
);
}

View file

@ -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>
</>
);
}

View file

@ -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>
);
}

View file

@ -8,23 +8,21 @@ services:
- "3010:3010" - "3010:3010"
environment: environment:
NEXT_PUBLIC_SITE_URL: ${NEXT_PUBLIC_SITE_URL:-http://127.0.0.1:3010} NEXT_PUBLIC_SITE_URL: ${NEXT_PUBLIC_SITE_URL:-http://127.0.0.1:3010}
NEXT_PUBLIC_APP_VERSION: "0.4.1" NEXT_PUBLIC_APP_VERSION: "0.2.2"
ADMIN_EMAIL: ${ADMIN_EMAIL:-} ADMIN_EMAIL: ${ADMIN_EMAIL:-}
ADMIN_PASSWORD: ${ADMIN_PASSWORD:-} ADMIN_PASSWORD: ${ADMIN_PASSWORD:-}
ADMIN_SESSION_SECRET: ${ADMIN_SESSION_SECRET:-} ADMIN_SESSION_SECRET: ${ADMIN_SESSION_SECRET:-}
AUTH_COOKIE_SECURE: ${AUTH_COOKIE_SECURE:-false} 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:-}
OLYMPUS_PUBLIC_STATUS_API_TOKEN: ${OLYMPUS_PUBLIC_STATUS_API_TOKEN:-}
DOCKER_ENV: "true" DOCKER_ENV: "true"
volumes: volumes:
- funktechnik-data:/app/data - funktechnik-data:/app/data
- funktechnik-storage:/app/storage - funktechnik-uploads:/app/public/uploads/images
- funktechnik-next-cache:/app/.next/cache - funktechnik-next-cache:/app/.next/cache
restart: unless-stopped restart: unless-stopped
volumes: volumes:
funktechnik-data: funktechnik-data:
funktechnik-storage: funktechnik-uploads:
funktechnik-next-cache: funktechnik-next-cache:

View file

@ -1,7 +1,7 @@
import { randomUUID } from "crypto"; import { randomUUID } from "crypto";
import { mkdir, readFile, readdir, rm, stat, writeFile } from "fs/promises"; import { mkdir, readdir, rm, stat, writeFile } from "fs/promises";
import path from "path"; import path from "path";
import { migrateLegacyUploads, uploadDirectory } from "@/lib/runtime/config"; import { uploadDirectory } from "@/lib/runtime/config";
const maxUploadSize = 5 * 1024 * 1024; const maxUploadSize = 5 * 1024 * 1024;
@ -15,23 +15,12 @@ const allowedMimeTypes = new Map([
export type MediaFile = { export type MediaFile = {
name: string; name: string;
url: string; url: string;
type: "image" | "pdf"; type: string;
extension: string;
size: number; size: number;
uploadedAt: string; uploadedAt: string;
isImage: boolean; 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() { export async function ensureUploadDirectory() {
await mkdir(uploadDirectory, { recursive: true }); await mkdir(uploadDirectory, { recursive: true });
const probe = path.join(uploadDirectory, `.write-check-${Date.now()}`); const probe = path.join(uploadDirectory, `.write-check-${Date.now()}`);
@ -83,32 +72,21 @@ export function resolveUploadTarget(fileName: string) {
return target; 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[]> { export async function listMediaFiles(): Promise<MediaFile[]> {
await migrateLegacyUploads();
await mkdir(uploadDirectory, { recursive: true }); await mkdir(uploadDirectory, { recursive: true });
const entries = await readdir(uploadDirectory); const entries = await readdir(uploadDirectory);
const visibleEntries = entries.filter(isAllowedStoredFile); const visibleEntries = entries.filter((entry) => !entry.startsWith("."));
const files = await Promise.all(visibleEntries.map(async (entry) => { const files = await Promise.all(visibleEntries.map(async (entry) => {
const fileStat = await stat(path.join(uploadDirectory, entry)); const fileStat = await stat(path.join(uploadDirectory, entry));
const extension = path.extname(entry).toLowerCase(); const extension = path.extname(entry).toLowerCase();
const isImage = [".jpg", ".jpeg", ".png", ".webp"].includes(extension);
const isPdf = extension === ".pdf";
return { return {
name: entry, name: entry,
url: `/api/media/${encodeURIComponent(entry)}`, url: `/uploads/images/${encodeURIComponent(entry)}`,
type: isPdf ? "pdf" as const : "image" as const, type: extension.replace(".", "").toUpperCase() || "Datei",
extension: extension.replace(".", "").toUpperCase() || "Datei",
size: fileStat.size, size: fileStat.size,
uploadedAt: fileStat.birthtime.toISOString(), uploadedAt: fileStat.birthtime.toISOString(),
isImage, isImage: [".jpg", ".jpeg", ".png", ".webp"].includes(extension),
isPdf,
}; };
})); }));
@ -126,30 +104,3 @@ export async function saveMediaFile(file: File) {
return { fileName: safeName }; 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 {};
}

View file

@ -1,7 +1,7 @@
import { mkdir, readFile, writeFile } from "fs/promises"; import { mkdir, readFile, writeFile } from "fs/promises";
import path from "path"; import path from "path";
import { configDirectory, dataDirectory } from "@/lib/runtime/config"; import { dataDirectory } from "@/lib/runtime/config";
import type { ContactInquiry, InquiryStatus, RepairInquiry, SiteSettings } from "./types"; import type { ContactInquiry, InquiryStatus, RepairInquiry, SiteSettings, SmtpSettings } from "./types";
async function ensureDataDir() { async function ensureDataDir() {
await mkdir(dataDirectory, { recursive: true }); await mkdir(dataDirectory, { recursive: true });
@ -112,8 +112,26 @@ export async function saveSiteSettings(form: FormData) {
return settings; return settings;
} }
export async function ensureConfigDir() { export async function getSmtpSettings() {
await mkdir(configDirectory, { recursive: true }); return readJson<SmtpSettings>("smtp-settings.json", {
host: "",
port: "587",
username: "",
fromAddress: "",
replyToAddress: "",
tls: true,
});
} }
export { text }; export async function saveSmtpSettings(form: FormData) {
const settings: SmtpSettings = {
host: text(form.get("host")),
port: text(form.get("port")),
username: text(form.get("username")),
fromAddress: text(form.get("fromAddress")),
replyToAddress: text(form.get("replyToAddress")),
tls: form.get("tls") === "on",
};
await writeJson("smtp-settings.json", settings);
return settings;
}

View file

@ -42,15 +42,7 @@ export type SmtpSettings = {
host: string; host: string;
port: string; port: string;
username: string; username: string;
password?: string;
security: "starttls" | "tls" | "none";
fromAddress: string; fromAddress: string;
replyToAddress: string; replyToAddress: string;
recipientAddress: string; tls: boolean;
bccAddress?: string;
lastTestAt?: string;
lastTestStatus?: "success" | "error";
lastDeliveryAt?: string;
lastDeliveryStatus?: "success" | "error";
lastError?: string;
}; };

View file

@ -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;

View file

@ -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,
};
}

View file

@ -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;
};

View file

@ -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 };

View file

@ -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;
}

View file

@ -1,74 +0,0 @@
import type { ContactMailInput, RepairMailInput } from "./types";
function escapeHtml(value?: string) {
return (value ?? "")
.replace(/&/g, "&amp;")
.replace(/</g, "&lt;")
.replace(/>/g, "&gt;")
.replace(/"/g, "&quot;")
.replace(/'/g, "&#039;");
}
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>",
};
}

View file

@ -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;

View file

@ -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 };
}

View file

@ -1,15 +1,10 @@
import { existsSync } from "fs"; import { existsSync } from "fs";
import { access, copyFile, mkdir, readdir, rm, writeFile } from "fs/promises"; import { access, mkdir, writeFile, rm } from "fs/promises";
import path from "path"; import path from "path";
import { appVersion } from "./version"; import { appVersion } from "./version";
export const dataDirectory = path.join(process.cwd(), "data"); export const dataDirectory = path.join(process.cwd(), "data");
export const storageDirectory = path.join(process.cwd(), "storage"); export const uploadDirectory = path.join(process.cwd(), "public", "uploads", "images");
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() { export function adminConfigurationStatus() {
return process.env.ADMIN_EMAIL && process.env.ADMIN_PASSWORD && process.env.ADMIN_SESSION_SECRET ? "configured" : "missing"; return process.env.ADMIN_EMAIL && process.env.ADMIN_PASSWORD && process.env.ADMIN_SESSION_SECRET ? "configured" : "missing";
@ -21,10 +16,7 @@ export function isDockerEnvironment() {
export async function ensureRuntimeDirectories() { export async function ensureRuntimeDirectories() {
await mkdir(dataDirectory, { recursive: true }); await mkdir(dataDirectory, { recursive: true });
await mkdir(configDirectory, { recursive: true });
await mkdir(contentBackupDirectory, { recursive: true });
await mkdir(uploadDirectory, { recursive: true }); await mkdir(uploadDirectory, { recursive: true });
await migrateLegacyUploads();
} }
export async function checkStorage() { export async function checkStorage() {
@ -44,20 +36,4 @@ export function smtpStatus() {
return "prepared"; 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 }; export { appVersion };

View file

@ -1 +1 @@
export const appVersion = "0.4.1"; export const appVersion = "0.2.2";

32
package-lock.json generated
View file

@ -1,16 +1,14 @@
{ {
"name": "funktechnik-schubert-website", "name": "funktechnik-schubert-website",
"version": "0.4.1", "version": "0.2.2",
"lockfileVersion": 3, "lockfileVersion": 3,
"requires": true, "requires": true,
"packages": { "packages": {
"": { "": {
"name": "funktechnik-schubert-website", "name": "funktechnik-schubert-website",
"version": "0.4.1", "version": "0.2.2",
"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": {

View file

@ -1,6 +1,6 @@
{ {
"name": "funktechnik-schubert-website", "name": "funktechnik-schubert-website",
"version": "0.4.1", "version": "0.2.2",
"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"
}, },

View file

@ -6,7 +6,7 @@ STAMP="$(date +%Y%m%d-%H%M%S)"
TARGET="${BACKUP_DIR}/funktechnik-data-${STAMP}.tar.gz" TARGET="${BACKUP_DIR}/funktechnik-data-${STAMP}.tar.gz"
mkdir -p "$BACKUP_DIR" mkdir -p "$BACKUP_DIR"
tar -czf "$TARGET" data storage tar -czf "$TARGET" data public/uploads
echo "Backup erstellt: $TARGET" echo "Backup erstellt: $TARGET"
echo "Hinweis: .env wird aus Sicherheitsgruenden nicht automatisch gesichert." echo "Hinweis: .env wird aus Sicherheitsgruenden nicht automatisch gesichert."

View file

@ -5,10 +5,7 @@ const path = require("path");
const requiredEnv = ["ADMIN_EMAIL", "ADMIN_PASSWORD", "ADMIN_SESSION_SECRET"]; const requiredEnv = ["ADMIN_EMAIL", "ADMIN_PASSWORD", "ADMIN_SESSION_SECRET"];
const requiredDirs = [ const requiredDirs = [
path.join(process.cwd(), "data"), path.join(process.cwd(), "data"),
path.join(process.cwd(), "storage", "config"), path.join(process.cwd(), "public", "uploads", "images"),
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"), path.join(process.cwd(), ".next", "cache"),
]; ];
@ -24,26 +21,6 @@ function ensureWritableDirectory(directory) {
fs.rmSync(probe, { force: true }); 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]); const missingEnv = requiredEnv.filter((name) => !process.env[name]);
if (missingEnv.length > 0) { if (missingEnv.length > 0) {
fail(`Start abgebrochen: fehlende Umgebungsvariablen: ${missingEnv.join(", ")}. Bitte .env anhand von .env.example konfigurieren.`); fail(`Start abgebrochen: fehlende Umgebungsvariablen: ${missingEnv.join(", ")}. Bitte .env anhand von .env.example konfigurieren.`);
@ -51,10 +28,9 @@ if (missingEnv.length > 0) {
try { try {
for (const directory of requiredDirs) ensureWritableDirectory(directory); for (const directory of requiredDirs) ensureWritableDirectory(directory);
migrateLegacyUploads();
} catch (error) { } catch (error) {
fail(`Start abgebrochen: Runtime-Verzeichnis ist nicht beschreibbar. Details: ${error instanceof Error ? error.message : "unbekannter Fehler"}`); 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`); process.stdout.write(`[funktechnik-website] Runtime checks ok. Starting version ${process.env.NEXT_PUBLIC_APP_VERSION ?? "0.2.2"}.\n`);
require("./server.js"); require("./server.js");

View file

@ -1 +0,0 @@

View file

@ -1 +0,0 @@