Compare commits

..

9 commits
v0.2.3 ... main

Author SHA1 Message Date
Schubert Ferenc
6f6891134e feat(status): add estimate customer actions 2026-07-05 02:17:19 +02:00
Schubert Ferenc
efdc4d5be1 feat(status): add public repair status page 2026-07-04 21:06:43 +02:00
Schubert Ferenc
a284bbc188 feat(cms): improve admin content editing 2026-07-04 11:01:29 +02:00
Schubert Ferenc
12d31f5468 feat(cms): add storage based website content management 2026-07-04 00:58:33 +02:00
Schubert Ferenc
7d5689cfa4 fix(forms): repair public form validation and reset 2026-07-04 00:31:13 +02:00
Schubert Ferenc
3456b04ed4 feat(mail): add smtp email delivery 2026-07-04 00:06:20 +02:00
Schubert Ferenc
faddaa91d0 fix(admin): serve media from private storage 2026-07-03 23:45:33 +02:00
Schubert Ferenc
d9da86dd46 fix(admin): clear media upload input safely 2026-07-03 23:35:16 +02:00
Schubert Ferenc
ec30f69c72 fix(admin): complete media manager 2026-07-03 23:25:41 +02:00
66 changed files with 3760 additions and 347 deletions

View file

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

View file

@ -25,3 +25,13 @@ OLYMPUS_INTAKE_API_URL=
# Optional future server-side Olympus intake token.
# Leave empty until the Olympus integration is explicitly implemented.
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,9 +9,17 @@ data/contact-inquiries.json
data/repair-inquiries.json
data/site-settings.json
data/smtp-settings.json
public/uploads/images/*
!public/uploads/.gitkeep
!public/uploads/images/.gitkeep
storage/uploads/images/*
storage/config/*
storage/content/*
storage/content/backups/*
!storage/.gitkeep
!storage/config/.gitkeep
!storage/content/.gitkeep
!storage/content/backups/
!storage/content/backups/.gitkeep
!storage/uploads/.gitkeep
!storage/uploads/images/.gitkeep
.DS_Store
*.tmp
*.temp

View file

@ -14,7 +14,7 @@ FROM node:22-alpine AS runner
WORKDIR /app
ENV NODE_ENV=production
ENV NEXT_TELEMETRY_DISABLED=1
ENV NEXT_PUBLIC_APP_VERSION=0.2.2
ENV NEXT_PUBLIC_APP_VERSION=0.4.1
ENV DOCKER_ENV=true
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/scripts/runtime-start.js ./runtime-start.js
RUN mkdir -p /app/data /app/public/uploads/images /app/.next/cache \
&& chown -R nextjs:nodejs /app/data /app/public/uploads /app/.next/cache
RUN mkdir -p /app/data /app/storage/config /app/storage/content/backups /app/storage/uploads/images /app/.next/cache \
&& chown -R nextjs:nodejs /app/data /app/storage /app/.next/cache
USER nextjs
EXPOSE 3010

160
README.md
View file

@ -8,12 +8,12 @@ Eigenständige öffentliche Firmenwebsite für Funktechnik Schubert. Dieses Proj
- TypeScript strict
- Serverseitige API-Routen für Kontakt und Reparaturannahme
- Geschützter Admin-Bereich unter `/admin`
- Lokale Foundation-Datenablage für Anfragen und Einstellungen
- Lokale Storage-Datenablage für Anfragen, Einstellungen, Medien und Website-Inhalte
- Docker
- Nginx/Reverse-Proxy-fähig
- SEO Metadata, Sitemap und robots.txt
Version: `0.2.2`
Version: `0.4.1`
## Seiten
@ -23,6 +23,7 @@ Version: `0.2.2`
- `/reparatur`
- `/ueber-uns`
- `/kontakt`
- `/status/[token]`
- `/impressum`
- `/datenschutz`
- `/admin/login`
@ -87,7 +88,7 @@ Port:
Die Runtime-Daten werden über Docker-Volumes gespeichert:
- `funktechnik-data``/app/data`
- `funktechnik-uploads` → `/app/public/uploads/images`
- `funktechnik-storage` → `/app/storage`
- `funktechnik-next-cache``/app/.next/cache`
Dadurch sind keine manuellen `chmod`- oder `chown`-Befehle notwendig.
@ -109,8 +110,12 @@ Variablen:
- `AUTH_COOKIE_SECURE`: `true` in Produktion mit HTTPS, lokal `false`
- `OLYMPUS_INTAKE_API_URL`: vorbereitet für spätere serverseitige Olympus-Anbindung
- `OLYMPUS_INTAKE_API_TOKEN`: vorbereitet für spätere serverseitige Olympus-Anbindung
- `OLYMPUS_PUBLIC_STATUS_API_URL`: serverseitig erreichbare Olympus-Basis-URL für öffentliche Reparaturstatuslinks
- `OLYMPUS_PUBLIC_STATUS_API_TOKEN`: optionaler serverseitiger Token für spätere geschützte Status-API-Zugriffe
Die Olympus-Variablen sind nur für eine spätere serverseitige Integration vorbereitet. Sie werden nicht im Browser verwendet.
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.
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.
@ -125,8 +130,62 @@ Für lokale Entwicklung kann `AUTH_COOKIE_SECURE=false` bleiben. Produktiv muss
- `POST /api/admin/settings`
- `POST /api/admin/smtp`
- `GET/POST /api/admin/media`
- `GET/PUT /api/admin/content`
- `GET /api/content/settings`
- `GET /api/media/[filename]`
- `GET /api/health`
## Öffentlicher Reparaturstatus
Olympus CRM erzeugt sichere Statuslinks für Reparaturen. Die Website stellt dafür die öffentliche Route bereit:
```text
/status/<token>
```
Die Seite ruft Olympus ausschließlich serverseitig auf:
```text
${OLYMPUS_PUBLIC_STATUS_API_URL}/public/repairs/status/<token>
```
Der Browser sieht weder die Olympus-URL noch optionale Server-Tokens. Die öffentliche Statusseite zeigt nur:
- Reparaturnummer
- Gerät
- aktuellen Status
- letzte Aktualisierung
- kundenfreundliche Status-Timeline
- optionalen Kostenvoranschlag aus Olympus CRM v0.8.6
Wenn Olympus einen Kostenvoranschlag im Status `sent` liefert, zeigt die Website:
- Nummer, Titel, Nachricht an den Kunden und Gültigkeit
- Positionen mit Menge, Einheit und Betrag
- Zwischensumme, Steuer und Gesamtbetrag
- Aktionen zum Freigeben, Ablehnen oder für eine Rückfrage
Diese Aktionen laufen nicht direkt vom Browser zu Olympus. Der Browser ruft ausschließlich relative Website-Endpunkte auf:
```text
POST /api/status/<token>/estimate/approve
POST /api/status/<token>/estimate/decline
POST /api/status/<token>/estimate/question
```
Die Website leitet diese Anfragen serverseitig an Olympus weiter:
```text
${OLYMPUS_PUBLIC_STATUS_API_URL}/public/repairs/status/<token>/estimate/<action>
```
Nicht angezeigt werden Kundendaten, interne Notizen, Diagnosedetails oder technische Fehlermeldungen.
Fehlerverhalten:
- ungültiger oder abgelaufener Token: freundliche Meldung mit Links zu Reparatur und Kontakt
- Olympus nicht erreichbar: neutrale Meldung ohne technische Details
Aktuell validieren die Routen serverseitig, geben klare JSON-Antworten zurück und schreiben nur technische Metadaten in Server-Logs. Es werden keine Nachrichteninhalte oder Tokens geloggt. Kontakt- und Reparaturanfragen werden lokal unter `data/*.json` gespeichert und nicht versioniert.
## Admin-Bereich
@ -138,14 +197,14 @@ Funktionen:
- Dashboard mit Kennzahlen
- Kontaktanfragen verwalten
- Reparaturanfragen verwalten
- Website-Inhaltsverwaltung vorbereitet
- Website-Inhalte pflegen und sofort veröffentlichen
- Firmendaten pflegen
- SMTP-Konfiguration vorbereiten
- SMTP-Konfiguration speichern und Testmail senden
- Medien hochladen
- SEO-Übersicht
- Systemübersicht
SMTP-Versand, Publishing von Website-Inhalten und Olympus-Übernahme sind bewusst noch nicht aktiv gekoppelt.
Website-Inhalte werden unter `/admin/website` gepflegt und beim Speichern sofort auf der öffentlichen Website wirksam. Die Content-Architektur ist bewusst über einen zentralen `ContentService` gekapselt, damit später Olympus CMS oder PostgreSQL als Backend angebunden werden können. SMTP-Versand fuer Kontakt- und Reparaturanfragen ist serverseitig angebunden, sofern `/admin/smtp` vollstaendig konfiguriert ist.
## Runtime Data
@ -163,10 +222,51 @@ Echte Runtime-Dateien werden nicht committed:
data/contact-inquiries.json
data/repair-inquiries.json
data/site-settings.json
data/smtp-settings.json
```
Uploads unter `public/uploads/images/` werden ebenfalls nicht committed.
SMTP-Konfiguration wird unter `storage/config/smtp.json` gespeichert und nicht committed. Das SMTP-Passwort wird nicht im Admin-Formular ausgegeben.
Uploads liegen unter `storage/uploads/images/`, werden über `/api/media/[filename]` ausgeliefert und nicht committed.
Alte Uploads aus `public/uploads/images` werden beim Start einmalig nach `storage/uploads/images` migriert, falls sie dort noch nicht vorhanden sind.
Website-Inhalte liegen unter `storage/content/` und werden nicht committed:
```text
storage/content/home.json
storage/content/services.json
storage/content/radio-service.json
storage/content/repair.json
storage/content/about.json
storage/content/contact.json
storage/content/settings.json
storage/content/backups/
```
Jede Seite besitzt eine eigene JSON-Datei. React-Komponenten lesen diese Dateien nicht direkt, sondern ausschließlich über `lib/content/service.ts`. Vor jedem Speichern erzeugt der Service eine Backup-Datei unter `storage/content/backups/` und behält pro Dokument die letzten 20 Versionen. Änderungen benötigen keinen Neustart und keinen Docker-Build.
Bearbeitbar sind aktuell:
- Startseite mit Hero, Call-to-Actions, Featureboxen, Leistungsboxen und Kundenversprechen
- Leistungen mit beliebig vielen sortierbaren und ein-/ausblendbaren Einträgen
- Funkgeräte-Service mit Einleitung, Marken, Fehlerbildern, Ablauf, Messmöglichkeiten, Abgleich und Reparaturtexten
- Reparaturannahme mit SEO-Daten und Seitentexten
- Über Uns mit Firmenbeschreibung, Werkstattbeschreibung und Philosophie
- Kontakt mit Kontaktinformationen, Öffnungszeiten, Maps-Link und Seitentexten
- Footer, Logo, Copyright, Header-CTA und globale SEO-Einstellungen
- SEO pro Seite inklusive Meta Title, Meta Description, Keywords, OpenGraph und Social Image
Bilder für Logo und Social Image werden aus dem Medienbestand ausgewählt. Neue Uploads bleiben im privaten Storage und werden über `/api/media/[filename]` ausgeliefert.
## Roadmap Kundenportal
Ein vollständiges Kundenportal ist vorbereitet, aber noch nicht öffentlich implementiert. Die öffentliche Statuslink-Seite ist bereits vorhanden. Für spätere Ausbaustufen sind folgende Routen vorgesehen:
- `/status/[token]`: öffentliche Statuslink-Seite für Olympus
- `/reparatur/status`: Statusabfrage für Reparaturanfragen
- `/portal/login`: geschützter Kundenlogin
Diese Routen sind bewusst noch nicht angelegt. Die spätere Umsetzung soll serverseitig erfolgen, ohne Tokens im Browser-JavaScript zu speichern und ohne bestehende Admin- oder CMS-Funktionen zu umgehen.
## Healthcheck
@ -185,13 +285,50 @@ Antwort:
```json
{
"status": "ok",
"version": "0.2.2",
"version": "0.4.1",
"storage": "ok",
"admin": "configured",
"smtp": "configured",
"timestamp": "..."
}
```
## SMTP
SMTP wird im Adminbereich unter `/admin/smtp` konfiguriert. Die Konfiguration wird persistent unter `storage/config/smtp.json` gespeichert und liegt damit im Docker-Storage-Volume, nicht in `public/`.
Pflichtfelder:
- SMTP Host
- SMTP Port
- SMTP Benutzername
- SMTP Passwort
- Verschlüsselung
- Absenderadresse
- Empfängeradresse
Für Apple Mail/iCloud Mail:
```text
Host: smtp.mail.me.com
Port: 587
Verschlüsselung: STARTTLS
Benutzername: vollständige E-Mail-Adresse
Passwort: app-spezifisches Passwort
```
Nicht das normale Apple-ID-Passwort verwenden. In der Apple-ID-Verwaltung ein app-spezifisches Passwort erzeugen und dieses als SMTP-Passwort speichern.
Nach dem Speichern kann im Adminbereich eine Testmail gesendet werden. Kontakt- und Reparaturanfragen werden weiterhin lokal gespeichert. Wenn SMTP konfiguriert ist, wird zusaetzlich eine E-Mail an die konfigurierte Empfaengeradresse gesendet. Schlaegt der Mailversand fehl, bleibt die Anfrage gespeichert; der Besucher sieht keine technische Fehlermeldung.
Troubleshooting:
- Host, Port und Verschlüsselung prüfen
- bei Apple Mail STARTTLS und Port 587 verwenden
- vollständige E-Mail-Adresse als Benutzername verwenden
- app-spezifisches Passwort neu erzeugen
- letzte Testmail und letzte Fehlermeldung unter `/admin/smtp` prüfen
## Deployment
```bash
@ -223,7 +360,7 @@ scripts/healthcheck.sh
scripts/backup.sh
```
Das Backup enthält `data/` und `public/uploads/`. Die `.env` wird bewusst nicht automatisch gesichert. Sie muss separat sicher abgelegt werden.
Das Backup enthält `data/` und `storage/`. Die `.env` wird bewusst nicht automatisch gesichert. Sie muss separat sicher abgelegt werden.
## Restore
@ -257,6 +394,5 @@ certbot --nginx -d funktechnik-schubert.de -d www.funktechnik-schubert.de
## Offene manuelle Punkte
- TODO: Rechtliche Angaben ergänzen
- Produktives Kontakt-/Mail-System anbinden
- Spätere Olympus-Reparaturannahme serverseitig anbinden
- Finale Domain und HTTPS-Konfiguration setzen

View file

@ -1,26 +1,12 @@
import Image from "next/image";
import { redirect } from "next/navigation";
import AdminShell from "@/components/admin/AdminShell";
import MediaManager from "@/components/admin/MediaManager";
import { requireAdminSession } from "@/lib/admin/auth";
import { listMediaFiles } from "@/lib/admin/media";
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 }> }) {
export default async function AdminMediaPage() {
if (!await requireAdminSession()) redirect("/admin/login");
const [files, params] = await Promise.all([listMediaFiles(), searchParams]);
const error = uploadErrorMessage(params.error);
const files = await listMediaFiles();
return (
<AdminShell>
@ -28,41 +14,7 @@ export default async function AdminMediaPage({ searchParams }: { searchParams: P
<p className="eyebrow">Assets</p>
<h1>Medienverwaltung</h1>
</div>
{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>
<MediaManager initialFiles={files} />
</AdminShell>
);
}

View file

@ -1,7 +1,11 @@
import { redirect } from "next/navigation";
import AdminShell from "@/components/admin/AdminShell";
import { requireAdminSession } from "@/lib/admin/auth";
import { listMediaFiles } from "@/lib/admin/media";
import { getContactInquiries, getRepairInquiries } from "@/lib/admin/store";
import { getContentSummary } from "@/lib/content/service";
import { getSmtpSettings, isSmtpConfigured } from "@/lib/mail/config";
import { appVersion, checkStorage } from "@/lib/runtime/config";
function countNew<T extends { status: string }>(items: T[]) {
return items.filter((item) => item.status === "new").length;
@ -9,8 +13,23 @@ function countNew<T extends { status: string }>(items: T[]) {
export default async function AdminDashboardPage() {
if (!await requireAdminSession()) redirect("/admin/login");
const [contacts, repairs] = await Promise.all([getContactInquiries(), getRepairInquiries()]);
let healthStatus = "OK";
try {
await checkStorage();
} catch {
healthStatus = "Fehler";
}
const [contacts, repairs, smtpSettings, mediaFiles, contentSummary] = await Promise.all([
getContactInquiries(),
getRepairInquiries(),
getSmtpSettings(),
listMediaFiles(),
getContentSummary(),
]);
const latest = [...contacts, ...repairs].sort((a, b) => b.createdAt.localeCompare(a.createdAt)).slice(0, 6);
const contentModified = contentSummary.lastModified ? new Date(contentSummary.lastModified).toLocaleString("de-DE") : "keine Änderung";
return (
<AdminShell>
@ -19,10 +38,13 @@ export default async function AdminDashboardPage() {
<h1>Dashboard</h1>
</div>
<div className="admin-kpis">
<div className="admin-kpi"><span>Kontaktanfragen</span><strong>{countNew(contacts)}</strong><small>neu</small></div>
<div className="admin-kpi"><span>Reparaturanfragen</span><strong>{countNew(repairs)}</strong><small>neu</small></div>
<div className="admin-kpi"><span>Website</span><strong>Online</strong><small>OK</small></div>
<div className="admin-kpi"><span>SMTP</span><strong>Vorbereitet</strong><small>nicht aktiv</small></div>
<div className="admin-kpi"><span>Kontaktanfragen</span><strong>{contacts.length}</strong><small>{countNew(contacts)} neu</small></div>
<div className="admin-kpi"><span>Reparaturanfragen</span><strong>{repairs.length}</strong><small>{countNew(repairs)} neu</small></div>
<div className="admin-kpi"><span>Medien</span><strong>{mediaFiles.length}</strong><small>im Storage</small></div>
<div className="admin-kpi"><span>Content</span><strong>{contentSummary.pageCount}</strong><small>{contentModified}</small></div>
<div className="admin-kpi"><span>SMTP</span><strong>{isSmtpConfigured(smtpSettings) ? "OK" : "Fehlt"}</strong><small>{smtpSettings.lastTestStatus ?? "kein Test"}</small></div>
<div className="admin-kpi"><span>Version</span><strong>v{appVersion}</strong><small>Website</small></div>
<div className="admin-kpi"><span>Health</span><strong>{healthStatus}</strong><small>Storage und Runtime</small></div>
</div>
<section className="admin-card">
<h2>Letzte Anfragen</h2>

View file

@ -1,7 +1,8 @@
import { redirect } from "next/navigation";
import AdminShell from "@/components/admin/AdminShell";
import SmtpSettingsForm from "@/components/admin/SmtpSettingsForm";
import { requireAdminSession } from "@/lib/admin/auth";
import { getSmtpSettings } from "@/lib/admin/store";
import { getSmtpSettings, isSmtpConfigured, toPublicSmtpSettings } from "@/lib/mail/config";
export default async function AdminSmtpPage() {
if (!await requireAdminSession()) redirect("/admin/login");
@ -13,16 +14,7 @@ export default async function AdminSmtpPage() {
<p className="eyebrow">E-Mail</p>
<h1>SMTP Einstellungen</h1>
</div>
<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>
<SmtpSettingsForm initialSettings={toPublicSmtpSettings(settings)} configured={isSmtpConfigured(settings)} />
</AdminShell>
);
}

View file

@ -1,7 +1,9 @@
import { redirect } from "next/navigation";
import AdminShell from "@/components/admin/AdminShell";
import { requireAdminSession } from "@/lib/admin/auth";
import { adminConfigurationStatus, appVersion, checkStorage, dataDirectory, isDockerEnvironment, olympusStatus, smtpStatus, uploadDirectory } from "@/lib/runtime/config";
import { adminConfigurationStatus, appVersion, checkStorage, configDirectory, dataDirectory, isDockerEnvironment, olympusStatus, uploadDirectory } from "@/lib/runtime/config";
import { getSmtpSettings, isSmtpConfigured } from "@/lib/mail/config";
import { getContentSystemStatus } from "@/lib/content/service";
export default async function AdminSystemPage() {
if (!await requireAdminSession()) redirect("/admin/login");
@ -12,6 +14,8 @@ export default async function AdminSystemPage() {
} catch {
storage = "error";
}
const [smtpSettings, contentStatus] = await Promise.all([getSmtpSettings(), getContentSystemStatus()]);
const smtpConfigured = isSmtpConfigured(smtpSettings);
return (
<AdminShell>
@ -27,20 +31,43 @@ export default async function AdminSystemPage() {
<div><dt>Docker Environment</dt><dd>{isDockerEnvironment() ? "ja" : "nein"}</dd></div>
<div><dt>Storage Status</dt><dd>{storage}</dd></div>
<div><dt>Data Directory</dt><dd>{dataDirectory}</dd></div>
<div><dt>Config Directory</dt><dd>{configDirectory}</dd></div>
<div><dt>Upload Directory</dt><dd>{uploadDirectory}</dd></div>
<div><dt>Admin Konfiguration</dt><dd>{adminConfigurationStatus()}</dd></div>
<div><dt>Olympus Verbindung</dt><dd>{olympusStatus()}</dd></div>
<div><dt>SMTP</dt><dd>{smtpStatus()}</dd></div>
<div><dt>SMTP</dt><dd>{smtpConfigured ? "configured" : "missing"}</dd></div>
<div><dt>Letzte SMTP-Testmail</dt><dd>{smtpSettings.lastTestAt ? `${new Date(smtpSettings.lastTestAt).toLocaleString("de-DE")} (${smtpSettings.lastTestStatus})` : "keine"}</dd></div>
<div><dt>Letzter SMTP-Formularversand</dt><dd>{smtpSettings.lastDeliveryAt ? `${new Date(smtpSettings.lastDeliveryAt).toLocaleString("de-DE")} (${smtpSettings.lastDeliveryStatus})` : "keiner"}</dd></div>
<div><dt>Content Version</dt><dd>{contentStatus.version}</dd></div>
<div><dt>Letzte Content-Änderung</dt><dd>{contentStatus.lastModified ? new Date(contentStatus.lastModified).toLocaleString("de-DE") : "keine"}</dd></div>
<div><dt>Anzahl Seiten</dt><dd>{contentStatus.pageCount}</dd></div>
<div><dt>Anzahl Medien</dt><dd>{contentStatus.mediaCount}</dd></div>
</dl>
</section>
<section className="admin-card">
<h2>Betriebsregeln</h2>
<ul className="list">
<li>Runtime-Daten liegen unter <code>data/</code> und werden nicht versioniert.</li>
<li>Uploads liegen unter <code>public/uploads/images/</code> und werden nicht versioniert.</li>
<li>Olympus- und SMTP-Integration sind vorbereitet, aber nicht aktiv implementiert.</li>
<li>Uploads liegen unter <code>storage/uploads/images/</code> und werden nicht versioniert.</li>
<li>SMTP-Versand ist serverseitig aktiv, sofern eine vollständige Konfiguration gespeichert ist.</li>
</ul>
</section>
<section className="admin-card">
<h2>Letzte Content-Backups</h2>
<table className="admin-table">
<thead><tr><th>Seite</th><th>Datum</th><th>Dateiname</th></tr></thead>
<tbody>
{contentStatus.backups.map((backup) => (
<tr key={backup.fileName}>
<td>{backup.page}</td>
<td>{new Date(backup.createdAt).toLocaleString("de-DE")}</td>
<td>{backup.fileName}</td>
</tr>
))}
{contentStatus.backups.length === 0 && <tr><td colSpan={3}>Noch keine Content-Backups vorhanden.</td></tr>}
</tbody>
</table>
</section>
</AdminShell>
);
}

View file

@ -1,18 +1,13 @@
import { redirect } from "next/navigation";
import AdminShell from "@/components/admin/AdminShell";
import ContentManager from "@/components/admin/ContentManager";
import { requireAdminSession } from "@/lib/admin/auth";
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;
import { listMediaFiles } from "@/lib/admin/media";
import { getAllContent } from "@/lib/content/service";
export default async function AdminWebsitePage() {
if (!await requireAdminSession()) redirect("/admin/login");
const [content, mediaFiles] = await Promise.all([getAllContent(), listMediaFiles()]);
return (
<AdminShell>
@ -20,19 +15,7 @@ export default async function AdminWebsitePage() {
<p className="eyebrow">Website</p>
<h1>Website-Inhalte</h1>
</div>
<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>
<ContentManager initialContent={content} mediaFiles={mediaFiles} />
</AdminShell>
);
}

View file

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

View file

@ -1,9 +1,26 @@
import { NextResponse } from "next/server";
import { requireAdminSession } from "@/lib/admin/auth";
import { saveSmtpSettings } from "@/lib/admin/store";
import { saveSmtpSettings, toPublicSmtpSettings } from "@/lib/mail/config";
import { sendTestMail } from "@/lib/mail/smtp";
export async function POST(request: Request) {
if (!await requireAdminSession()) return NextResponse.json({ message: "Nicht autorisiert." }, { status: 401 });
await saveSmtpSettings(await request.formData());
return new NextResponse(null, { status: 303, headers: { Location: "/admin/smtp?saved=1" } });
const form = await request.formData();
const action = String(form.get("action") ?? "save");
if (action === "test") {
const settings = await saveSmtpSettings(form);
const result = await sendTestMail();
return NextResponse.json({
message: result.ok ? "Test-E-Mail wurde gesendet." : result.error ?? "Test-E-Mail fehlgeschlagen.",
ok: result.ok,
settings: toPublicSmtpSettings(settings),
}, { status: result.ok ? 200 : 400 });
}
const settings = await saveSmtpSettings(form);
return NextResponse.json({
message: "SMTP-Konfiguration gespeichert.",
settings: toPublicSmtpSettings(settings),
});
}

View file

@ -1,5 +1,6 @@
import { NextResponse } from "next/server";
import { addContactInquiry } from "@/lib/admin/store";
import { sendContactInquiryMail } from "@/lib/mail/smtp";
function text(value: FormDataEntryValue | null) {
return typeof value === "string" ? value.trim() : "";
@ -12,11 +13,12 @@ export async function POST(request: Request) {
const subject = text(form.get("subject"));
const message = text(form.get("message"));
if (!name || !email.includes("@") || !subject || message.length < 10 || form.get("privacy") !== "on") {
if (!name || !email.includes("@") || !subject || message.length < 10 || form.get("privacyAccepted") !== "on") {
return NextResponse.json({ message: "Bitte füllen Sie alle Pflichtfelder aus." }, { status: 400 });
}
await addContactInquiry(form);
const inquiry = await addContactInquiry(form);
await sendContactInquiryMail(inquiry);
return NextResponse.json({
message: "Ihre Nachricht wurde erfasst. Sie erhalten eine Rückmeldung.",

View file

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

View file

@ -0,0 +1,22 @@
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,5 +1,6 @@
import { NextResponse } from "next/server";
import { addRepairInquiry } from "@/lib/admin/store";
import { sendRepairInquiryMail } from "@/lib/mail/smtp";
function required(value: FormDataEntryValue | null) {
return typeof value === "string" && value.trim().length > 0;
@ -13,13 +14,14 @@ export async function POST(request: Request) {
const model = form.get("model");
const deviceType = form.get("deviceType");
const description = form.get("description");
const privacy = form.get("privacy");
const privacyAccepted = form.get("privacyAccepted");
if (!required(name) || !required(email) || !required(manufacturer) || !required(model) || !required(deviceType) || !required(description) || privacy !== "on") {
if (!required(name) || !required(email) || !required(manufacturer) || !required(model) || !required(deviceType) || !required(description) || privacyAccepted !== "on") {
return NextResponse.json({ message: "Bitte füllen Sie alle Pflichtfelder aus." }, { status: 400 });
}
await addRepairInquiry(form);
const inquiry = await addRepairInquiry(form);
await sendRepairInquiryMail(inquiry);
return NextResponse.json({
message: "Ihre Reparaturanfrage wurde erfasst. Sie erhalten nach Prüfung eine Rückmeldung.",

View file

@ -0,0 +1,36 @@
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

@ -0,0 +1,15 @@
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

@ -0,0 +1,16 @@
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

@ -0,0 +1,16 @@
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>
<section className="section">
<div className="container legal">
<p className="notice">TODO: Rechtliche Angaben ergänzen</p>
<p className="notice">Datenschutzhinweise müssen vor produktiver Veröffentlichung durch den Betreiber ergänzt und rechtlich geprüft werden.</p>
<h2>Verantwortlicher</h2>
<p>TODO: Rechtliche Angaben ergänzen</p>
<p>Die Angaben zum Verantwortlichen werden durch den Betreiber gepflegt.</p>
<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>
<h2>Hosting und Server-Logs</h2>
<p>TODO: Rechtliche Angaben ergänzen</p>
<p>Informationen zum Hosting und zur Verarbeitung technischer Serverdaten werden durch den Betreiber gepflegt.</p>
<h2>Betroffenenrechte</h2>
<p>TODO: Rechtliche Angaben ergänzen</p>
<p>Hinweise zu Betroffenenrechten werden durch den Betreiber rechtlich geprüft und gepflegt.</p>
</div>
</section>
</>

View file

@ -1,33 +1,41 @@
import type { Metadata } from "next";
import Link from "next/link";
import PageHero from "@/components/PageHero";
import { createMetadata } from "../seo";
import { defaultContent } from "@/lib/content/defaults";
import { getContent } from "@/lib/content/service";
import { createContentMetadata } from "../seo";
export const metadata: Metadata = createMetadata("Funkgeräte-Service", "Werkstattservice für CB-Funk, Amateurfunk und Funkgeräte-Abgleich.", "/funkgeraete-service");
export const dynamic = "force-dynamic";
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 (
<>
<PageHero eyebrow="Funkgeräte-Service" title="Prüfung, Diagnose und Abgleich">
Funkgeräte zeigen Fehler oft erst im Zusammenspiel von Empfang, Sendeteil, Versorgung, Antennenanpassung, Bedienung und Abgleich. Der Service betrachtet diese Zusammenhänge strukturiert.
<PageHero eyebrow={content.eyebrow} title={content.title}>
{content.subtitle}
</PageHero>
<section className="section">
<div className="container split">
<div>
<h2>Typische Fehlerbilder</h2>
<h2>{content.listTitle}</h2>
<ul className="list">
<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>
{content.symptoms.map((symptom) => <li key={symptom}>{symptom}</li>)}
</ul>
</div>
<div className="panel">
<h2>Messung und Einordnung</h2>
<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>
<Link className="button" href="/reparatur">Reparatur anfragen</Link>
<h2>{content.cta.text}</h2>
<p className="lead">{content.intro}</p>
<ul className="list">
{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>
</section>

View file

@ -488,6 +488,372 @@ h3 {
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 {
min-height: 100vh;
display: grid;
@ -630,7 +996,7 @@ h3 {
.admin-kpis {
display: grid;
grid-template-columns: repeat(4, minmax(0, 1fr));
grid-template-columns: repeat(auto-fit, minmax(180px, 1fr));
gap: 14px;
margin-bottom: 18px;
}
@ -754,26 +1120,29 @@ h3 {
margin-bottom: 18px;
}
.media-grid {
.media-toolbar {
display: grid;
grid-template-columns: repeat(4, minmax(0, 1fr));
gap: 12px;
grid-template-columns: minmax(220px, 1.2fr) minmax(180px, 1fr) 150px 150px auto;
gap: 14px;
align-items: end;
}
.media-item {
.media-toolbar label {
display: grid;
min-height: 130px;
place-items: center;
border: 1px solid var(--line);
border-radius: 8px;
background: #f8fafc;
overflow: hidden;
gap: 7px;
color: var(--ink);
font-size: 14px;
font-weight: 800;
}
.media-item img {
.media-toolbar input,
.media-toolbar select {
width: 100%;
height: 150px;
object-fit: cover;
border: 1px solid #cfd8e5;
border-radius: 8px;
background: #fff;
padding: 11px 12px;
color: var(--ink);
}
.media-empty {
@ -787,47 +1156,81 @@ h3 {
color: var(--muted);
}
.media-list {
.media-card-grid {
display: grid;
gap: 14px;
grid-template-columns: repeat(3, minmax(0, 1fr));
gap: 16px;
}
.media-row {
.media-card {
display: grid;
grid-template-columns: 180px minmax(0, 1fr);
gap: 16px;
align-items: center;
border: 1px solid var(--line);
border-radius: 8px;
background: #fff;
padding: 12px;
overflow: hidden;
box-shadow: 0 10px 26px rgba(8, 42, 96, 0.06);
}
.media-preview {
.media-card-preview {
display: grid;
min-height: 110px;
width: 100%;
aspect-ratio: 16 / 10;
place-items: center;
border: 1px solid var(--line);
border-radius: 8px;
border: 0;
border-bottom: 1px solid var(--line);
background: #f8fafc;
color: var(--navy);
font-weight: 800;
overflow: hidden;
cursor: pointer;
padding: 0;
}
.media-preview img {
.media-card-preview img {
width: 100%;
height: 110px;
height: 100%;
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 {
display: grid;
grid-template-columns: repeat(3, minmax(0, 1fr));
grid-template-columns: 1fr;
gap: 10px;
margin: 0;
}
.media-meta div {
display: grid;
grid-template-columns: 72px minmax(0, 1fr);
gap: 8px;
}
.media-meta dt {
color: var(--muted);
font-size: 13px;
@ -839,6 +1242,91 @@ h3 {
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 {
display: grid;
gap: 0;
@ -863,6 +1351,201 @@ h3 {
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) {
.brand-logo {
width: 236px;
@ -946,10 +1629,44 @@ h3 {
.admin-shell,
.admin-kpis,
.admin-detail-grid,
.media-grid {
.media-grid,
.status-summary {
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 {
position: static;
height: auto;
@ -963,7 +1680,8 @@ h3 {
display: grid;
}
.media-row,
.media-toolbar,
.media-card-grid,
.media-meta {
grid-template-columns: 1fr;
}
@ -972,4 +1690,29 @@ h3 {
grid-template-columns: 1fr;
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>
<section className="section">
<div className="container legal">
<p className="notice">TODO: Rechtliche Angaben ergänzen</p>
<p className="notice">Rechtliche Angaben müssen vor produktiver Veröffentlichung durch den Betreiber ergänzt und geprüft werden.</p>
<h2>Anbieterkennzeichnung</h2>
<p>TODO: Rechtliche Angaben ergänzen</p>
<p>Die vollständige Anbieterkennzeichnung wird durch den Betreiber gepflegt.</p>
<h2>Kontakt</h2>
<p>TODO: Rechtliche Angaben ergänzen</p>
<p>Die rechtlich relevanten Kontaktdaten werden durch den Betreiber gepflegt.</p>
<h2>Umsatzsteuer / Registerangaben</h2>
<p>TODO: Rechtliche Angaben ergänzen</p>
<p>Umsatzsteuer- und Registerangaben werden durch den Betreiber gepflegt, sofern sie erforderlich sind.</p>
</div>
</section>
</>

View file

@ -1,21 +1,41 @@
import type { Metadata } from "next";
import PageHero from "@/components/PageHero";
import ContactForm from "@/components/ContactForm";
import { createMetadata } from "../seo";
import { defaultContent } from "@/lib/content/defaults";
import { getContent } from "@/lib/content/service";
import { createContentMetadata } from "../seo";
export const metadata: Metadata = createMetadata("Kontakt", "Kontakt zu Funktechnik Schubert aufnehmen.", "/kontakt");
export const dynamic = "force-dynamic";
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 (
<>
<PageHero eyebrow="Kontakt" title="Kontakt aufnehmen">
Beschreiben Sie kurz Ihr Anliegen. Für Reparaturen nutzen Sie idealerweise die strukturierte Reparaturannahme.
<PageHero eyebrow={content.eyebrow} title={content.title}>
{content.subtitle}
</PageHero>
<section className="section">
<div className="container split">
<div>
<h2>Direkt und technisch</h2>
<p className="lead">Je genauer Gerät, Anliegen und gewünschte Unterstützung beschrieben werden, desto gezielter kann die Rückmeldung erfolgen.</p>
<h2>{content.cta.text}</h2>
<p className="lead">{content.heroText}</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 className="panel">
<ContactForm />

View file

@ -1,12 +1,14 @@
import type { Metadata } from "next";
import SiteFrame from "@/components/SiteFrame";
import "./globals.css";
import { createMetadata } from "./seo";
import { defaultContent } from "@/lib/content/defaults";
import { getContent } from "@/lib/content/service";
import { createContentMetadata } from "./seo";
export const metadata: Metadata = createMetadata(
"Funktechnik Schubert",
"Service, Reparatur, Diagnose und Abgleich von Funktechnik, CB-Funk, Amateurfunk und Kommunikationstechnik.",
);
export async function generateMetadata(): Promise<Metadata> {
const settings = await getContent("settings");
return createContentMetadata(settings.seo, defaultContent.settings.seo);
}
export default function RootLayout({ children }: Readonly<{ children: React.ReactNode }>) {
return (

View file

@ -1,27 +1,30 @@
import type { Metadata } from "next";
import PageHero from "@/components/PageHero";
import ServiceCard from "@/components/ServiceCard";
import { createMetadata } from "../seo";
import { defaultContent } from "@/lib/content/defaults";
import { getContent } from "@/lib/content/service";
import { createContentMetadata } from "../seo";
export const metadata: Metadata = createMetadata("Leistungen", "Funkgeräte-Service, Diagnose, Abgleich und Dokumentation für Kommunikationstechnik.", "/leistungen");
export const dynamic = "force-dynamic";
const 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."],
["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."],
["Serviceunterlagen", "Sorgfältige Einordnung von Schaltplänen, Service Manuals, Abgleichanleitungen, Dokumentation und Reparaturhinweisen für nachvollziehbare Arbeitsschritte."],
] as const;
export async function generateMetadata() {
const content = await getContent("services");
return createContentMetadata(content.seo, defaultContent.services.seo);
}
export default async function ServicesPage() {
const content = await getContent("services");
const services = content.services
.filter((service) => service.visible)
.sort((left, right) => left.sortOrder - right.sortOrder);
export default function ServicesPage() {
return (
<>
<PageHero eyebrow="Leistungen" title="Service für Funkgeräte und Kommunikationstechnik">
Technische Unterstützung für Geräte, bei denen Diagnose, Messtechnik, Erfahrung und saubere Dokumentation zählen.
<PageHero eyebrow={content.eyebrow} title={content.title}>
{content.subtitle}
</PageHero>
<section className="section">
<div className="container grid service-grid">
{services.map(([title, text]) => <ServiceCard key={title} title={title}>{text}</ServiceCard>)}
{services.map((service) => <ServiceCard key={service.title} title={service.title}>{service.description}</ServiceCard>)}
</div>
</section>
</>

View file

@ -1,26 +1,36 @@
import Link from "next/link";
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 (
<>
<section className="hero">
<section
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">
<p className="eyebrow">Funktechnik, Messtechnik, Werkstattservice</p>
<h1>Funktechnik Schubert</h1>
<p className="lead">Service, Reparatur und Diagnose für Funkgeräte, Messtechnik und Kommunikationstechnik.</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>
<p className="eyebrow">{content.eyebrow}</p>
<h1>{content.title}</h1>
<p className="lead">{content.subtitle}</p>
<p className="hero-copy">{content.heroText}</p>
<div className="hero-badges" aria-label="Technische Schwerpunkte">
<span>CB-Funk</span>
<span>Amateurfunk</span>
<span>Messtechnik</span>
<span>Abgleich</span>
<span>Diagnose</span>
<span>Service Manuals</span>
{content.badges.map((badge) => <span key={badge}>{badge}</span>)}
</div>
<div className="hero-actions">
<Link className="button" href="/reparatur">Reparatur anfragen</Link>
<Link className="button secondary" href="/leistungen">Leistungen ansehen</Link>
<Link className="button" href={content.cta.primary.href}>{content.cta.primary.label}</Link>
<Link className="button secondary" href={content.cta.secondary.href}>{content.cta.secondary.label}</Link>
</div>
</div>
</section>
@ -29,13 +39,11 @@ export default function HomePage() {
<div className="container">
<div className="section-head">
<p className="eyebrow">Leistungsbereiche</p>
<h2>Werkstattservice für Funktechnik und Messtechnik</h2>
<p className="lead">Von der ersten Fehlerbeschreibung bis zur dokumentierten Prüfung: Funktechnik Schubert verbindet technische Erfahrung, geeignete Messmittel und strukturierte Kommunikation.</p>
<h2>{content.cta.text}</h2>
<p className="lead">{content.intro}</p>
</div>
<div className="grid three">
<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>
{content.features.map((feature) => <ServiceCard key={feature.title} title={feature.title}>{feature.text}</ServiceCard>)}
</div>
</div>
</section>
@ -43,20 +51,17 @@ export default function HomePage() {
<section className="section dark">
<div className="container split">
<div>
<p className="eyebrow">Persönlicher Support</p>
<h2>Direkte technische Einschätzung statt anonymer Abwicklung</h2>
<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>
<p className="eyebrow">{content.promise.eyebrow}</p>
<h2>{content.promise.title}</h2>
<p className="lead">{content.promise.text}</p>
<div className="actions">
<Link className="button" href="/reparatur">Reparaturformular öffnen</Link>
<Link className="button" href={content.promise.button.href}>{content.promise.button.label}</Link>
</div>
</div>
<div className="card">
<h3>Warum Funktechnik Schubert?</h3>
<h3>{content.promise.title}</h3>
<ul className="list">
<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>
{content.promise.bullets.map((item) => <li key={item}>{item}</li>)}
</ul>
</div>
</div>

View file

@ -1,27 +1,32 @@
import type { Metadata } from "next";
import PageHero from "@/components/PageHero";
import RepairForm from "@/components/RepairForm";
import { createMetadata } from "../seo";
import { defaultContent } from "@/lib/content/defaults";
import { getContent } from "@/lib/content/service";
import { createContentMetadata } from "../seo";
export const metadata: Metadata = createMetadata("Reparaturannahme", "Reparaturanfrage für Funkgeräte strukturiert vorbereiten.", "/reparatur");
export const dynamic = "force-dynamic";
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 (
<>
<PageHero eyebrow="Reparaturannahme" title="Reparatur anfragen">
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 eyebrow={content.eyebrow} title={content.title}>
{content.subtitle}
</PageHero>
<section className="section">
<div className="container split">
<div>
<h2>Vor der Einsendung</h2>
<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>
<h2>{paragraph?.title || content.cta.text}</h2>
<p className="lead">{paragraph?.text || content.intro}</p>
<ul className="list">
<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>
{content.list.map((item) => <li key={item}>{item}</li>)}
</ul>
</div>
<div className="panel">

View file

@ -1,4 +1,5 @@
import type { Metadata } from "next";
import type { SeoContent } from "@/lib/content/types";
const siteName = "Funktechnik Schubert";
const defaultTitle = "Funktechnik Schubert | Funkgeräte-Service & Messtechnik";
@ -59,4 +60,36 @@ 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 };

268
app/status/[token]/page.tsx Normal file
View file

@ -0,0 +1,268 @@
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,25 +1,35 @@
import type { Metadata } from "next";
import PageHero from "@/components/PageHero";
import { createMetadata } from "../seo";
import { defaultContent } from "@/lib/content/defaults";
import { getContent } from "@/lib/content/service";
import { createContentMetadata } from "../seo";
export const metadata: Metadata = createMetadata("Über uns", "Funktechnik Schubert steht für technischen Service und klare Kommunikation.", "/ueber-uns");
export const dynamic = "force-dynamic";
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 (
<>
<PageHero eyebrow="Über uns" title="Technik verstehen, Fehler nachvollziehbar lösen">
Funktechnik Schubert richtet sich an Kunden, die bei Funkgeräten und Kommunikationstechnik eine persönliche, technische Einschätzung suchen.
<PageHero eyebrow={content.eyebrow} title={content.title}>
{content.subtitle}
</PageHero>
<section className="section">
<div className="container grid two">
<article className="card">
<h2>Arbeitsweise</h2>
<p>Im Mittelpunkt stehen saubere Diagnose, transparente Kommunikation und der respektvolle Umgang mit bestehenden Geräten und Serviceunterlagen.</p>
</article>
<article className="card">
<h2>Fokus</h2>
<p>Der Schwerpunkt liegt auf Funktechnik, Elektronikservice, Abgleichfragen und technischer Unterstützung rund um Kommunikationstechnik.</p>
{blocks.map((block) => (
<article key={block.title} className="card">
<h2>{block.title}</h2>
<p>{block.text}</p>
</article>
))}
</div>
</section>
</>

View file

@ -1,8 +1,9 @@
"use client";
import { useState, type FormEvent } from "react";
import { useRef, useState, type FormEvent } from "react";
export default function ContactForm() {
const formRef = useRef<HTMLFormElement>(null);
const [message, setMessage] = useState("");
const [error, setError] = useState("");
const [pending, setPending] = useState(false);
@ -16,8 +17,9 @@ export default function ContactForm() {
const email = String(form.get("email") ?? "").trim();
const subject = String(form.get("subject") ?? "").trim();
const text = String(form.get("message") ?? "").trim();
const privacyAccepted = form.get("privacyAccepted") === "on";
if (!name || !email.includes("@") || !subject || text.length < 10 || form.get("privacy") !== "on") {
if (!name || !email.includes("@") || !subject || text.length < 10 || !privacyAccepted) {
setError("Bitte füllen Sie alle Pflichtfelder aus und bestätigen Sie die Datenschutz-Hinweise.");
return;
}
@ -27,17 +29,17 @@ export default function ContactForm() {
const response = await fetch("/api/contact", { method: "POST", body: form });
const result = await response.json() as { message?: string };
if (!response.ok) throw new Error(result.message ?? "Nachricht konnte nicht gesendet werden.");
event.currentTarget.reset();
formRef.current?.reset();
setMessage(result.message ?? "Ihre Nachricht wurde vorbereitet.");
} catch (err) {
setError(err instanceof Error ? err.message : "Nachricht konnte nicht gesendet werden.");
setError(err instanceof Error ? err.message : "Nachricht konnte nicht gesendet werden. Bitte versuchen Sie es später erneut.");
} finally {
setPending(false);
}
}
return (
<form className="form" onSubmit={submit} noValidate>
<form ref={formRef} className="form" onSubmit={submit} noValidate>
<div className="field">
<label htmlFor="name">Name</label>
<input id="name" name="name" />
@ -59,7 +61,7 @@ export default function ContactForm() {
<textarea id="message" name="message" />
</div>
<label className="checkbox">
<input type="checkbox" name="privacy" />
<input type="checkbox" name="privacyAccepted" />
<span>Ich stimme zu, dass meine Angaben zur Bearbeitung der Kontaktanfrage verarbeitet werden.</span>
</label>
{error && <p className="error">{error}</p>}

View file

@ -1,33 +1,82 @@
"use client";
import Image from "next/image";
import Link from "next/link";
import { useEffect, useState } from "react";
import { appVersion } from "@/lib/runtime/version";
import type { SettingsContent } from "@/lib/content/types";
const fallbackSettings: SettingsContent = {
siteName: "Funktechnik Schubert",
logoUrl: "/funktechnik_schubert_logo.jpg",
footerShortText: "Service und technische Unterstützung für Funkgeräte, Messtechnik und Kommunikationstechnik.",
copyright: "© Funktechnik Schubert",
footerLinks: [
{ label: "Leistungen", href: "/leistungen" },
{ label: "Funkgeräte-Service", href: "/funkgeraete-service" },
{ label: "Reparatur", href: "/reparatur" },
{ label: "Kontakt", href: "/kontakt" },
],
legalLinks: [
{ label: "Impressum", href: "/impressum" },
{ label: "Datenschutz", href: "/datenschutz" },
],
headerCta: { label: "Reparatur anfragen", href: "/reparatur" },
seo: {
metaTitle: "Funktechnik Schubert",
metaDescription: "",
keywords: "",
openGraphTitle: "",
openGraphDescription: "",
socialImage: "/funktechnik_schubert_logo.jpg",
canonicalUrl: "/",
},
updatedAt: "",
};
export default function Footer() {
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 (
<footer className="footer">
<div className="container footer-grid">
<div>
<Image
src="/funktechnik_schubert_logo.jpg"
alt="Funktechnik Schubert"
src={settings.logoUrl || fallbackSettings.logoUrl}
alt={settings.siteName}
width={1672}
height={941}
className="footer-logo"
/>
<p>Funktechnik Schubert Service und technische Unterstützung für Funkgeräte, Messtechnik und Kommunikationstechnik.</p>
<p className="copyright">© Funktechnik Schubert · v{appVersion}</p>
<p>{settings.footerShortText}</p>
<p className="copyright">{settings.copyright} · v{appVersion}</p>
</div>
<div>
<h3>Website</h3>
<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>
{settings.footerLinks.map((link) => <p key={`${link.href}-${link.label}`}><Link href={link.href}>{link.label}</Link></p>)}
</div>
<div>
<h3>Rechtliches</h3>
<p><Link href="/impressum">Impressum</Link></p>
<p><Link href="/datenschutz">Datenschutz</Link></p>
{settings.legalLinks.map((link) => <p key={`${link.href}-${link.label}`}><Link href={link.href}>{link.label}</Link></p>)}
</div>
</div>
</footer>

View file

@ -3,7 +3,8 @@
import Image from "next/image";
import Link from "next/link";
import { usePathname } from "next/navigation";
import { useState } from "react";
import { useEffect, useState } from "react";
import type { SettingsContent } from "@/lib/content/types";
const navigation = [
["Start", "/"],
@ -14,17 +15,49 @@ const navigation = [
["Kontakt", "/kontakt"],
] as const;
const fallbackSettings = {
siteName: "Funktechnik Schubert",
logoUrl: "/funktechnik_schubert_logo.jpg",
headerCta: { label: "Reparatur anfragen", href: "/reparatur" },
};
export default function Header() {
const pathname = usePathname();
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 (
<header className="site-header">
<div className="container header-inner">
<Link href="/" className="brand" aria-label="Funktechnik Schubert Startseite" onClick={() => setMenuOpen(false)}>
<Link href="/" className="brand" aria-label={`${settings.siteName} Startseite`} onClick={() => setMenuOpen(false)}>
<Image
src="/funktechnik_schubert_logo.jpg"
alt="Funktechnik Schubert"
src={settings.logoUrl}
alt={settings.siteName}
width={1672}
height={941}
priority
@ -47,7 +80,7 @@ export default function Header() {
</Link>
))}
</nav>
<Link className="header-cta" href="/reparatur">Reparatur anfragen</Link>
<Link className="header-cta" href={settings.headerCta.href}>{settings.headerCta.label}</Link>
</div>
</header>
);

View file

@ -1,18 +1,21 @@
"use client";
import { useState, type FormEvent } from "react";
import { useRef, useState, type FormEvent } from "react";
type ErrorField = "name" | "email" | "manufacturer" | "model" | "deviceType" | "description" | "privacy";
type ErrorField = "name" | "email" | "manufacturer" | "model" | "deviceType" | "description" | "privacyAccepted";
type Errors = Partial<Record<ErrorField, string>>;
export default function RepairForm() {
const formRef = useRef<HTMLFormElement>(null);
const [errors, setErrors] = useState<Errors>({});
const [status, setStatus] = useState("");
const [submitError, setSubmitError] = useState("");
const [pending, setPending] = useState(false);
async function submit(event: FormEvent<HTMLFormElement>) {
event.preventDefault();
setStatus("");
setSubmitError("");
const form = new FormData(event.currentTarget);
const nextErrors: Errors = {};
@ -22,7 +25,7 @@ export default function RepairForm() {
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("description") ?? "").trim().length < 20) nextErrors.description = "Bitte beschreiben Sie den Fehler mit mindestens 20 Zeichen.";
if (form.get("privacy") !== "on") nextErrors.privacy = "Bitte stimmen Sie der Verarbeitung Ihrer Angaben zu.";
if (form.get("privacyAccepted") !== "on") nextErrors.privacyAccepted = "Bitte stimmen Sie der Verarbeitung Ihrer Angaben zu.";
setErrors(nextErrors);
if (Object.keys(nextErrors).length > 0) return;
@ -35,17 +38,18 @@ export default function RepairForm() {
});
const result = await response.json() as { message?: string };
if (!response.ok) throw new Error(result.message ?? "Anfrage konnte nicht gesendet werden.");
event.currentTarget.reset();
formRef.current?.reset();
setErrors({});
setStatus(result.message ?? "Ihre Reparaturanfrage wurde vorbereitet.");
} catch (error) {
setStatus(error instanceof Error ? error.message : "Anfrage konnte nicht gesendet werden.");
setSubmitError(error instanceof Error ? error.message : "Anfrage konnte nicht gesendet werden. Bitte versuchen Sie es später erneut.");
} finally {
setPending(false);
}
}
return (
<form className="form" onSubmit={submit} noValidate>
<form ref={formRef} className="form" onSubmit={submit} noValidate>
<div className="grid two">
<Field label="Name" name="name" error={errors.name} />
<Field label="E-Mail" name="email" type="email" error={errors.email} />
@ -90,10 +94,11 @@ export default function RepairForm() {
</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>
<label className="checkbox">
<input type="checkbox" name="privacy" />
<input type="checkbox" name="privacyAccepted" />
<span>Ich stimme zu, dass meine Angaben zur Bearbeitung der Reparaturanfrage verarbeitet werden.</span>
</label>
{errors.privacy && <span className="error">{errors.privacy}</span>}
{errors.privacyAccepted && <span className="error">{errors.privacyAccepted}</span>}
{submitError && <p className="error">{submitError}</p>}
{status && <p className="success">{status}</p>}
<button className="button" type="submit" disabled={pending}>{pending ? "Wird gesendet..." : "Reparatur anfragen"}</button>
</form>

View file

@ -0,0 +1,387 @@
"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

@ -0,0 +1,221 @@
"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

@ -0,0 +1,94 @@
"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

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

View file

@ -1,7 +1,7 @@
import { randomUUID } from "crypto";
import { mkdir, readdir, rm, stat, writeFile } from "fs/promises";
import { mkdir, readFile, readdir, rm, stat, writeFile } from "fs/promises";
import path from "path";
import { uploadDirectory } from "@/lib/runtime/config";
import { migrateLegacyUploads, uploadDirectory } from "@/lib/runtime/config";
const maxUploadSize = 5 * 1024 * 1024;
@ -15,12 +15,23 @@ const allowedMimeTypes = new Map([
export type MediaFile = {
name: string;
url: string;
type: string;
type: "image" | "pdf";
extension: string;
size: number;
uploadedAt: string;
isImage: boolean;
isPdf: boolean;
};
export function getMediaMimeType(fileName: string) {
const extension = path.extname(fileName).toLowerCase();
if (extension === ".jpg" || extension === ".jpeg") return "image/jpeg";
if (extension === ".png") return "image/png";
if (extension === ".webp") return "image/webp";
if (extension === ".pdf") return "application/pdf";
return "";
}
export async function ensureUploadDirectory() {
await mkdir(uploadDirectory, { recursive: true });
const probe = path.join(uploadDirectory, `.write-check-${Date.now()}`);
@ -72,21 +83,32 @@ export function resolveUploadTarget(fileName: string) {
return target;
}
function isAllowedStoredFile(fileName: string) {
const extension = path.extname(fileName).toLowerCase();
const allowedExtensions = [...allowedMimeTypes.values()].flat();
return !fileName.startsWith(".") && allowedExtensions.includes(extension);
}
export async function listMediaFiles(): Promise<MediaFile[]> {
await migrateLegacyUploads();
await mkdir(uploadDirectory, { recursive: true });
const entries = await readdir(uploadDirectory);
const visibleEntries = entries.filter((entry) => !entry.startsWith("."));
const visibleEntries = entries.filter(isAllowedStoredFile);
const files = await Promise.all(visibleEntries.map(async (entry) => {
const fileStat = await stat(path.join(uploadDirectory, entry));
const extension = path.extname(entry).toLowerCase();
const isImage = [".jpg", ".jpeg", ".png", ".webp"].includes(extension);
const isPdf = extension === ".pdf";
return {
name: entry,
url: `/uploads/images/${encodeURIComponent(entry)}`,
type: extension.replace(".", "").toUpperCase() || "Datei",
url: `/api/media/${encodeURIComponent(entry)}`,
type: isPdf ? "pdf" as const : "image" as const,
extension: extension.replace(".", "").toUpperCase() || "Datei",
size: fileStat.size,
uploadedAt: fileStat.birthtime.toISOString(),
isImage: [".jpg", ".jpeg", ".png", ".webp"].includes(extension),
isImage,
isPdf,
};
}));
@ -104,3 +126,30 @@ export async function saveMediaFile(file: File) {
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 path from "path";
import { dataDirectory } from "@/lib/runtime/config";
import type { ContactInquiry, InquiryStatus, RepairInquiry, SiteSettings, SmtpSettings } from "./types";
import { configDirectory, dataDirectory } from "@/lib/runtime/config";
import type { ContactInquiry, InquiryStatus, RepairInquiry, SiteSettings } from "./types";
async function ensureDataDir() {
await mkdir(dataDirectory, { recursive: true });
@ -112,26 +112,8 @@ export async function saveSiteSettings(form: FormData) {
return settings;
}
export async function getSmtpSettings() {
return readJson<SmtpSettings>("smtp-settings.json", {
host: "",
port: "587",
username: "",
fromAddress: "",
replyToAddress: "",
tls: true,
});
export async function ensureConfigDir() {
await mkdir(configDirectory, { recursive: true });
}
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;
}
export { text };

View file

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

205
lib/content/defaults.ts Normal file
View file

@ -0,0 +1,205 @@
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;

176
lib/content/service.ts Normal file
View file

@ -0,0 +1,176 @@
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,
};
}

138
lib/content/types.ts Normal file
View file

@ -0,0 +1,138 @@
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;
};

109
lib/mail/config.ts Normal file
View file

@ -0,0 +1,109 @@
import { mkdir, readFile, writeFile } from "fs/promises";
import path from "path";
import { configDirectory } from "@/lib/runtime/config";
import type { SmtpSettings } from "@/lib/admin/types";
import type { PublicSmtpSettings } from "./types";
const smtpConfigFile = path.join(configDirectory, "smtp.json");
const defaultSmtpSettings: SmtpSettings = {
host: "",
port: "587",
username: "",
password: "",
security: "starttls",
fromAddress: "",
replyToAddress: "",
recipientAddress: "",
bccAddress: "",
};
function text(value: FormDataEntryValue | null) {
return typeof value === "string" ? value.trim() : "";
}
function normalizeSecurity(value: string): SmtpSettings["security"] {
if (value === "tls" || value === "none") return value;
return "starttls";
}
async function ensureConfigDirectory() {
await mkdir(configDirectory, { recursive: true });
}
export async function getSmtpSettings(): Promise<SmtpSettings> {
await ensureConfigDirectory();
try {
const file = await readFile(smtpConfigFile, "utf8");
return { ...defaultSmtpSettings, ...JSON.parse(file) as SmtpSettings };
} catch {
return defaultSmtpSettings;
}
}
export function toPublicSmtpSettings(settings: SmtpSettings): PublicSmtpSettings {
return {
host: settings.host,
port: settings.port,
username: settings.username,
security: settings.security,
fromAddress: settings.fromAddress,
replyToAddress: settings.replyToAddress,
recipientAddress: settings.recipientAddress,
bccAddress: settings.bccAddress,
lastTestAt: settings.lastTestAt,
lastTestStatus: settings.lastTestStatus,
lastError: settings.lastError,
hasPassword: Boolean(settings.password),
};
}
export function isSmtpConfigured(settings: SmtpSettings) {
return Boolean(
settings.host &&
settings.port &&
settings.username &&
settings.password &&
settings.fromAddress &&
settings.recipientAddress,
);
}
export async function saveSmtpSettings(form: FormData) {
const current = await getSmtpSettings();
const nextPassword = text(form.get("password"));
const settings: SmtpSettings = {
...current,
host: text(form.get("host")),
port: text(form.get("port")) || "587",
username: text(form.get("username")),
password: nextPassword || current.password || "",
security: normalizeSecurity(text(form.get("security"))),
fromAddress: text(form.get("fromAddress")),
replyToAddress: text(form.get("replyToAddress")),
recipientAddress: text(form.get("recipientAddress")),
bccAddress: text(form.get("bccAddress")),
};
await ensureConfigDirectory();
await writeFile(smtpConfigFile, `${JSON.stringify(settings, null, 2)}\n`, "utf8");
return settings;
}
export async function updateSmtpTestStatus(status: Pick<SmtpSettings, "lastTestAt" | "lastTestStatus" | "lastError">) {
const current = await getSmtpSettings();
const settings: SmtpSettings = { ...current, ...status };
await ensureConfigDirectory();
await writeFile(smtpConfigFile, `${JSON.stringify(settings, null, 2)}\n`, "utf8");
return settings;
}
export async function updateSmtpDeliveryStatus(status: Pick<SmtpSettings, "lastDeliveryAt" | "lastDeliveryStatus" | "lastError">) {
const current = await getSmtpSettings();
const settings: SmtpSettings = { ...current, ...status };
await ensureConfigDirectory();
await writeFile(smtpConfigFile, `${JSON.stringify(settings, null, 2)}\n`, "utf8");
return settings;
}
export { smtpConfigFile };

100
lib/mail/smtp.ts Normal file
View file

@ -0,0 +1,100 @@
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;
}

74
lib/mail/templates.ts Normal file
View file

@ -0,0 +1,74 @@
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>",
};
}

13
lib/mail/types.ts Normal file
View file

@ -0,0 +1,13 @@
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;

145
lib/olympus/status.ts Normal file
View file

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

View file

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

32
package-lock.json generated
View file

@ -1,14 +1,16 @@
{
"name": "funktechnik-schubert-website",
"version": "0.2.2",
"version": "0.4.1",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "funktechnik-schubert-website",
"version": "0.2.2",
"version": "0.4.1",
"dependencies": {
"@types/nodemailer": "^8.0.1",
"next": "16.2.10",
"nodemailer": "^9.0.3",
"react": "19.2.3",
"react-dom": "19.2.3"
},
@ -1343,12 +1345,20 @@
"version": "24.13.2",
"resolved": "https://registry.npmjs.org/@types/node/-/node-24.13.2.tgz",
"integrity": "sha512-fRa09kZTgu8o71KFcDjUFuc7F+dEbZYZmkI0mg5YBTRs0yMKjYHsq/c0urDKeDb+D5qVgXOdFcuu+DZPKOITwA==",
"dev": true,
"license": "MIT",
"dependencies": {
"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": {
"version": "19.2.17",
"resolved": "https://registry.npmjs.org/@types/react/-/react-19.2.17.tgz",
@ -2701,9 +2711,9 @@
}
},
"node_modules/electron-to-chromium": {
"version": "1.5.385",
"resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.385.tgz",
"integrity": "sha512-78sa/M08MNAYHQfjoWMvOlKQqZ0ElhSm/L5HNUc96VZ3b+KvDVnngFm8sYQy0XrhTRgAhggHr5abA7yTvRdo4Q==",
"version": "1.5.387",
"resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.387.tgz",
"integrity": "sha512-TaxwufTFDufvPEoXdhwVrA3UdFWBeWGkYoJ1K8ldF1xe6gKfth6iRNS5lTQ5JPNOHdGQm8PT1QYKUqFLCiUefQ==",
"dev": true,
"license": "ISC"
},
@ -4669,6 +4679,15 @@
"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": {
"version": "4.1.1",
"resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz",
@ -5912,7 +5931,6 @@
"version": "7.18.2",
"resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.18.2.tgz",
"integrity": "sha512-AsuCzffGHJybSaRrmr5eHr81mwJU3kjw6M+uprWvCXiNeN9SOGwQ3Jn8jb8m3Z6izVgknn1R0FTCEAP2QrLY/w==",
"dev": true,
"license": "MIT"
},
"node_modules/unrs-resolver": {

View file

@ -1,6 +1,6 @@
{
"name": "funktechnik-schubert-website",
"version": "0.2.2",
"version": "0.4.1",
"private": true,
"scripts": {
"dev": "next dev -p 3010",
@ -9,7 +9,9 @@
"lint": "eslint"
},
"dependencies": {
"@types/nodemailer": "^8.0.1",
"next": "16.2.10",
"nodemailer": "^9.0.3",
"react": "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"
mkdir -p "$BACKUP_DIR"
tar -czf "$TARGET" data public/uploads
tar -czf "$TARGET" data storage
echo "Backup erstellt: $TARGET"
echo "Hinweis: .env wird aus Sicherheitsgruenden nicht automatisch gesichert."

View file

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

0
storage/content/.gitkeep Normal file
View file

View file

1
storage/uploads/.gitkeep Normal file
View file

@ -0,0 +1 @@

View file

@ -0,0 +1 @@