Compare commits

..

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

94 changed files with 189 additions and 5537 deletions

BIN
.DS_Store vendored Normal file

Binary file not shown.

View file

@ -5,11 +5,3 @@ node_modules
npm-debug.log*
.git
README.md
data/*.json
storage/uploads/images/*
storage/config/*
!storage/config/.gitkeep
!storage/uploads/images/.gitkeep
*.tmp
*.temp
.DS_Store

View file

@ -1,37 +1,5 @@
# Public base URL used for canonical URLs, sitemap, robots.txt and absolute metadata.
# Local example: http://localhost:3010
# Production example: https://funktechnik-schubert.de
NEXT_PUBLIC_SITE_URL=
NEXT_PUBLIC_SITE_URL=http://localhost:3010
# Admin login email address. Required for /admin.
ADMIN_EMAIL=
# Admin login password. Required for /admin.
# Use a long unique password and never commit a real value.
ADMIN_PASSWORD=
# Secret for signing the HttpOnly admin session cookie. Required.
# Use a long random value, for example generated by: openssl rand -base64 48
ADMIN_SESSION_SECRET=
# Set to true in production behind HTTPS so admin cookies are Secure.
# Use false only for local HTTP development.
AUTH_COOKIE_SECURE=false
# Optional future server-side Olympus intake endpoint.
# Leave empty until the Olympus integration is explicitly implemented.
# Optional spaetere Integration. Nicht im Browser verwenden.
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=

18
.gitignore vendored
View file

@ -5,21 +5,3 @@ dist
.env
.env.local
npm-debug.log*
data/contact-inquiries.json
data/repair-inquiries.json
data/site-settings.json
data/smtp-settings.json
storage/uploads/images/*
storage/config/*
storage/content/*
storage/content/backups/*
!storage/.gitkeep
!storage/config/.gitkeep
!storage/content/.gitkeep
!storage/content/backups/
!storage/content/backups/.gitkeep
!storage/uploads/.gitkeep
!storage/uploads/images/.gitkeep
.DS_Store
*.tmp
*.temp

View file

@ -14,22 +14,16 @@ FROM node:22-alpine AS runner
WORKDIR /app
ENV NODE_ENV=production
ENV NEXT_TELEMETRY_DISABLED=1
ENV NEXT_PUBLIC_APP_VERSION=0.4.1
ENV DOCKER_ENV=true
ENV PORT=3010
RUN addgroup --system --gid 1001 nodejs
RUN adduser --system --uid 1001 nextjs
COPY --from=builder --chown=nextjs:nodejs /app/public ./public
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/storage/config /app/storage/content/backups /app/storage/uploads/images /app/.next/cache \
&& chown -R nextjs:nodejs /app/data /app/storage /app/.next/cache
COPY --from=builder /app/public ./public
COPY --from=builder /app/.next/standalone ./
COPY --from=builder /app/.next/static ./.next/static
USER nextjs
EXPOSE 3010
ENV PORT=3010
CMD ["node", "runtime-start.js"]
CMD ["node", "server.js"]

304
README.md
View file

@ -7,14 +7,10 @@ Eigenständige öffentliche Firmenwebsite für Funktechnik Schubert. Dieses Proj
- Next.js 16 App Router
- TypeScript strict
- Serverseitige API-Routen für Kontakt und Reparaturannahme
- Geschützter Admin-Bereich unter `/admin`
- Lokale Storage-Datenablage für Anfragen, Einstellungen, Medien und Website-Inhalte
- Docker
- Nginx/Reverse-Proxy-fähig
- SEO Metadata, Sitemap und robots.txt
Version: `0.4.1`
## Seiten
- `/` Startseite
@ -23,40 +19,20 @@ Version: `0.4.1`
- `/reparatur`
- `/ueber-uns`
- `/kontakt`
- `/status/[token]`
- `/impressum`
- `/datenschutz`
- `/admin/login`
- `/admin`
- `/admin/kontaktanfragen`
- `/admin/reparaturanfragen`
- `/admin/website`
- `/admin/medien`
- `/admin/einstellungen`
- `/admin/smtp`
- `/admin/seo`
- `/admin/system`
## Lokale Entwicklung
```bash
npm install
cp .env.example .env
npm run dev
```
Die Website läuft lokal unter:
```text
http://127.0.0.1:3010
```
Für den Admin-Bereich müssen in `.env` mindestens gesetzt sein:
```text
ADMIN_EMAIL
ADMIN_PASSWORD
ADMIN_SESSION_SECRET
http://localhost:3010
```
## Checks
@ -85,14 +61,6 @@ Port:
3010:3010
```
Die Runtime-Daten werden über Docker-Volumes gespeichert:
- `funktechnik-data``/app/data`
- `funktechnik-storage``/app/storage`
- `funktechnik-next-cache``/app/.next/cache`
Dadurch sind keine manuellen `chmod`- oder `chown`-Befehle notwendig.
## Umgebung
`.env.example` kopieren:
@ -103,277 +71,18 @@ cp .env.example .env
Variablen:
- `NEXT_PUBLIC_SITE_URL`: öffentliche Basis-URL für SEO, Sitemap und Metadaten
- `ADMIN_EMAIL`: Admin-Login E-Mail
- `ADMIN_PASSWORD`: Admin-Login Passwort
- `ADMIN_SESSION_SECRET`: langer Zufallswert zum Signieren der Admin-Session
- `AUTH_COOKIE_SECURE`: `true` in Produktion mit HTTPS, lokal `false`
- `OLYMPUS_INTAKE_API_URL`: vorbereitet für spätere serverseitige Olympus-Anbindung
- `OLYMPUS_INTAKE_API_TOKEN`: vorbereitet für spätere serverseitige Olympus-Anbindung
- `OLYMPUS_PUBLIC_STATUS_API_URL`: serverseitig erreichbare Olympus-Basis-URL für öffentliche Reparaturstatuslinks
- `OLYMPUS_PUBLIC_STATUS_API_TOKEN`: optionaler serverseitiger Token für spätere geschützte Status-API-Zugriffe
- `NEXT_PUBLIC_SITE_URL`
- `OLYMPUS_INTAKE_API_URL`
- `OLYMPUS_INTAKE_API_TOKEN`
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.
Die Olympus-Variablen sind nur für eine spätere serverseitige Integration vorbereitet. Sie werden nicht im Browser verwendet.
## API-Routen
- `POST /api/repair`
- `POST /api/contact`
- `POST /api/admin/login`
- `POST /api/admin/logout`
- `PATCH /api/admin/contact/[id]`
- `PATCH /api/admin/repair/[id]`
- `POST /api/admin/settings`
- `POST /api/admin/smtp`
- `GET/POST /api/admin/media`
- `GET/PUT /api/admin/content`
- `GET /api/content/settings`
- `GET /api/media/[filename]`
- `GET /api/health`
## Öffentlicher Reparaturstatus
Olympus CRM erzeugt sichere Statuslinks für Reparaturen. Die Website stellt dafür die öffentliche Route bereit:
```text
/status/<token>
```
Die Seite ruft Olympus ausschließlich serverseitig auf:
```text
${OLYMPUS_PUBLIC_STATUS_API_URL}/public/repairs/status/<token>
```
Der Browser sieht weder die Olympus-URL noch optionale Server-Tokens. Die öffentliche Statusseite zeigt nur:
- Reparaturnummer
- Gerät
- aktuellen Status
- letzte Aktualisierung
- kundenfreundliche Status-Timeline
- optionalen Kostenvoranschlag aus Olympus CRM v0.8.6
Wenn Olympus einen Kostenvoranschlag im Status `sent` liefert, zeigt die Website:
- Nummer, Titel, Nachricht an den Kunden und Gültigkeit
- Positionen mit Menge, Einheit und Betrag
- Zwischensumme, Steuer und Gesamtbetrag
- Aktionen zum Freigeben, Ablehnen oder für eine Rückfrage
Diese Aktionen laufen nicht direkt vom Browser zu Olympus. Der Browser ruft ausschließlich relative Website-Endpunkte auf:
```text
POST /api/status/<token>/estimate/approve
POST /api/status/<token>/estimate/decline
POST /api/status/<token>/estimate/question
```
Die Website leitet diese Anfragen serverseitig an Olympus weiter:
```text
${OLYMPUS_PUBLIC_STATUS_API_URL}/public/repairs/status/<token>/estimate/<action>
```
Nicht angezeigt werden Kundendaten, interne Notizen, Diagnosedetails oder technische Fehlermeldungen.
Fehlerverhalten:
- ungültiger oder abgelaufener Token: freundliche Meldung mit Links zu Reparatur und Kontakt
- Olympus nicht erreichbar: neutrale Meldung ohne technische Details
Aktuell validieren die Routen serverseitig, geben klare JSON-Antworten zurück und schreiben nur technische Metadaten in Server-Logs. Es werden keine Nachrichteninhalte oder Tokens geloggt. Kontakt- und Reparaturanfragen werden lokal unter `data/*.json` gespeichert und nicht versioniert.
## Admin-Bereich
Der geschützte Admin-Bereich ist unter `/admin/login` erreichbar. Er verwendet ein signiertes HttpOnly-Cookie und ist für Desktop und Tablet ausgelegt.
Funktionen:
- Dashboard mit Kennzahlen
- Kontaktanfragen verwalten
- Reparaturanfragen verwalten
- Website-Inhalte pflegen und sofort veröffentlichen
- Firmendaten pflegen
- SMTP-Konfiguration speichern und Testmail senden
- Medien hochladen
- SEO-Übersicht
- Systemübersicht
Website-Inhalte werden unter `/admin/website` gepflegt und beim Speichern sofort auf der öffentlichen Website wirksam. Die Content-Architektur ist bewusst über einen zentralen `ContentService` gekapselt, damit später Olympus CMS oder PostgreSQL als Backend angebunden werden können. SMTP-Versand fuer Kontakt- und Reparaturanfragen ist serverseitig angebunden, sofern `/admin/smtp` vollstaendig konfiguriert ist.
## Runtime Data
Im Repository liegen nur:
```text
data/.gitkeep
data/contact-inquiries.example.json
data/repair-inquiries.example.json
```
Echte Runtime-Dateien werden nicht committed:
```text
data/contact-inquiries.json
data/repair-inquiries.json
data/site-settings.json
```
SMTP-Konfiguration wird unter `storage/config/smtp.json` gespeichert und nicht committed. Das SMTP-Passwort wird nicht im Admin-Formular ausgegeben.
Uploads liegen unter `storage/uploads/images/`, werden über `/api/media/[filename]` ausgeliefert und nicht committed.
Alte Uploads aus `public/uploads/images` werden beim Start einmalig nach `storage/uploads/images` migriert, falls sie dort noch nicht vorhanden sind.
Website-Inhalte liegen unter `storage/content/` und werden nicht committed:
```text
storage/content/home.json
storage/content/services.json
storage/content/radio-service.json
storage/content/repair.json
storage/content/about.json
storage/content/contact.json
storage/content/settings.json
storage/content/backups/
```
Jede Seite besitzt eine eigene JSON-Datei. React-Komponenten lesen diese Dateien nicht direkt, sondern ausschließlich über `lib/content/service.ts`. Vor jedem Speichern erzeugt der Service eine Backup-Datei unter `storage/content/backups/` und behält pro Dokument die letzten 20 Versionen. Änderungen benötigen keinen Neustart und keinen Docker-Build.
Bearbeitbar sind aktuell:
- Startseite mit Hero, Call-to-Actions, Featureboxen, Leistungsboxen und Kundenversprechen
- Leistungen mit beliebig vielen sortierbaren und ein-/ausblendbaren Einträgen
- Funkgeräte-Service mit Einleitung, Marken, Fehlerbildern, Ablauf, Messmöglichkeiten, Abgleich und Reparaturtexten
- Reparaturannahme mit SEO-Daten und Seitentexten
- Über Uns mit Firmenbeschreibung, Werkstattbeschreibung und Philosophie
- Kontakt mit Kontaktinformationen, Öffnungszeiten, Maps-Link und Seitentexten
- Footer, Logo, Copyright, Header-CTA und globale SEO-Einstellungen
- SEO pro Seite inklusive Meta Title, Meta Description, Keywords, OpenGraph und Social Image
Bilder für Logo und Social Image werden aus dem Medienbestand ausgewählt. Neue Uploads bleiben im privaten Storage und werden über `/api/media/[filename]` ausgeliefert.
## Roadmap Kundenportal
Ein vollständiges Kundenportal ist vorbereitet, aber noch nicht öffentlich implementiert. Die öffentliche Statuslink-Seite ist bereits vorhanden. Für spätere Ausbaustufen sind folgende Routen vorgesehen:
- `/status/[token]`: öffentliche Statuslink-Seite für Olympus
- `/reparatur/status`: Statusabfrage für Reparaturanfragen
- `/portal/login`: geschützter Kundenlogin
Diese Routen sind bewusst noch nicht angelegt. Die spätere Umsetzung soll serverseitig erfolgen, ohne Tokens im Browser-JavaScript zu speichern und ohne bestehende Admin- oder CMS-Funktionen zu umgehen.
## Healthcheck
```bash
scripts/healthcheck.sh
```
Oder direkt:
```bash
curl http://127.0.0.1:3010/api/health
```
Antwort:
```json
{
"status": "ok",
"version": "0.4.1",
"storage": "ok",
"admin": "configured",
"smtp": "configured",
"timestamp": "..."
}
```
## SMTP
SMTP wird im Adminbereich unter `/admin/smtp` konfiguriert. Die Konfiguration wird persistent unter `storage/config/smtp.json` gespeichert und liegt damit im Docker-Storage-Volume, nicht in `public/`.
Pflichtfelder:
- SMTP Host
- SMTP Port
- SMTP Benutzername
- SMTP Passwort
- Verschlüsselung
- Absenderadresse
- Empfängeradresse
Für Apple Mail/iCloud Mail:
```text
Host: smtp.mail.me.com
Port: 587
Verschlüsselung: STARTTLS
Benutzername: vollständige E-Mail-Adresse
Passwort: app-spezifisches Passwort
```
Nicht das normale Apple-ID-Passwort verwenden. In der Apple-ID-Verwaltung ein app-spezifisches Passwort erzeugen und dieses als SMTP-Passwort speichern.
Nach dem Speichern kann im Adminbereich eine Testmail gesendet werden. Kontakt- und Reparaturanfragen werden weiterhin lokal gespeichert. Wenn SMTP konfiguriert ist, wird zusaetzlich eine E-Mail an die konfigurierte Empfaengeradresse gesendet. Schlaegt der Mailversand fehl, bleibt die Anfrage gespeichert; der Besucher sieht keine technische Fehlermeldung.
Troubleshooting:
- Host, Port und Verschlüsselung prüfen
- bei Apple Mail STARTTLS und Port 587 verwenden
- vollständige E-Mail-Adresse als Benutzername verwenden
- app-spezifisches Passwort neu erzeugen
- letzte Testmail und letzte Fehlermeldung unter `/admin/smtp` prüfen
## Deployment
```bash
scripts/deploy.sh
```
Das Skript führt aus:
- `docker compose config`
- `docker compose build`
- `docker compose up -d`
- Healthcheck über `/api/health`
Produktiv muss die `.env` auf dem Zielsystem gepflegt werden. Secrets werden nicht ins Repository aufgenommen.
## Update
```bash
git pull
docker compose config
docker compose build
docker compose up -d
scripts/healthcheck.sh
```
## Backup
```bash
scripts/backup.sh
```
Das Backup enthält `data/` und `storage/`. Die `.env` wird bewusst nicht automatisch gesichert. Sie muss separat sicher abgelegt werden.
## Restore
```bash
scripts/restore.sh backups/funktechnik-data-YYYYMMDD-HHMMSS.tar.gz
```
Danach Container neu starten:
```bash
docker compose up -d
scripts/healthcheck.sh
```
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.
## Nginx
@ -394,5 +103,6 @@ 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,28 +0,0 @@
import { redirect } from "next/navigation";
import AdminShell from "@/components/admin/AdminShell";
import { requireAdminSession } from "@/lib/admin/auth";
import { getSiteSettings } from "@/lib/admin/store";
export default async function AdminSettingsPage() {
if (!await requireAdminSession()) redirect("/admin/login");
const settings = await getSiteSettings();
return (
<AdminShell>
<div className="admin-page-head">
<p className="eyebrow">Konfiguration</p>
<h1>Firmeneinstellungen</h1>
</div>
<form className="admin-card admin-form" action="/api/admin/settings" method="post">
<label>Firmenname<input name="companyName" defaultValue={settings.companyName} /></label>
<label>Telefon<input name="phone" defaultValue={settings.phone} /></label>
<label>E-Mail<input name="email" type="email" defaultValue={settings.email} /></label>
<label>Adresse<input name="address" defaultValue={settings.address} /></label>
<label>Öffnungszeiten<input name="openingHours" defaultValue={settings.openingHours} /></label>
<label>Google Maps Embed URL<input name="googleMapsEmbedUrl" defaultValue={settings.googleMapsEmbedUrl} /></label>
<label>Social Links<textarea name="socialLinks" defaultValue={settings.socialLinks} /></label>
<button className="button" type="submit">Speichern</button>
</form>
</AdminShell>
);
}

View file

@ -1,48 +0,0 @@
import { redirect } from "next/navigation";
import AdminShell from "@/components/admin/AdminShell";
import StatusSelect from "@/components/admin/StatusSelect";
import { requireAdminSession } from "@/lib/admin/auth";
import { getContactInquiries } from "@/lib/admin/store";
export default async function AdminContactsPage() {
if (!await requireAdminSession()) redirect("/admin/login");
const inquiries = await getContactInquiries();
return (
<AdminShell>
<div className="admin-page-head">
<p className="eyebrow">Anfragen</p>
<h1>Kontaktanfragen</h1>
</div>
<section className="admin-card">
<table className="admin-table">
<thead><tr><th>Name</th><th>E-Mail</th><th>Betreff</th><th>Datum</th><th>Status</th></tr></thead>
<tbody>
{inquiries.map((item) => (
<tr key={item.id}>
<td>{item.name}</td>
<td><a href={`mailto:${item.email}`}>{item.email}</a></td>
<td>{item.subject}</td>
<td>{new Date(item.createdAt).toLocaleString("de-DE")}</td>
<td><StatusSelect id={item.id} type="contact" status={item.status} /></td>
</tr>
))}
{inquiries.length === 0 && <tr><td colSpan={5}>Noch keine Kontaktanfragen vorhanden.</td></tr>}
</tbody>
</table>
</section>
<section className="admin-card">
<h2>Detailansicht</h2>
<div className="admin-detail-grid">
{inquiries.slice(0, 6).map((item) => (
<article key={item.id} className="admin-detail">
<h3>{item.name}</h3>
<p><strong>{item.subject}</strong></p>
<p>{item.message}</p>
</article>
))}
</div>
</section>
</AdminShell>
);
}

View file

@ -1,5 +0,0 @@
import type { ReactNode } from "react";
export default function AdminRootLayout({ children }: { children: ReactNode }) {
return children;
}

View file

@ -1,14 +0,0 @@
import type { Metadata } from "next";
import AdminLoginForm from "@/components/admin/AdminLoginForm";
import { adminConfigurationStatus } from "@/lib/runtime/config";
export const metadata: Metadata = {
title: "Admin Login | Funktechnik Schubert",
robots: { index: false, follow: false },
};
export default async function AdminLoginPage({ searchParams }: { searchParams: Promise<{ next?: string }> }) {
const params = await searchParams;
const nextPath = params.next?.startsWith("/admin") ? params.next : "/admin";
return <AdminLoginForm nextPath={nextPath} adminConfigured={adminConfigurationStatus() === "configured"} />;
}

View file

@ -1,20 +0,0 @@
import { redirect } from "next/navigation";
import AdminShell from "@/components/admin/AdminShell";
import MediaManager from "@/components/admin/MediaManager";
import { requireAdminSession } from "@/lib/admin/auth";
import { listMediaFiles } from "@/lib/admin/media";
export default async function AdminMediaPage() {
if (!await requireAdminSession()) redirect("/admin/login");
const files = await listMediaFiles();
return (
<AdminShell>
<div className="admin-page-head">
<p className="eyebrow">Assets</p>
<h1>Medienverwaltung</h1>
</div>
<MediaManager initialFiles={files} />
</AdminShell>
);
}

View file

@ -1,68 +0,0 @@
import { redirect } from "next/navigation";
import AdminShell from "@/components/admin/AdminShell";
import { requireAdminSession } from "@/lib/admin/auth";
import { listMediaFiles } from "@/lib/admin/media";
import { getContactInquiries, getRepairInquiries } from "@/lib/admin/store";
import { getContentSummary } from "@/lib/content/service";
import { getSmtpSettings, isSmtpConfigured } from "@/lib/mail/config";
import { appVersion, checkStorage } from "@/lib/runtime/config";
function countNew<T extends { status: string }>(items: T[]) {
return items.filter((item) => item.status === "new").length;
}
export default async function AdminDashboardPage() {
if (!await requireAdminSession()) redirect("/admin/login");
let healthStatus = "OK";
try {
await checkStorage();
} catch {
healthStatus = "Fehler";
}
const [contacts, repairs, smtpSettings, mediaFiles, contentSummary] = await Promise.all([
getContactInquiries(),
getRepairInquiries(),
getSmtpSettings(),
listMediaFiles(),
getContentSummary(),
]);
const latest = [...contacts, ...repairs].sort((a, b) => b.createdAt.localeCompare(a.createdAt)).slice(0, 6);
const contentModified = contentSummary.lastModified ? new Date(contentSummary.lastModified).toLocaleString("de-DE") : "keine Änderung";
return (
<AdminShell>
<div className="admin-page-head">
<p className="eyebrow">Administration</p>
<h1>Dashboard</h1>
</div>
<div className="admin-kpis">
<div className="admin-kpi"><span>Kontaktanfragen</span><strong>{contacts.length}</strong><small>{countNew(contacts)} neu</small></div>
<div className="admin-kpi"><span>Reparaturanfragen</span><strong>{repairs.length}</strong><small>{countNew(repairs)} neu</small></div>
<div className="admin-kpi"><span>Medien</span><strong>{mediaFiles.length}</strong><small>im Storage</small></div>
<div className="admin-kpi"><span>Content</span><strong>{contentSummary.pageCount}</strong><small>{contentModified}</small></div>
<div className="admin-kpi"><span>SMTP</span><strong>{isSmtpConfigured(smtpSettings) ? "OK" : "Fehlt"}</strong><small>{smtpSettings.lastTestStatus ?? "kein Test"}</small></div>
<div className="admin-kpi"><span>Version</span><strong>v{appVersion}</strong><small>Website</small></div>
<div className="admin-kpi"><span>Health</span><strong>{healthStatus}</strong><small>Storage und Runtime</small></div>
</div>
<section className="admin-card">
<h2>Letzte Anfragen</h2>
<table className="admin-table">
<thead><tr><th>Datum</th><th>Name</th><th>Typ</th><th>Status</th></tr></thead>
<tbody>
{latest.map((item) => (
<tr key={item.id}>
<td>{new Date(item.createdAt).toLocaleDateString("de-DE")}</td>
<td>{item.name}</td>
<td>{"manufacturer" in item ? "Reparatur" : "Kontakt"}</td>
<td><span className={`status ${item.status}`}>{item.status}</span></td>
</tr>
))}
{latest.length === 0 && <tr><td colSpan={4}>Noch keine Anfragen vorhanden.</td></tr>}
</tbody>
</table>
</section>
</AdminShell>
);
}

View file

@ -1,51 +0,0 @@
import { redirect } from "next/navigation";
import AdminShell from "@/components/admin/AdminShell";
import StatusSelect from "@/components/admin/StatusSelect";
import { requireAdminSession } from "@/lib/admin/auth";
import { getRepairInquiries } from "@/lib/admin/store";
export default async function AdminRepairsPage() {
if (!await requireAdminSession()) redirect("/admin/login");
const inquiries = await getRepairInquiries();
return (
<AdminShell>
<div className="admin-page-head">
<p className="eyebrow">Anfragen</p>
<h1>Reparaturanfragen</h1>
</div>
<section className="admin-card">
<table className="admin-table">
<thead><tr><th>Datum</th><th>Name</th><th>Gerät</th><th>Hersteller</th><th>Modell</th><th>Status</th></tr></thead>
<tbody>
{inquiries.map((item) => (
<tr key={item.id}>
<td>{new Date(item.createdAt).toLocaleDateString("de-DE")}</td>
<td>{item.name}</td>
<td>{item.deviceType}</td>
<td>{item.manufacturer}</td>
<td>{item.model}</td>
<td><StatusSelect id={item.id} type="repair" status={item.status} /></td>
</tr>
))}
{inquiries.length === 0 && <tr><td colSpan={6}>Noch keine Reparaturanfragen vorhanden.</td></tr>}
</tbody>
</table>
</section>
<section className="admin-card">
<h2>Technische Details</h2>
<div className="admin-detail-grid">
{inquiries.slice(0, 6).map((item) => (
<article key={item.id} className="admin-detail">
<h3>{item.manufacturer} {item.model}</h3>
<p><strong>Fehler:</strong> {item.description}</p>
<p><strong>Zubehör:</strong> {item.accessories || "nicht angegeben"}</p>
<p><strong>Vorarbeiten:</strong> {item.previousWork || "nicht angegeben"}</p>
<button className="button light" type="button" disabled>In Olympus übernehmen</button>
</article>
))}
</div>
</section>
</AdminShell>
);
}

View file

@ -1,24 +0,0 @@
import { redirect } from "next/navigation";
import AdminShell from "@/components/admin/AdminShell";
import { requireAdminSession } from "@/lib/admin/auth";
import { defaultTitle, siteName, siteUrl } from "@/app/seo";
export default async function AdminSeoPage() {
if (!await requireAdminSession()) redirect("/admin/login");
return (
<AdminShell>
<div className="admin-page-head">
<p className="eyebrow">Sichtbarkeit</p>
<h1>SEO Verwaltung</h1>
</div>
<section className="admin-card admin-form">
<label>Seitentitel<input readOnly value={defaultTitle} /></label>
<label>Site Name<input readOnly value={siteName} /></label>
<label>Basis-URL<input readOnly value={siteUrl} /></label>
<label>Meta Description<textarea readOnly value="Service, Reparatur, Diagnose und Abgleich von Funktechnik, CB-Funk, Amateurfunk und Kommunikationstechnik." /></label>
<p className="admin-muted">Live-Bearbeitung pro Seite ist vorbereitet und wird in einer späteren Publishing-Ausbaustufe aktiviert.</p>
</section>
</AdminShell>
);
}

View file

@ -1,20 +0,0 @@
import { redirect } from "next/navigation";
import AdminShell from "@/components/admin/AdminShell";
import SmtpSettingsForm from "@/components/admin/SmtpSettingsForm";
import { requireAdminSession } from "@/lib/admin/auth";
import { getSmtpSettings, isSmtpConfigured, toPublicSmtpSettings } from "@/lib/mail/config";
export default async function AdminSmtpPage() {
if (!await requireAdminSession()) redirect("/admin/login");
const settings = await getSmtpSettings();
return (
<AdminShell>
<div className="admin-page-head">
<p className="eyebrow">E-Mail</p>
<h1>SMTP Einstellungen</h1>
</div>
<SmtpSettingsForm initialSettings={toPublicSmtpSettings(settings)} configured={isSmtpConfigured(settings)} />
</AdminShell>
);
}

View file

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

View file

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

View file

@ -1,19 +0,0 @@
import { NextResponse } from "next/server";
import { requireAdminSession } from "@/lib/admin/auth";
import { updateContactStatus } from "@/lib/admin/store";
import type { InquiryStatus } from "@/lib/admin/types";
const statuses: InquiryStatus[] = ["new", "in_progress", "done"];
export async function PATCH(request: Request, { params }: { params: Promise<{ id: string }> }) {
if (!await requireAdminSession()) return NextResponse.json({ message: "Nicht autorisiert." }, { status: 401 });
const body = await request.json() as { status?: InquiryStatus };
if (!body.status || !statuses.includes(body.status)) {
return NextResponse.json({ message: "Ungültiger Status." }, { status: 400 });
}
const { id } = await params;
await updateContactStatus(id, body.status);
return NextResponse.json({ message: "Status aktualisiert." });
}

View file

@ -1,27 +0,0 @@
import { NextResponse } from "next/server";
import { requireAdminSession } from "@/lib/admin/auth";
import { getAllContent, saveContent } from "@/lib/content/service";
import type { ContentDocuments, ContentKey } from "@/lib/content/types";
const keys: ContentKey[] = ["home", "services", "radio-service", "repair", "about", "contact", "settings"];
function isContentKey(value: unknown): value is ContentKey {
return typeof value === "string" && keys.includes(value as ContentKey);
}
export async function GET() {
if (!await requireAdminSession()) return NextResponse.json({ message: "Nicht autorisiert." }, { status: 401 });
return NextResponse.json({ content: await getAllContent() });
}
export async function PUT(request: Request) {
if (!await requireAdminSession()) return NextResponse.json({ message: "Nicht autorisiert." }, { status: 401 });
const body = await request.json() as { key?: unknown; content?: unknown };
if (!isContentKey(body.key) || !body.content) {
return NextResponse.json({ message: "Ungültige Content-Anfrage." }, { status: 400 });
}
const saved = await saveContent(body.key, body.content as ContentDocuments[typeof body.key]);
return NextResponse.json({ message: "Inhalt gespeichert.", key: body.key, content: saved });
}

View file

@ -1,16 +0,0 @@
import { NextResponse } from "next/server";
import { adminCookieOptions, adminSessionCookie, createAdminSession, validateAdminCredentials } from "@/lib/admin/auth";
export async function POST(request: Request) {
const form = await request.formData();
const email = String(form.get("email") ?? "");
const password = String(form.get("password") ?? "");
if (!validateAdminCredentials(email, password)) {
return NextResponse.json({ message: "Ungültige Zugangsdaten." }, { status: 401 });
}
const response = NextResponse.json({ message: "Anmeldung erfolgreich." });
response.cookies.set(adminSessionCookie, createAdminSession(), adminCookieOptions());
return response;
}

View file

@ -1,8 +0,0 @@
import { NextResponse } from "next/server";
import { adminSessionCookie } from "@/lib/admin/auth";
export async function POST() {
const response = NextResponse.json({ message: "Abgemeldet." });
response.cookies.set(adminSessionCookie, "", { path: "/", maxAge: 0 });
return response;
}

View file

@ -1,39 +0,0 @@
import { NextResponse } from "next/server";
import { requireAdminSession } from "@/lib/admin/auth";
import { deleteMediaFile, listMediaFiles, saveMediaFile } from "@/lib/admin/media";
export async function GET() {
if (!await requireAdminSession()) return NextResponse.json({ message: "Nicht autorisiert." }, { status: 401 });
return NextResponse.json({ files: await listMediaFiles() });
}
export async function POST(request: Request) {
if (!await requireAdminSession()) return NextResponse.json({ message: "Nicht autorisiert." }, { status: 401 });
const form = await request.formData();
const file = form.get("file");
if (!(file instanceof File)) {
return NextResponse.json({ message: "Bitte wählen Sie eine Datei aus." }, { status: 400 });
}
try {
const result = await saveMediaFile(file);
if (result.error) return NextResponse.json({ message: result.error }, { status: 400 });
} catch {
return NextResponse.json({ message: "Die Datei konnte nicht gespeichert werden. Bitte Upload-Speicher und Rechte prüfen." }, { status: 500 });
}
return NextResponse.json({ message: "Datei erfolgreich hochgeladen.", files: await listMediaFiles() });
}
export async function DELETE(request: Request) {
if (!await requireAdminSession()) return NextResponse.json({ message: "Nicht autorisiert." }, { status: 401 });
const body = await request.json() as { name?: string };
if (!body.name) return NextResponse.json({ message: "Dateiname fehlt." }, { status: 400 });
const result = await deleteMediaFile(body.name);
if (result.error) return NextResponse.json({ message: result.error }, { status: 400 });
return NextResponse.json({ message: "Datei gelöscht.", files: await listMediaFiles() });
}

View file

@ -1,19 +0,0 @@
import { NextResponse } from "next/server";
import { requireAdminSession } from "@/lib/admin/auth";
import { updateRepairStatus } from "@/lib/admin/store";
import type { InquiryStatus } from "@/lib/admin/types";
const statuses: InquiryStatus[] = ["new", "in_progress", "done"];
export async function PATCH(request: Request, { params }: { params: Promise<{ id: string }> }) {
if (!await requireAdminSession()) return NextResponse.json({ message: "Nicht autorisiert." }, { status: 401 });
const body = await request.json() as { status?: InquiryStatus };
if (!body.status || !statuses.includes(body.status)) {
return NextResponse.json({ message: "Ungültiger Status." }, { status: 400 });
}
const { id } = await params;
await updateRepairStatus(id, body.status);
return NextResponse.json({ message: "Status aktualisiert." });
}

View file

@ -1,9 +0,0 @@
import { NextResponse } from "next/server";
import { requireAdminSession } from "@/lib/admin/auth";
import { saveSiteSettings } from "@/lib/admin/store";
export async function POST(request: Request) {
if (!await requireAdminSession()) return NextResponse.json({ message: "Nicht autorisiert." }, { status: 401 });
await saveSiteSettings(await request.formData());
return new NextResponse(null, { status: 303, headers: { Location: "/admin/einstellungen?saved=1" } });
}

View file

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

View file

@ -1,6 +1,4 @@
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() : "";
@ -10,15 +8,16 @@ export async function POST(request: Request) {
const form = await request.formData();
const name = text(form.get("name"));
const email = text(form.get("email"));
const subject = text(form.get("subject"));
const message = text(form.get("message"));
if (!name || !email.includes("@") || !subject || message.length < 10 || form.get("privacyAccepted") !== "on") {
if (!name || !email.includes("@") || message.length < 10 || form.get("privacy") !== "on") {
return NextResponse.json({ message: "Bitte füllen Sie alle Pflichtfelder aus." }, { status: 400 });
}
const inquiry = await addContactInquiry(form);
await sendContactInquiryMail(inquiry);
console.info("contact_request.received", {
messageLength: message.length,
hasEmail: true,
});
return NextResponse.json({
message: "Ihre Nachricht wurde erfasst. Sie erhalten eine Rückmeldung.",

View file

@ -1,7 +0,0 @@
import { NextResponse } from "next/server";
import { getContent } from "@/lib/content/service";
export async function GET() {
const settings = await getContent("settings");
return NextResponse.json({ settings });
}

View file

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

View file

@ -1,22 +0,0 @@
import { NextResponse } from "next/server";
import { readMediaFile } from "@/lib/admin/media";
export async function GET(_request: Request, { params }: { params: Promise<{ filename: string }> }) {
const { filename } = await params;
const media = await readMediaFile(decodeURIComponent(filename));
if (!media) {
return NextResponse.json({ message: "Datei nicht gefunden." }, { status: 404 });
}
return new NextResponse(media.data, {
status: 200,
headers: {
"Content-Type": media.mimeType,
"Content-Length": String(media.size),
"Content-Disposition": "inline",
"Cache-Control": "private, max-age=300",
"X-Content-Type-Options": "nosniff",
},
});
}

View file

@ -1,6 +1,4 @@
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;
@ -14,14 +12,24 @@ export async function POST(request: Request) {
const model = form.get("model");
const deviceType = form.get("deviceType");
const description = form.get("description");
const privacyAccepted = form.get("privacyAccepted");
const privacy = form.get("privacy");
if (!required(name) || !required(email) || !required(manufacturer) || !required(model) || !required(deviceType) || !required(description) || privacyAccepted !== "on") {
if (!required(name) || !required(email) || !required(manufacturer) || !required(model) || !required(deviceType) || !required(description) || privacy !== "on") {
return NextResponse.json({ message: "Bitte füllen Sie alle Pflichtfelder aus." }, { status: 400 });
}
const inquiry = await addRepairInquiry(form);
await sendRepairInquiryMail(inquiry);
const intakeUrl = process.env.OLYMPUS_INTAKE_API_URL;
const hasIntegrationToken = Boolean(process.env.OLYMPUS_INTAKE_API_TOKEN);
console.info("repair_intake.received", {
hasIntakeIntegration: Boolean(intakeUrl && hasIntegrationToken),
manufacturer: String(manufacturer),
model: String(model),
deviceType: String(deviceType),
hasPhone: required(form.get("phone")),
hasSerialNumber: required(form.get("serialNumber")),
hasAccessories: required(form.get("accessories")),
});
return NextResponse.json({
message: "Ihre Reparaturanfrage wurde erfasst. Sie erhalten nach Prüfung eine Rückmeldung.",

View file

@ -1,36 +0,0 @@
import { NextResponse } from "next/server";
import type { EstimateActionResult } from "@/lib/olympus/status";
export function estimateActionResponse(result: EstimateActionResult, successMessage: string) {
if (result.ok) {
return NextResponse.json({ message: successMessage });
}
if (result.reason === "invalid") {
return NextResponse.json(
{ message: "Dieser Statuslink ist ungültig oder nicht mehr aktiv." },
{ status: 404 },
);
}
if (result.reason === "unavailable") {
return NextResponse.json(
{ message: "Der Reparaturstatus ist momentan nicht abrufbar." },
{ status: 502 },
);
}
return NextResponse.json(
{ message: "Die Aktion konnte nicht abgeschlossen werden. Bitte versuchen Sie es erneut." },
{ status: 400 },
);
}
export async function readOptionalMessage(request: Request) {
try {
const body = await request.json() as { message?: unknown };
return typeof body.message === "string" ? body.message : "";
} catch {
return "";
}
}

View file

@ -1,15 +0,0 @@
import { submitOlympusEstimateAction } from "@/lib/olympus/status";
import { estimateActionResponse } from "../action-response";
type RouteContext = {
params: Promise<{
token: string;
}>;
};
export async function POST(_request: Request, { params }: RouteContext) {
const { token } = await params;
const result = await submitOlympusEstimateAction(token, "approve");
return estimateActionResponse(result, "Kostenvoranschlag wurde freigegeben.");
}

View file

@ -1,16 +0,0 @@
import { submitOlympusEstimateAction } from "@/lib/olympus/status";
import { estimateActionResponse, readOptionalMessage } from "../action-response";
type RouteContext = {
params: Promise<{
token: string;
}>;
};
export async function POST(request: Request, { params }: RouteContext) {
const { token } = await params;
const message = await readOptionalMessage(request);
const result = await submitOlympusEstimateAction(token, "decline", message);
return estimateActionResponse(result, "Kostenvoranschlag wurde abgelehnt.");
}

View file

@ -1,16 +0,0 @@
import { submitOlympusEstimateAction } from "@/lib/olympus/status";
import { estimateActionResponse, readOptionalMessage } from "../action-response";
type RouteContext = {
params: Promise<{
token: string;
}>;
};
export async function POST(request: Request, { params }: RouteContext) {
const { token } = await params;
const message = await readOptionalMessage(request);
const result = await submitOlympusEstimateAction(token, "question", message);
return estimateActionResponse(result, "Ihre Rückfrage wurde gesendet.");
}

View file

@ -10,15 +10,15 @@ export default function DatenschutzPage() {
<PageHero eyebrow="Rechtliches" title="Datenschutz">Informationen zur Verarbeitung personenbezogener Daten.</PageHero>
<section className="section">
<div className="container legal">
<p className="notice">Datenschutzhinweise müssen vor produktiver Veröffentlichung durch den Betreiber ergänzt und rechtlich geprüft werden.</p>
<p className="notice">TODO: Rechtliche Angaben ergänzen</p>
<h2>Verantwortlicher</h2>
<p>Die Angaben zum Verantwortlichen werden durch den Betreiber gepflegt.</p>
<p>TODO: Rechtliche Angaben ergänzen</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>Informationen zum Hosting und zur Verarbeitung technischer Serverdaten werden durch den Betreiber gepflegt.</p>
<p>TODO: Rechtliche Angaben ergänzen</p>
<h2>Betroffenenrechte</h2>
<p>Hinweise zu Betroffenenrechten werden durch den Betreiber rechtlich geprüft und gepflegt.</p>
<p>TODO: Rechtliche Angaben ergänzen</p>
</div>
</section>
</>

View file

@ -1,19 +0,0 @@
"use client";
import Link from "next/link";
export default function ErrorPage({ reset }: { error: Error & { digest?: string }; reset: () => void }) {
return (
<section className="error-page">
<div className="error-card">
<p className="eyebrow">500</p>
<h1>Die Seite konnte nicht geladen werden</h1>
<p className="lead">Es ist ein technischer Fehler aufgetreten. Bitte versuchen Sie es erneut.</p>
<div className="actions">
<button className="button" type="button" onClick={reset}>Erneut versuchen</button>
<Link className="button light" href="/">Zur Startseite</Link>
</div>
</div>
</section>
);
}

View file

@ -1,41 +1,33 @@
import type { Metadata } from "next";
import Link from "next/link";
import PageHero from "@/components/PageHero";
import { defaultContent } from "@/lib/content/defaults";
import { getContent } from "@/lib/content/service";
import { createContentMetadata } from "../seo";
import { createMetadata } from "../seo";
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 const metadata: Metadata = createMetadata("Funkgeräte-Service", "Werkstattservice für CB-Funk, Amateurfunk und Funkgeräte-Abgleich.", "/funkgeraete-service");
export default function RadioServicePage() {
return (
<>
<PageHero eyebrow={content.eyebrow} title={content.title}>
{content.subtitle}
<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>
<section className="section">
<div className="container split">
<div>
<h2>{content.listTitle}</h2>
<h2>Typische Fehlerbilder</h2>
<ul className="list">
{content.symptoms.map((symptom) => <li key={symptom}>{symptom}</li>)}
<li>Kein oder schwacher Empfang</li>
<li>Keine, leise oder verzerrte Modulation</li>
<li>Frequenzversatz, PLL-Rasten oder instabile Kanäle</li>
<li>Sendeprobleme, schwankende Leistung oder auffällige Erwärmung</li>
<li>Audio-/NF-Probleme, Displayfehler oder Aussetzer durch Bedienelemente</li>
<li>Unklare Vorarbeiten, fehlende Serviceunterlagen oder bereits geöffnete Geräte</li>
</ul>
</div>
<div className="panel">
<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>
<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>
</div>
</div>
</section>

View file

@ -1,18 +0,0 @@
"use client";
export default function GlobalErrorPage({ reset }: { error: Error & { digest?: string }; reset: () => void }) {
return (
<html lang="de">
<body>
<section className="error-page">
<div className="error-card">
<p className="eyebrow">Runtime</p>
<h1>Ein unerwarteter Fehler ist aufgetreten</h1>
<p className="lead">Die Anwendung konnte diesen Bereich nicht laden. Bitte versuchen Sie es erneut.</p>
<button className="button" type="button" onClick={reset}>Erneut versuchen</button>
</div>
</section>
</body>
</html>
);
}

File diff suppressed because it is too large Load diff

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">Rechtliche Angaben müssen vor produktiver Veröffentlichung durch den Betreiber ergänzt und geprüft werden.</p>
<p className="notice">TODO: Rechtliche Angaben ergänzen</p>
<h2>Anbieterkennzeichnung</h2>
<p>Die vollständige Anbieterkennzeichnung wird durch den Betreiber gepflegt.</p>
<p>TODO: Rechtliche Angaben ergänzen</p>
<h2>Kontakt</h2>
<p>Die rechtlich relevanten Kontaktdaten werden durch den Betreiber gepflegt.</p>
<p>TODO: Rechtliche Angaben ergänzen</p>
<h2>Umsatzsteuer / Registerangaben</h2>
<p>Umsatzsteuer- und Registerangaben werden durch den Betreiber gepflegt, sofern sie erforderlich sind.</p>
<p>TODO: Rechtliche Angaben ergänzen</p>
</div>
</section>
</>

View file

@ -1,41 +1,21 @@
import type { Metadata } from "next";
import PageHero from "@/components/PageHero";
import ContactForm from "@/components/ContactForm";
import { defaultContent } from "@/lib/content/defaults";
import { getContent } from "@/lib/content/service";
import { createContentMetadata } from "../seo";
import { createMetadata } from "../seo";
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 const metadata: Metadata = createMetadata("Kontakt", "Kontakt zu Funktechnik Schubert aufnehmen.", "/kontakt");
export default function ContactPage() {
return (
<>
<PageHero eyebrow={content.eyebrow} title={content.title}>
{content.subtitle}
<PageHero eyebrow="Kontakt" title="Kontakt aufnehmen">
Beschreiben Sie kurz Ihr Anliegen. Für Reparaturen nutzen Sie idealerweise die strukturierte Reparaturannahme.
</PageHero>
<section className="section">
<div className="container split">
<div>
<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>}
<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>
</div>
<div className="panel">
<ContactForm />

View file

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

View file

@ -1,30 +1,27 @@
import type { Metadata } from "next";
import PageHero from "@/components/PageHero";
import ServiceCard from "@/components/ServiceCard";
import { defaultContent } from "@/lib/content/defaults";
import { getContent } from "@/lib/content/service";
import { createContentMetadata } from "../seo";
import { createMetadata } from "../seo";
export const dynamic = "force-dynamic";
export const metadata: Metadata = createMetadata("Leistungen", "Funkgeräte-Service, Diagnose, Abgleich und Dokumentation für Kommunikationstechnik.", "/leistungen");
export async function generateMetadata() {
const 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);
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 default function ServicesPage() {
return (
<>
<PageHero eyebrow={content.eyebrow} title={content.title}>
{content.subtitle}
<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>
<section className="section">
<div className="container grid service-grid">
{services.map((service) => <ServiceCard key={service.title} title={service.title}>{service.description}</ServiceCard>)}
{services.map(([title, text]) => <ServiceCard key={title} title={title}>{text}</ServiceCard>)}
</div>
</section>
</>

View file

@ -1,17 +0,0 @@
import Link from "next/link";
export default function NotFoundPage() {
return (
<section className="error-page">
<div className="error-card">
<p className="eyebrow">404</p>
<h1>Seite nicht gefunden</h1>
<p className="lead">Die angeforderte Seite existiert nicht oder wurde verschoben.</p>
<div className="actions">
<Link className="button" href="/">Zur Startseite</Link>
<Link className="button light" href="/kontakt">Kontakt aufnehmen</Link>
</div>
</div>
</section>
);
}

View file

@ -1,36 +1,26 @@
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"
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)` }}
>
<section className="hero">
<div className="container hero-content">
<p className="eyebrow">{content.eyebrow}</p>
<h1>{content.title}</h1>
<p className="lead">{content.subtitle}</p>
<p className="hero-copy">{content.heroText}</p>
<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>
<div className="hero-badges" aria-label="Technische Schwerpunkte">
{content.badges.map((badge) => <span key={badge}>{badge}</span>)}
<span>CB-Funk</span>
<span>Amateurfunk</span>
<span>Messtechnik</span>
<span>Abgleich</span>
<span>Diagnose</span>
<span>Service Manuals</span>
</div>
<div className="hero-actions">
<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>
<Link className="button" href="/reparatur">Reparatur anfragen</Link>
<Link className="button secondary" href="/leistungen">Leistungen ansehen</Link>
</div>
</div>
</section>
@ -39,11 +29,13 @@ export default async function HomePage() {
<div className="container">
<div className="section-head">
<p className="eyebrow">Leistungsbereiche</p>
<h2>{content.cta.text}</h2>
<p className="lead">{content.intro}</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>
</div>
<div className="grid three">
{content.features.map((feature) => <ServiceCard key={feature.title} title={feature.title}>{feature.text}</ServiceCard>)}
<ServiceCard title="Funkgeräte-Service">Sichtprüfung, Funktionsprüfung und technische Beurteilung für CB-Funk, Amateurfunk, Betriebsfunk sowie ältere und moderne Geräte.</ServiceCard>
<ServiceCard title="Fehlersuche & Reparatur">Systematische Diagnose bei Empfangsproblemen, Sendeproblemen, PLL-Fehlern, Frequenzabweichungen und Audio-/NF-Störungen.</ServiceCard>
<ServiceCard title="Abgleich & Messtechnik">Frequenzabgleich, Hub- und Modulationsprüfung, Sendeleistung, Empfindlichkeit und RF-Messung mit geeigneter Messtechnik.</ServiceCard>
</div>
</div>
</section>
@ -51,17 +43,20 @@ export default async function HomePage() {
<section className="section dark">
<div className="container split">
<div>
<p className="eyebrow">{content.promise.eyebrow}</p>
<h2>{content.promise.title}</h2>
<p className="lead">{content.promise.text}</p>
<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>
<div className="actions">
<Link className="button" href={content.promise.button.href}>{content.promise.button.label}</Link>
<Link className="button" href="/reparatur">Reparaturformular öffnen</Link>
</div>
</div>
<div className="card">
<h3>{content.promise.title}</h3>
<h3>Warum Funktechnik Schubert?</h3>
<ul className="list">
{content.promise.bullets.map((item) => <li key={item}>{item}</li>)}
<li>Technischer Fokus auf Funkgeräte und Kommunikationstechnik</li>
<li>Nachvollziehbare Diagnose mit Blick auf Messwerte und Fehlerbild</li>
<li>Sauberer Umgang mit Service Manuals, Schaltplänen und Abgleichanleitungen</li>
<li>Klare Rückmeldung zu Zustand, Aufwand und sinnvollen nächsten Schritten</li>
</ul>
</div>
</div>

View file

@ -1,32 +1,27 @@
import type { Metadata } from "next";
import PageHero from "@/components/PageHero";
import RepairForm from "@/components/RepairForm";
import { defaultContent } from "@/lib/content/defaults";
import { getContent } from "@/lib/content/service";
import { createContentMetadata } from "../seo";
import { createMetadata } from "../seo";
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 const metadata: Metadata = createMetadata("Reparaturannahme", "Reparaturanfrage für Funkgeräte strukturiert vorbereiten.", "/reparatur");
export default function RepairPage() {
return (
<>
<PageHero eyebrow={content.eyebrow} title={content.title}>
{content.subtitle}
<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>
<section className="section">
<div className="container split">
<div>
<h2>{paragraph?.title || content.cta.text}</h2>
<p className="lead">{paragraph?.text || content.intro}</p>
<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>
<ul className="list">
{content.list.map((item) => <li key={item}>{item}</li>)}
<li>Hersteller, Modell, Geräteart und Seriennummer bereithalten, soweit vorhanden</li>
<li>Fehler möglichst konkret beschreiben: Empfang, Sendung, Modulation, Frequenz, Anzeige oder Versorgung</li>
<li>Zubehör wie Netzteil, Mikrofon, Antennenadapter oder Kabel angeben</li>
<li>Bereits geöffnete Geräte und durchgeführte Arbeiten ehrlich nennen</li>
<li>Fotos können später nach Rückmeldung ergänzt werden</li>
</ul>
</div>
<div className="panel">

View file

@ -1,9 +1,8 @@
import type { Metadata } from "next";
import type { SeoContent } from "@/lib/content/types";
const siteName = "Funktechnik Schubert";
const defaultTitle = "Funktechnik Schubert | Funkgeräte-Service & Messtechnik";
const siteUrl = process.env.NEXT_PUBLIC_SITE_URL ?? "http://127.0.0.1:3010";
const siteUrl = process.env.NEXT_PUBLIC_SITE_URL ?? "http://localhost:3010";
const keywords = [
"Funktechnik Schubert",
"Funkgeräte Reparatur",
@ -60,36 +59,4 @@ export function createMetadata(title: string, description: string, path = "/"):
};
}
export function createContentMetadata(seo: SeoContent, fallbackSeo?: SeoContent): Metadata {
const resolvedSeo = {
...fallbackSeo,
...Object.fromEntries(Object.entries(seo).filter(([, value]) => value.trim() !== "")),
} as SeoContent;
const path = resolvedSeo.canonicalUrl || "/";
const url = new URL(path, siteUrl).toString();
const title = resolvedSeo.metaTitle || siteName;
const description = resolvedSeo.metaDescription || defaultTitle;
return {
...createMetadata(title, description, path),
keywords: resolvedSeo.keywords ? resolvedSeo.keywords.split(",").map((keyword) => keyword.trim()).filter(Boolean) : keywords,
openGraph: {
type: "website",
locale: "de_DE",
url,
siteName,
title: resolvedSeo.openGraphTitle || title,
description: resolvedSeo.openGraphDescription || description,
images: [
{
url: resolvedSeo.socialImage || "/funktechnik_schubert_logo.jpg",
width: 1200,
height: 630,
alt: resolvedSeo.openGraphTitle || title,
},
],
},
};
}
export { defaultTitle, siteName, siteUrl };

View file

@ -1,268 +0,0 @@
import type { Metadata } from "next";
import Link from "next/link";
import { EstimateActions } from "@/components/status/EstimateActions";
import {
fetchOlympusRepairStatus,
type OlympusEstimate,
type OlympusRepairStatus,
} from "@/lib/olympus/status";
type PageProps = {
params: Promise<{
token: string;
}>;
};
export const dynamic = "force-dynamic";
export const metadata: Metadata = {
title: "Reparaturstatus | Funktechnik Schubert",
description: "Öffentlicher Reparaturstatus für Kunden von Funktechnik Schubert.",
robots: {
index: false,
follow: false,
},
};
function formatDate(value: string) {
return new Intl.DateTimeFormat("de-DE", {
dateStyle: "medium",
timeStyle: "short",
}).format(new Date(value));
}
function formatDateOnly(value: string | null) {
if (!value) {
return "Nicht angegeben";
}
return new Intl.DateTimeFormat("de-DE", {
dateStyle: "medium",
}).format(new Date(value));
}
function formatMoney(cents: number, currency: string) {
return new Intl.NumberFormat("de-DE", {
style: "currency",
currency: currency || "EUR",
}).format(cents / 100);
}
function estimateStatusLabel(status: string) {
const labels: Record<string, string> = {
draft: "In Vorbereitung",
sent: "Zur Freigabe",
approved: "Freigegeben",
declined: "Abgelehnt",
expired: "Abgelaufen",
cancelled: "Storniert",
};
return labels[status] ?? status;
}
function estimateStatusText(status: string) {
const texts: Record<string, string> = {
sent: "Bitte prüfen Sie den Kostenvoranschlag. Sie können ihn freigeben, ablehnen oder eine Rückfrage senden.",
approved: "Dieser Kostenvoranschlag wurde freigegeben.",
declined: "Dieser Kostenvoranschlag wurde abgelehnt.",
expired: "Dieser Kostenvoranschlag ist nicht mehr gültig.",
cancelled: "Dieser Kostenvoranschlag wurde storniert.",
};
return texts[status] ?? "Der Kostenvoranschlag ist aktuell nicht zur Bearbeitung freigegeben.";
}
function estimateItemTypeLabel(type: string) {
const labels: Record<string, string> = {
labor: "Arbeitszeit",
part: "Ersatzteil",
material: "Material",
service: "Service",
shipping: "Versand",
other: "Sonstiges",
};
return labels[type] ?? "Position";
}
function EstimateCard({ estimate, token }: { estimate: OlympusEstimate | null | undefined; token: string }) {
if (!estimate) {
return null;
}
return (
<section className="estimate-card" aria-labelledby="estimate-title">
<div className="estimate-header">
<div>
<p className="eyebrow">Kostenvoranschlag</p>
<h2 id="estimate-title">{estimate.title || "Kostenvoranschlag"}</h2>
<p className="status-muted">Nummer {estimate.estimate_number}</p>
</div>
<span className="estimate-status">{estimateStatusLabel(estimate.status)}</span>
</div>
{estimate.customer_message && <p className="estimate-message">{estimate.customer_message}</p>}
<dl className="estimate-meta">
<div>
<dt>Gültig bis</dt>
<dd>{formatDateOnly(estimate.valid_until)}</dd>
</div>
<div>
<dt>Status</dt>
<dd>{estimateStatusLabel(estimate.status)}</dd>
</div>
</dl>
<div className="estimate-items" aria-label="Positionen des Kostenvoranschlags">
{estimate.items.map((item, index) => (
<div className="estimate-item" key={`${item.title}-${index}`}>
<div>
<span className="estimate-item-type">{estimateItemTypeLabel(item.item_type)}</span>
<h3>{item.title}</h3>
{item.description && <p>{item.description}</p>}
</div>
<div className="estimate-item-price">
<span>
{item.quantity} {item.unit}
</span>
<strong>{formatMoney(item.total_cents, estimate.currency)}</strong>
</div>
</div>
))}
</div>
<dl className="estimate-totals">
<div>
<dt>Zwischensumme</dt>
<dd>{formatMoney(estimate.subtotal_cents, estimate.currency)}</dd>
</div>
<div>
<dt>Mehrwertsteuer</dt>
<dd>{formatMoney(estimate.tax_cents, estimate.currency)}</dd>
</div>
<div className="estimate-total-highlight">
<dt>Gesamt</dt>
<dd>{formatMoney(estimate.total_cents, estimate.currency)}</dd>
</div>
</dl>
<p className="estimate-status-text">{estimateStatusText(estimate.status)}</p>
{estimate.status === "sent" && <EstimateActions token={token} />}
</section>
);
}
function StatusError({ title, text }: { title: string; text: string }) {
return (
<section className="status-page">
<div className="container status-page-inner">
<div className="status-card status-card-narrow">
<p className="eyebrow">Reparaturstatus</p>
<h1>{title}</h1>
<p className="lead">{text}</p>
<div className="actions">
<Link className="button" href="/reparatur">Reparatur anfragen</Link>
<Link className="button light" href="/kontakt">Kontakt aufnehmen</Link>
</div>
</div>
</div>
</section>
);
}
function StatusTimeline({ status }: { status: OlympusRepairStatus }) {
if (status.status_history_public.length === 0) {
return <p className="status-muted">Noch keine Statushistorie vorhanden.</p>;
}
const latestIndex = status.status_history_public.length - 1;
return (
<ol className="public-status-timeline">
{status.status_history_public.map((item, index) => (
<li key={`${item.status}-${item.created_at}-${index}`} className={index === latestIndex ? "is-current" : ""}>
<span className="timeline-dot" aria-hidden="true" />
<div className="timeline-content">
<div>
<p className="timeline-title">{item.status_label}</p>
{index === latestIndex && <span className="status-pill">Aktueller Status</span>}
</div>
<time dateTime={item.created_at}>{formatDate(item.created_at)}</time>
</div>
</li>
))}
</ol>
);
}
export default async function RepairStatusPage({ params }: PageProps) {
const { token } = await params;
const result = await fetchOlympusRepairStatus(token);
if (!result.ok && result.reason === "invalid") {
return (
<StatusError
title="Statuslink nicht aktiv"
text="Dieser Statuslink ist ungültig oder nicht mehr aktiv."
/>
);
}
if (!result.ok) {
return (
<StatusError
title="Status momentan nicht abrufbar"
text="Der Reparaturstatus ist momentan nicht abrufbar. Bitte versuchen Sie es später erneut oder kontaktieren Sie uns direkt."
/>
);
}
const status = result.status;
const device = `${status.device_manufacturer} ${status.device_model}`.trim();
return (
<section className="status-page">
<div className="container status-page-inner">
<div className="status-card">
<p className="eyebrow">Reparaturstatus</p>
<div className="status-title-row">
<div>
<h1>Reparaturstatus</h1>
<p className="lead">Hier sehen Sie den aktuellen Stand Ihrer Reparatur.</p>
</div>
<span className="status-badge">{status.public_status_label}</span>
</div>
<dl className="status-summary">
<div>
<dt>Reparaturnummer</dt>
<dd>{status.repair_number}</dd>
</div>
<div>
<dt>Gerät</dt>
<dd>{device || "Nicht angegeben"}</dd>
</div>
<div>
<dt>Aktueller Status</dt>
<dd>{status.public_status_label}</dd>
</div>
<div>
<dt>Letzte Aktualisierung</dt>
<dd>{formatDate(status.updated_at)}</dd>
</div>
</dl>
<EstimateCard estimate={status.estimate} token={token} />
<div className="status-timeline-section">
<h2>Statusverlauf</h2>
<StatusTimeline status={status} />
</div>
</div>
</div>
</section>
);
}

View file

@ -1,35 +1,25 @@
import type { Metadata } from "next";
import PageHero from "@/components/PageHero";
import { defaultContent } from "@/lib/content/defaults";
import { getContent } from "@/lib/content/service";
import { createContentMetadata } from "../seo";
import { createMetadata } from "../seo";
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 const metadata: Metadata = createMetadata("Über uns", "Funktechnik Schubert steht für technischen Service und klare Kommunikation.", "/ueber-uns");
export default function AboutPage() {
return (
<>
<PageHero eyebrow={content.eyebrow} title={content.title}>
{content.subtitle}
<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>
<section className="section">
<div className="container grid two">
{blocks.map((block) => (
<article key={block.title} className="card">
<h2>{block.title}</h2>
<p>{block.text}</p>
</article>
))}
<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>
</article>
</div>
</section>
</>

View file

@ -1,9 +1,8 @@
"use client";
import { useRef, useState, type FormEvent } from "react";
import { 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);
@ -15,11 +14,9 @@ export default function ContactForm() {
const form = new FormData(event.currentTarget);
const name = String(form.get("name") ?? "").trim();
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 || !privacyAccepted) {
if (!name || !email.includes("@") || text.length < 10 || form.get("privacy") !== "on") {
setError("Bitte füllen Sie alle Pflichtfelder aus und bestätigen Sie die Datenschutz-Hinweise.");
return;
}
@ -29,17 +26,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.");
formRef.current?.reset();
event.currentTarget.reset();
setMessage(result.message ?? "Ihre Nachricht wurde vorbereitet.");
} catch (err) {
setError(err instanceof Error ? err.message : "Nachricht konnte nicht gesendet werden. Bitte versuchen Sie es später erneut.");
setError(err instanceof Error ? err.message : "Nachricht konnte nicht gesendet werden.");
} finally {
setPending(false);
}
}
return (
<form ref={formRef} className="form" onSubmit={submit} noValidate>
<form className="form" onSubmit={submit} noValidate>
<div className="field">
<label htmlFor="name">Name</label>
<input id="name" name="name" />
@ -48,20 +45,12 @@ export default function ContactForm() {
<label htmlFor="email">E-Mail</label>
<input id="email" name="email" type="email" />
</div>
<div className="field">
<label htmlFor="phone">Telefon</label>
<input id="phone" name="phone" />
</div>
<div className="field">
<label htmlFor="subject">Betreff</label>
<input id="subject" name="subject" />
</div>
<div className="field">
<label htmlFor="message">Nachricht</label>
<textarea id="message" name="message" />
</div>
<label className="checkbox">
<input type="checkbox" name="privacyAccepted" />
<input type="checkbox" name="privacy" />
<span>Ich stimme zu, dass meine Angaben zur Bearbeitung der Kontaktanfrage verarbeitet werden.</span>
</label>
{error && <p className="error">{error}</p>}

View file

@ -1,82 +1,32 @@
"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={settings.logoUrl || fallbackSettings.logoUrl}
alt={settings.siteName}
src="/funktechnik_schubert_logo.jpg"
alt="Funktechnik Schubert"
width={1672}
height={941}
className="footer-logo"
/>
<p>{settings.footerShortText}</p>
<p className="copyright">{settings.copyright} · v{appVersion}</p>
<p>Funktechnik Schubert Service und technische Unterstützung für Funkgeräte, Messtechnik und Kommunikationstechnik.</p>
<p className="copyright">© Funktechnik Schubert</p>
</div>
<div>
<h3>Website</h3>
{settings.footerLinks.map((link) => <p key={`${link.href}-${link.label}`}><Link href={link.href}>{link.label}</Link></p>)}
<p><Link href="/leistungen">Leistungen</Link></p>
<p><Link href="/funkgeraete-service">Funkgeräte-Service</Link></p>
<p><Link href="/reparatur">Reparatur</Link></p>
<p><Link href="/kontakt">Kontakt</Link></p>
</div>
<div>
<h3>Rechtliches</h3>
{settings.legalLinks.map((link) => <p key={`${link.href}-${link.label}`}><Link href={link.href}>{link.label}</Link></p>)}
<p><Link href="/impressum">Impressum</Link></p>
<p><Link href="/datenschutz">Datenschutz</Link></p>
</div>
</div>
</footer>

View file

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

View file

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

View file

@ -1,21 +0,0 @@
"use client";
import { usePathname } from "next/navigation";
import type { ReactNode } from "react";
import Header from "@/components/Header";
import Footer from "@/components/Footer";
export default function SiteFrame({ children }: { children: ReactNode }) {
const pathname = usePathname();
const isAdmin = pathname.startsWith("/admin");
if (isAdmin) return <>{children}</>;
return (
<>
<Header />
<main>{children}</main>
<Footer />
</>
);
}

View file

@ -1,55 +0,0 @@
"use client";
import Image from "next/image";
import { useRouter } from "next/navigation";
import { useState, type FormEvent } from "react";
export default function AdminLoginForm({ nextPath = "/admin", adminConfigured = true }: { nextPath?: string; adminConfigured?: boolean }) {
const router = useRouter();
const [error, setError] = useState("");
const [pending, setPending] = useState(false);
async function submit(event: FormEvent<HTMLFormElement>) {
event.preventDefault();
setError("");
setPending(true);
try {
const response = await fetch("/api/admin/login", {
method: "POST",
body: new FormData(event.currentTarget),
});
const result = await response.json() as { message?: string };
if (!response.ok) throw new Error(result.message ?? "Anmeldung fehlgeschlagen.");
router.push(nextPath);
router.refresh();
} catch (err) {
setError(err instanceof Error ? err.message : "Anmeldung fehlgeschlagen.");
} finally {
setPending(false);
}
}
return (
<main className="admin-login-page">
<form className="admin-login-card" onSubmit={submit}>
<Image src="/funktechnik_schubert_logo.jpg" alt="Funktechnik Schubert" width={1672} height={941} priority />
<h1>Administration Login</h1>
{!adminConfigured && (
<p className="error">
Admin-Zugang ist nicht konfiguriert. Bitte ADMIN_EMAIL, ADMIN_PASSWORD und ADMIN_SESSION_SECRET in der Umgebung setzen.
</p>
)}
<label>
E-Mail
<input name="email" type="email" autoComplete="username" required />
</label>
<label>
Passwort
<input name="password" type="password" autoComplete="current-password" required />
</label>
{error && <p className="error">{error}</p>}
<button className="button" type="submit" disabled={pending || !adminConfigured}>{pending ? "Anmeldung..." : "Anmelden"}</button>
</form>
</main>
);
}

View file

@ -1,50 +0,0 @@
"use client";
import Image from "next/image";
import Link from "next/link";
import { usePathname, useRouter } from "next/navigation";
import type { ReactNode } from "react";
import { appVersion } from "@/lib/runtime/version";
const navItems = [
["Dashboard", "/admin"],
["Kontaktanfragen", "/admin/kontaktanfragen"],
["Reparaturanfragen", "/admin/reparaturanfragen"],
["Website-Inhalte", "/admin/website"],
["Medien", "/admin/medien"],
["Einstellungen", "/admin/einstellungen"],
["SMTP", "/admin/smtp"],
["SEO", "/admin/seo"],
["System", "/admin/system"],
] as const;
export default function AdminShell({ children }: { children: ReactNode }) {
const pathname = usePathname();
const router = useRouter();
async function logout() {
await fetch("/api/admin/logout", { method: "POST" });
router.push("/admin/login");
router.refresh();
}
return (
<div className="admin-shell">
<aside className="admin-sidebar">
<Link className="admin-logo" href="/admin">
<Image src="/funktechnik_schubert_logo.jpg" alt="Funktechnik Schubert" width={1672} height={941} />
</Link>
<nav className="admin-nav" aria-label="Administration">
{navItems.map(([label, href]) => (
<Link key={href} href={href} aria-current={pathname === href ? "page" : undefined}>
{label}
</Link>
))}
</nav>
<p className="admin-version">v{appVersion}</p>
<button className="admin-logout" type="button" onClick={logout}>Abmelden</button>
</aside>
<div className="admin-main">{children}</div>
</div>
);
}

View file

@ -1,387 +0,0 @@
"use client";
/* eslint-disable @next/next/no-img-element */
import { useMemo, useState } from "react";
import type { MediaFile } from "@/lib/admin/media";
import type { ContentDocuments, ContentKey, LinkContent, PageContent, SeoContent, ServiceContent, TextBlockContent } from "@/lib/content/types";
type Viewport = "desktop" | "tablet" | "mobile";
type ContentTabId = "home" | "services" | "radio-service" | "about" | "contact" | "footer" | "seo";
type SeoPageKey = Exclude<ContentKey, "settings">;
const tabs: Array<{ id: ContentTabId; label: string }> = [
{ id: "home", label: "Startseite" },
{ id: "services", label: "Leistungen" },
{ id: "radio-service", label: "Funkgeräte-Service" },
{ id: "about", label: "Über uns" },
{ id: "contact", label: "Kontakt" },
{ id: "footer", label: "Footer" },
{ id: "seo", label: "SEO" },
];
const seoPages: Array<{ key: SeoPageKey; label: string }> = [
{ key: "home", label: "Startseite" },
{ key: "services", label: "Leistungen" },
{ key: "radio-service", label: "Funkgeräte-Service" },
{ key: "repair", label: "Reparatur" },
{ key: "about", label: "Über uns" },
{ key: "contact", label: "Kontakt" },
];
type Props = {
initialContent: ContentDocuments;
mediaFiles: MediaFile[];
};
type ApiResponse<K extends ContentKey> = {
message?: string;
content?: ContentDocuments[K];
};
function contentKeyForTab(tab: ContentTabId, seoPage: SeoPageKey): ContentKey {
if (tab === "footer") return "settings";
if (tab === "seo") return seoPage;
return tab;
}
function linesToText(lines: string[]) {
return lines.join("\n");
}
function textToLines(value: string) {
return value.split("\n").map((line) => line.trim()).filter(Boolean);
}
function updateLink(link: LinkContent, field: keyof LinkContent, value: string): LinkContent {
return { ...link, [field]: value };
}
function TextInput({ label, value, onChange }: { label: string; value: string; onChange: (value: string) => void }) {
return (
<label>
{label}
<input value={value} onChange={(event) => onChange(event.target.value)} />
</label>
);
}
function TextArea({ label, value, onChange, rows = 4 }: { label: string; value: string; rows?: number; onChange: (value: string) => void }) {
return (
<label>
{label}
<textarea rows={rows} value={value} onChange={(event) => onChange(event.target.value)} />
</label>
);
}
function SectionTitle({ children }: { children: string }) {
return <h3 className="content-editor-heading">{children}</h3>;
}
function ImagePicker({ label, value, mediaFiles, onChange, fallback = "" }: { label: string; value: string; mediaFiles: MediaFile[]; fallback?: string; onChange: (value: string) => void }) {
const images = mediaFiles.filter((file) => file.isImage);
const preview = value || fallback;
return (
<div className="content-image-picker">
<label>
{label}
<select value={value} onChange={(event) => onChange(event.target.value)}>
<option value="">Standard verwenden</option>
<option value="/funktechnik_schubert_logo.jpg">Standard-Logo</option>
<option value="/workbench-signal.svg">Standard-Hintergrund</option>
{images.map((file) => <option key={file.name} value={file.url}>{file.name}</option>)}
</select>
</label>
{preview && (
<div className="content-image-preview">
<img src={preview} alt="" />
<button className="button light" type="button" onClick={() => onChange("")}>Bild entfernen</button>
</div>
)}
</div>
);
}
function SaveActions({ pending, onSave }: { pending: boolean; onSave: () => void }) {
return (
<div className="content-save-actions">
<button className="button" type="button" onClick={onSave} disabled={pending}>{pending ? "Speichert..." : "Speichern"}</button>
</div>
);
}
function BlocksEditor({ blocks, onChange }: { blocks: TextBlockContent[]; onChange: (blocks: TextBlockContent[]) => void }) {
function update(index: number, field: keyof TextBlockContent, value: string) {
onChange(blocks.map((block, current) => current === index ? { ...block, [field]: value } : block));
}
return (
<div className="content-repeaters">
{blocks.map((block, index) => (
<div key={index} className="content-repeater">
<TextInput label="Titel" value={block.title} onChange={(value) => update(index, "title", value)} />
<TextArea label="Text" value={block.text} onChange={(value) => update(index, "text", value)} />
<button className="button light" type="button" onClick={() => onChange(blocks.filter((_, current) => current !== index))}>Entfernen</button>
</div>
))}
<button className="button light" type="button" onClick={() => onChange([...blocks, { title: "", text: "" }])}>Block hinzufügen</button>
</div>
);
}
function PageFields({ page, onChange }: { page: PageContent; onChange: (page: PageContent) => void }) {
return (
<>
<SectionTitle>Hero</SectionTitle>
<div className="content-field-grid">
<TextInput label="Titel" value={page.title} onChange={(value) => onChange({ ...page, title: value })} />
<TextInput label="Untertitel" value={page.subtitle} onChange={(value) => onChange({ ...page, subtitle: value })} />
<TextInput label="Eyebrow" value={page.eyebrow} onChange={(value) => onChange({ ...page, eyebrow: value })} />
<TextArea label="Hero Text" value={page.heroText} onChange={(value) => onChange({ ...page, heroText: value })} />
</div>
<SectionTitle>Inhalt</SectionTitle>
<TextArea label="Einleitung" value={page.intro} onChange={(value) => onChange({ ...page, intro: value })} />
<TextArea label="Listenpunkte, eine Zeile pro Eintrag" value={linesToText(page.list)} onChange={(value) => onChange({ ...page, list: textToLines(value) })} />
<BlocksEditor blocks={page.paragraphs} onChange={(paragraphs) => onChange({ ...page, paragraphs })} />
<SectionTitle>Call-To-Action</SectionTitle>
<div className="content-field-grid">
<TextInput label="CTA Text" value={page.cta.text} onChange={(value) => onChange({ ...page, cta: { ...page.cta, text: value } })} />
<TextInput label="Button 1 Text" value={page.cta.primary.label} onChange={(value) => onChange({ ...page, cta: { ...page.cta, primary: updateLink(page.cta.primary, "label", value) } })} />
<TextInput label="Button 1 Link" value={page.cta.primary.href} onChange={(value) => onChange({ ...page, cta: { ...page.cta, primary: updateLink(page.cta.primary, "href", value) } })} />
<TextInput label="Button 2 Text" value={page.cta.secondary.label} onChange={(value) => onChange({ ...page, cta: { ...page.cta, secondary: updateLink(page.cta.secondary, "label", value) } })} />
<TextInput label="Button 2 Link" value={page.cta.secondary.href} onChange={(value) => onChange({ ...page, cta: { ...page.cta, secondary: updateLink(page.cta.secondary, "href", value) } })} />
</div>
</>
);
}
function SeoFields({ seo, mediaFiles, onChange }: { seo: SeoContent; mediaFiles: MediaFile[]; onChange: (seo: SeoContent) => void }) {
return (
<div className="content-field-grid">
<TextInput label="Meta Title" value={seo.metaTitle} onChange={(value) => onChange({ ...seo, metaTitle: value })} />
<TextInput label="Meta Description" value={seo.metaDescription} onChange={(value) => onChange({ ...seo, metaDescription: value })} />
<TextInput label="Keywords" value={seo.keywords} onChange={(value) => onChange({ ...seo, keywords: value })} />
<TextInput label="OpenGraph Title" value={seo.openGraphTitle} onChange={(value) => onChange({ ...seo, openGraphTitle: value })} />
<TextInput label="OpenGraph Description" value={seo.openGraphDescription} onChange={(value) => onChange({ ...seo, openGraphDescription: value })} />
<ImagePicker label="OpenGraph Image" value={seo.socialImage} mediaFiles={mediaFiles} fallback="/funktechnik_schubert_logo.jpg" onChange={(value) => onChange({ ...seo, socialImage: value })} />
<TextInput label="Canonical URL" value={seo.canonicalUrl} onChange={(value) => onChange({ ...seo, canonicalUrl: value })} />
</div>
);
}
function ServicesEditor({ services, onChange }: { services: ServiceContent[]; onChange: (services: ServiceContent[]) => void }) {
function update(index: number, field: keyof ServiceContent, value: string | number | boolean) {
onChange(services.map((service, current) => current === index ? { ...service, [field]: value } : service));
}
return (
<div className="content-repeaters">
{services.map((service, index) => (
<div key={index} className="content-repeater">
<div className="content-field-grid">
<TextInput label="Titel" value={service.title} onChange={(value) => update(index, "title", value)} />
<TextInput label="Icon" value={service.icon} onChange={(value) => update(index, "icon", value)} />
<label>
Sortierung
<input type="number" value={service.sortOrder} onChange={(event) => update(index, "sortOrder", Number(event.target.value))} />
</label>
<label className="admin-checkbox">
<input type="checkbox" checked={service.visible} onChange={(event) => update(index, "visible", event.target.checked)} />
Einblenden
</label>
</div>
<TextArea label="Beschreibung" value={service.description} onChange={(value) => update(index, "description", value)} />
<button className="button light" type="button" onClick={() => onChange(services.filter((_, current) => current !== index))}>Leistung entfernen</button>
</div>
))}
<button className="button light" type="button" onClick={() => onChange([...services, { title: "", description: "", icon: "radio", sortOrder: services.length * 10 + 10, visible: true }])}>Leistung hinzufügen</button>
</div>
);
}
export default function ContentManager({ initialContent, mediaFiles }: Props) {
const [content, setContent] = useState(initialContent);
const [activeTab, setActiveTab] = useState<ContentTabId>("home");
const [seoPage, setSeoPage] = useState<SeoPageKey>("home");
const [viewport, setViewport] = useState<Viewport>("desktop");
const [message, setMessage] = useState("");
const [error, setError] = useState("");
const [pending, setPending] = useState(false);
const active = contentKeyForTab(activeTab, seoPage);
const current = content[active];
const activeLabel = useMemo(() => tabs.find((tab) => tab.id === activeTab)?.label ?? "Startseite", [activeTab]);
const pagePreview = "title" in current ? current : content.home;
function update<K extends ContentKey>(key: K, value: ContentDocuments[K]) {
setContent((previous) => ({ ...previous, [key]: value }));
}
async function save() {
setPending(true);
setMessage("");
setError("");
try {
const response = await fetch("/api/admin/content", {
method: "PUT",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ key: active, content: current }),
});
const result = await response.json() as ApiResponse<typeof active>;
if (!response.ok || !result.content) throw new Error("Inhalt konnte nicht gespeichert werden.");
update(active, result.content);
setMessage("Inhalt wurde gespeichert.");
} catch {
setError("Der Inhalt konnte nicht gespeichert werden. Bitte prüfen Sie die Eingaben und versuchen Sie es erneut.");
} finally {
setPending(false);
}
}
const heroBackground = content.home.heroImage || "/workbench-signal.svg";
return (
<div className="content-manager">
{message && <p className="success admin-message">{message}</p>}
{error && <p className="error admin-message">{error}</p>}
<section className="admin-card content-editor-layout">
<aside className="content-tabs" aria-label="Website-Inhalte">
{tabs.map((tab) => (
<button key={tab.id} type="button" aria-current={activeTab === tab.id ? "page" : undefined} onClick={() => setActiveTab(tab.id)}>
{tab.label}
</button>
))}
</aside>
<div className="content-editor">
<div className="content-editor-top">
<div>
<p className="eyebrow">Editor</p>
<h2>{activeLabel}</h2>
</div>
<SaveActions pending={pending} onSave={save} />
</div>
{activeTab === "home" && (
<>
<PageFields page={content.home} onChange={(value) => update("home", { ...content.home, ...value })} />
<SectionTitle>Medien</SectionTitle>
<ImagePicker label="Hero-Bild" value={content.home.heroImage} mediaFiles={mediaFiles} fallback="/workbench-signal.svg" onChange={(value) => update("home", { ...content.home, heroImage: value })} />
<SectionTitle>Homepage Builder</SectionTitle>
<TextArea label="Badges, eine Zeile pro Eintrag" value={linesToText(content.home.badges)} onChange={(value) => update("home", { ...content.home, badges: textToLines(value) })} />
<h4>3 Featureboxen</h4>
<BlocksEditor blocks={content.home.features} onChange={(features) => update("home", { ...content.home, features })} />
<h4>3 Leistungsboxen</h4>
<BlocksEditor blocks={content.home.services} onChange={(services) => update("home", { ...content.home, services })} />
<h4>Kundenversprechen</h4>
<TextInput label="Headline" value={content.home.promise.title} onChange={(value) => update("home", { ...content.home, promise: { ...content.home.promise, title: value } })} />
<TextArea label="Text" value={content.home.promise.text} onChange={(value) => update("home", { ...content.home, promise: { ...content.home.promise, text: value } })} />
<TextArea label="Punkte, eine Zeile pro Eintrag" value={linesToText(content.home.promise.bullets)} onChange={(value) => update("home", { ...content.home, promise: { ...content.home.promise, bullets: textToLines(value) } })} />
<TextArea label="Footer-Text" value={content.home.footerText} onChange={(value) => update("home", { ...content.home, footerText: value })} />
</>
)}
{activeTab === "services" && (
<>
<PageFields page={content.services} onChange={(value) => update("services", { ...content.services, ...value })} />
<SectionTitle>Leistungen</SectionTitle>
<ServicesEditor services={content.services.services} onChange={(services) => update("services", { ...content.services, services })} />
</>
)}
{activeTab === "radio-service" && (
<>
<PageFields page={content["radio-service"]} onChange={(value) => update("radio-service", { ...content["radio-service"], ...value })} />
<SectionTitle>Funkgeräte-Service</SectionTitle>
<TextArea label="Marken" value={linesToText(content["radio-service"].brands)} onChange={(value) => update("radio-service", { ...content["radio-service"], brands: textToLines(value) })} />
<TextArea label="Fehlerbilder" value={linesToText(content["radio-service"].symptoms)} onChange={(value) => update("radio-service", { ...content["radio-service"], symptoms: textToLines(value) })} />
<TextArea label="Ablauf" value={linesToText(content["radio-service"].workflow)} onChange={(value) => update("radio-service", { ...content["radio-service"], workflow: textToLines(value) })} />
<TextArea label="Messmöglichkeiten" value={linesToText(content["radio-service"].measurements)} onChange={(value) => update("radio-service", { ...content["radio-service"], measurements: textToLines(value) })} />
<TextArea label="Abgleich" value={content["radio-service"].alignment} onChange={(value) => update("radio-service", { ...content["radio-service"], alignment: value })} />
<TextArea label="Reparatur" value={content["radio-service"].repair} onChange={(value) => update("radio-service", { ...content["radio-service"], repair: value })} />
</>
)}
{activeTab === "about" && (
<>
<PageFields page={content.about} onChange={(value) => update("about", { ...content.about, ...value })} />
<SectionTitle>Über uns</SectionTitle>
<TextArea label="Firmenbeschreibung" value={content.about.companyDescription} onChange={(value) => update("about", { ...content.about, companyDescription: value })} />
<TextArea label="Werkstattbeschreibung" value={content.about.workshopDescription} onChange={(value) => update("about", { ...content.about, workshopDescription: value })} />
<TextArea label="Philosophie" value={content.about.philosophy} onChange={(value) => update("about", { ...content.about, philosophy: value })} />
</>
)}
{activeTab === "contact" && (
<>
<PageFields page={content.contact} onChange={(value) => update("contact", { ...content.contact, ...value })} />
<SectionTitle>Kontaktinformationen</SectionTitle>
<div className="content-field-grid">
<TextInput label="Telefon" value={content.contact.phone} onChange={(value) => update("contact", { ...content.contact, phone: value })} />
<TextInput label="E-Mail" value={content.contact.email} onChange={(value) => update("contact", { ...content.contact, email: value })} />
<TextInput label="Adresse" value={content.contact.address} onChange={(value) => update("contact", { ...content.contact, address: value })} />
<TextInput label="Öffnungszeiten" value={content.contact.openingHours} onChange={(value) => update("contact", { ...content.contact, openingHours: value })} />
<TextInput label="Google Maps Link" value={content.contact.googleMapsLink} onChange={(value) => update("contact", { ...content.contact, googleMapsLink: value })} />
</div>
</>
)}
{activeTab === "footer" && (
<>
<SectionTitle>Footer und Header</SectionTitle>
<div className="content-field-grid">
<TextInput label="Site Name" value={content.settings.siteName} onChange={(value) => update("settings", { ...content.settings, siteName: value })} />
<ImagePicker label="Logo" value={content.settings.logoUrl} mediaFiles={mediaFiles} fallback="/funktechnik_schubert_logo.jpg" onChange={(value) => update("settings", { ...content.settings, logoUrl: value })} />
<TextInput label="Copyright" value={content.settings.copyright} onChange={(value) => update("settings", { ...content.settings, copyright: value })} />
<TextInput label="Header CTA Text" value={content.settings.headerCta.label} onChange={(value) => update("settings", { ...content.settings, headerCta: updateLink(content.settings.headerCta, "label", value) })} />
<TextInput label="Header CTA Link" value={content.settings.headerCta.href} onChange={(value) => update("settings", { ...content.settings, headerCta: updateLink(content.settings.headerCta, "href", value) })} />
</div>
<TextArea label="Footer-Kurztext" value={content.settings.footerShortText} onChange={(value) => update("settings", { ...content.settings, footerShortText: value })} />
</>
)}
{activeTab === "seo" && (
<>
<SectionTitle>SEO pro Seite</SectionTitle>
<label>
Seite
<select value={seoPage} onChange={(event) => setSeoPage(event.target.value as SeoPageKey)}>
{seoPages.map((page) => <option key={page.key} value={page.key}>{page.label}</option>)}
</select>
</label>
<SeoFields seo={content[seoPage].seo} mediaFiles={mediaFiles} onChange={(seo) => update(seoPage, { ...content[seoPage], seo })} />
</>
)}
<SaveActions pending={pending} onSave={save} />
</div>
</section>
<section className="admin-card content-preview-card">
<div className="content-preview-toolbar">
<div>
<p className="eyebrow">Vorschau</p>
<h2>Live Preview</h2>
</div>
<div>
{(["desktop", "tablet", "mobile"] as const).map((item) => (
<button key={item} type="button" aria-current={viewport === item ? "true" : undefined} onClick={() => setViewport(item)}>{item}</button>
))}
</div>
</div>
<div className={`content-preview ${viewport}`} style={{ backgroundImage: `linear-gradient(90deg, rgba(6, 24, 52, 0.95), rgba(8, 42, 96, 0.72)), url("${activeTab === "home" ? heroBackground : pagePreview.heroImage || "/workbench-signal.svg"}")` }}>
<p className="eyebrow">{activeTab === "seo" ? content[seoPage].seo.metaTitle || pagePreview.eyebrow : pagePreview.eyebrow}</p>
<h1>{pagePreview.title}</h1>
<p className="lead">{pagePreview.subtitle}</p>
<p>{pagePreview.heroText}</p>
<div className="hero-actions">
<span className="button">{pagePreview.cta.primary.label}</span>
<span className="button light">{pagePreview.cta.secondary.label}</span>
</div>
</div>
</section>
</div>
);
}

View file

@ -1,221 +0,0 @@
"use client";
/* eslint-disable @next/next/no-img-element */
import { useEffect, useMemo, useRef, useState, type ChangeEvent } from "react";
import type { MediaFile } from "@/lib/admin/media";
type SortKey = "date" | "name" | "size";
type FilterKey = "all" | "images" | "pdf";
type ApiResponse = {
message?: string;
files?: MediaFile[];
};
function formatBytes(size: number) {
if (size < 1024) return `${size} B`;
if (size < 1024 * 1024) return `${(size / 1024).toFixed(1)} KB`;
return `${(size / 1024 / 1024).toFixed(1)} MB`;
}
function sortFiles(files: MediaFile[], sort: SortKey) {
return [...files].sort((left, right) => {
if (sort === "name") return left.name.localeCompare(right.name);
if (sort === "size") return right.size - left.size;
return right.uploadedAt.localeCompare(left.uploadedAt);
});
}
export default function MediaManager({ initialFiles }: { initialFiles: MediaFile[] }) {
const fileInputRef = useRef<HTMLInputElement>(null);
const [files, setFiles] = useState(initialFiles);
const [filter, setFilter] = useState<FilterKey>("all");
const [sort, setSort] = useState<SortKey>("date");
const [query, setQuery] = useState("");
const [toast, setToast] = useState("");
const [error, setError] = useState("");
const [pending, setPending] = useState(false);
const [preview, setPreview] = useState<MediaFile | null>(null);
const [zoom, setZoom] = useState(1);
useEffect(() => {
function closeOnEscape(event: KeyboardEvent) {
if (event.key === "Escape") {
setPreview(null);
setZoom(1);
}
}
window.addEventListener("keydown", closeOnEscape);
return () => window.removeEventListener("keydown", closeOnEscape);
}, []);
const visibleFiles = useMemo(() => {
const normalizedQuery = query.trim().toLowerCase();
const filtered = files.filter((file) => {
if (filter === "images" && !file.isImage) return false;
if (filter === "pdf" && !file.isPdf) return false;
if (!normalizedQuery) return true;
return file.name.toLowerCase().includes(normalizedQuery) || file.extension.toLowerCase().includes(normalizedQuery);
});
return sortFiles(filtered, sort);
}, [files, filter, query, sort]);
async function upload(event: ChangeEvent<HTMLInputElement>) {
const file = event.currentTarget.files?.[0];
if (!file) return;
setPending(true);
setToast("");
setError("");
const form = new FormData();
form.set("file", file);
try {
const response = await fetch("/api/admin/media", { method: "POST", body: form });
const result = await response.json() as ApiResponse;
if (!response.ok) throw new Error(result.message ?? "Upload fehlgeschlagen.");
if (result.files) setFiles(result.files);
setToast(result.message ?? "Datei erfolgreich hochgeladen.");
if (fileInputRef.current) fileInputRef.current.value = "";
} catch (err) {
setError(err instanceof Error ? err.message : "Upload fehlgeschlagen.");
} finally {
setPending(false);
}
}
async function refresh() {
const response = await fetch("/api/admin/media", { method: "GET" });
const result = await response.json() as ApiResponse;
if (response.ok && result.files) setFiles(result.files);
}
async function remove(file: MediaFile) {
if (!window.confirm(`Datei wirklich löschen?\n\n${file.name}`)) return;
setToast("");
setError("");
try {
const response = await fetch("/api/admin/media", {
method: "DELETE",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ name: file.name }),
});
const result = await response.json() as ApiResponse;
if (!response.ok) throw new Error(result.message ?? "Datei konnte nicht gelöscht werden.");
if (result.files) setFiles(result.files);
setToast(result.message ?? "Datei gelöscht.");
} catch (err) {
setError(err instanceof Error ? err.message : "Datei konnte nicht gelöscht werden.");
}
}
async function copyUrl(file: MediaFile) {
try {
await navigator.clipboard.writeText(file.url);
setToast("Relative URL wurde kopiert.");
setError("");
} catch {
setError("URL konnte nicht kopiert werden.");
}
}
function openPreview(file: MediaFile) {
if (file.isImage) {
setPreview(file);
setZoom(1);
return;
}
window.open(file.url, "_blank", "noopener,noreferrer");
}
return (
<>
{toast && <p className="success admin-message">{toast}</p>}
{error && <p className="error admin-message">{error}</p>}
<section className="admin-card media-toolbar">
<label>
Datei hochladen
<input ref={fileInputRef} type="file" accept="image/jpeg,image/png,image/webp,application/pdf" onChange={upload} disabled={pending} />
</label>
<label>
Suche
<input value={query} onChange={(event) => setQuery(event.target.value)} placeholder="Dateiname oder Typ" />
</label>
<label>
Filter
<select value={filter} onChange={(event) => setFilter(event.target.value as FilterKey)}>
<option value="all">Alle</option>
<option value="images">Bilder</option>
<option value="pdf">PDF</option>
</select>
</label>
<label>
Sortierung
<select value={sort} onChange={(event) => setSort(event.target.value as SortKey)}>
<option value="date">Datum</option>
<option value="name">Name</option>
<option value="size">Größe</option>
</select>
</label>
<button className="button light" type="button" onClick={refresh}>Aktualisieren</button>
</section>
<section className="admin-card">
<h2>Uploads</h2>
{visibleFiles.length === 0 ? (
<div className="media-empty">
<h3>Noch keine Medien vorhanden</h3>
<p>Hochgeladene Bilder und PDF-Dateien erscheinen hier mit Vorschau, Metadaten und Aktionen.</p>
</div>
) : (
<div className="media-card-grid">
{visibleFiles.map((file) => (
<article key={file.name} className="media-card">
<button className="media-card-preview" type="button" onClick={() => openPreview(file)}>
{file.isImage ? <img src={file.url} alt={file.name} /> : <span className="pdf-icon">PDF</span>}
</button>
<div className="media-card-body">
<h3 title={file.name}>{file.name}</h3>
<dl className="media-meta">
<div><dt>Typ</dt><dd>{file.extension}</dd></div>
<div><dt>Größe</dt><dd>{formatBytes(file.size)}</dd></div>
<div><dt>Upload</dt><dd>{new Date(file.uploadedAt).toLocaleString("de-DE")}</dd></div>
</dl>
<div className="media-actions">
<button type="button" onClick={() => openPreview(file)}>Vorschau</button>
<a href={file.url} download={file.name}>Download</a>
<button type="button" onClick={() => copyUrl(file)}>URL kopieren</button>
<button type="button" className="danger" onClick={() => remove(file)}>Löschen</button>
</div>
</div>
</article>
))}
</div>
)}
</section>
{preview && (
<div className="lightbox" role="dialog" aria-modal="true" aria-label={`Vorschau ${preview.name}`}>
<div className="lightbox-toolbar">
<strong>{preview.name}</strong>
<div>
<button type="button" onClick={() => setZoom((value) => Math.max(0.5, value - 0.25))}>Zoom -</button>
<button type="button" onClick={() => setZoom((value) => Math.min(3, value + 0.25))}>Zoom +</button>
<button type="button" onClick={() => { setPreview(null); setZoom(1); }}>Schließen</button>
</div>
</div>
<button className="lightbox-backdrop" type="button" aria-label="Vorschau schließen" onClick={() => { setPreview(null); setZoom(1); }} />
<div className="lightbox-stage">
<img src={preview.url} alt={preview.name} style={{ transform: `scale(${zoom})` }} />
</div>
</div>
)}
</>
);
}

View file

@ -1,94 +0,0 @@
"use client";
import { useRef, useState, type FormEvent } from "react";
import type { PublicSmtpSettings } from "@/lib/mail/types";
type ApiResponse = {
message?: string;
ok?: boolean;
settings?: PublicSmtpSettings;
};
export default function SmtpSettingsForm({ initialSettings, configured }: { initialSettings: PublicSmtpSettings; configured: boolean }) {
const formRef = useRef<HTMLFormElement>(null);
const [settings, setSettings] = useState(initialSettings);
const [isConfigured, setIsConfigured] = useState(configured);
const [message, setMessage] = useState("");
const [error, setError] = useState("");
const [pending, setPending] = useState(false);
async function submitForm(form: HTMLFormElement, action: "save" | "test") {
setMessage("");
setError("");
setPending(true);
const formData = new FormData(form);
formData.set("action", action);
try {
const response = await fetch("/api/admin/smtp", { method: "POST", body: formData });
const result = await response.json() as ApiResponse;
if (result.settings) {
setSettings(result.settings);
setIsConfigured(Boolean(result.settings.host && result.settings.username && result.settings.hasPassword && result.settings.fromAddress && result.settings.recipientAddress));
}
if (!response.ok) throw new Error(result.message ?? "SMTP-Aktion fehlgeschlagen.");
setMessage(result.message ?? "Aktion erfolgreich.");
} catch (err) {
setError(err instanceof Error ? err.message : "SMTP-Aktion fehlgeschlagen.");
} finally {
setPending(false);
}
}
function submit(event: FormEvent<HTMLFormElement>) {
event.preventDefault();
void submitForm(event.currentTarget, "save");
}
function test() {
if (formRef.current) void submitForm(formRef.current, "test");
}
return (
<>
<section className="admin-card">
<h2>Status</h2>
<p className="admin-muted">SMTP ist {isConfigured ? "konfiguriert" : "nicht konfiguriert"}.</p>
{settings.lastTestAt && <p>Letzte Testmail: {new Date(settings.lastTestAt).toLocaleString("de-DE")} ({settings.lastTestStatus})</p>}
{settings.lastDeliveryAt && <p>Letzter Formularversand: {new Date(settings.lastDeliveryAt).toLocaleString("de-DE")} ({settings.lastDeliveryStatus})</p>}
{settings.lastError && <p className="error">Letzter Fehler: {settings.lastError}</p>}
<p className="notice">Für Apple Mail/iCloud bitte smtp.mail.me.com, Port 587, STARTTLS und ein app-spezifisches Passwort verwenden.</p>
</section>
{message && <p className="success admin-message">{message}</p>}
{error && <p className="error admin-message">{error}</p>}
<form ref={formRef} className="admin-card admin-form" onSubmit={submit}>
<label>SMTP Host<input name="host" defaultValue={settings.host} placeholder="smtp.mail.me.com" /></label>
<label>SMTP Port<input name="port" defaultValue={settings.port} inputMode="numeric" placeholder="587" /></label>
<label>SMTP Benutzername<input name="username" defaultValue={settings.username} placeholder="name@example.com" /></label>
<label>
SMTP Passwort
<input name="password" type="password" placeholder={settings.hasPassword ? "Passwort ist gesetzt" : "App-spezifisches Passwort"} autoComplete="new-password" />
</label>
<label>
Verschlüsselung
<select name="security" defaultValue={settings.security}>
<option value="starttls">STARTTLS</option>
<option value="tls">TLS</option>
<option value="none">Keine</option>
</select>
</label>
<label>Absenderadresse<input name="fromAddress" type="email" defaultValue={settings.fromAddress} /></label>
<label>Antwortadresse<input name="replyToAddress" type="email" defaultValue={settings.replyToAddress} /></label>
<label>Empfängeradresse<input name="recipientAddress" type="email" defaultValue={settings.recipientAddress} /></label>
<label>BCC optional<input name="bccAddress" type="email" defaultValue={settings.bccAddress} /></label>
<div className="actions">
<button className="button" type="submit" disabled={pending}>Speichern</button>
<button className="button light" type="button" disabled={pending} onClick={test}>Test-E-Mail senden</button>
</div>
</form>
</>
);
}

View file

@ -1,34 +0,0 @@
"use client";
import { useRouter } from "next/navigation";
import { useState } from "react";
import type { InquiryStatus } from "@/lib/admin/types";
type Props = {
id: string;
type: "contact" | "repair";
status: InquiryStatus;
};
export default function StatusSelect({ id, type, status }: Props) {
const router = useRouter();
const [value, setValue] = useState(status);
async function update(nextStatus: InquiryStatus) {
setValue(nextStatus);
const response = await fetch(`/api/admin/${type}/${id}`, {
method: "PATCH",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ status: nextStatus }),
});
if (response.ok) router.refresh();
}
return (
<select className="status-select" value={value} onChange={(event) => update(event.target.value as InquiryStatus)}>
<option value="new">Neu</option>
<option value="in_progress">In Prüfung</option>
<option value="done">Erledigt</option>
</select>
);
}

View file

@ -1,153 +0,0 @@
"use client";
import { useRouter } from "next/navigation";
import { useState } from "react";
type EstimateAction = "approve" | "decline" | "question";
type EstimateActionsProps = {
token: string;
};
type ActionResponse = {
message?: string;
};
const actionLabels: Record<EstimateAction, string> = {
approve: "Freigeben",
decline: "Ablehnen",
question: "Rückfrage senden",
};
async function readActionMessage(response: Response) {
try {
const body = await response.json() as ActionResponse;
return body.message || "Die Aktion konnte nicht abgeschlossen werden.";
} catch {
return "Die Aktion konnte nicht abgeschlossen werden.";
}
}
export function EstimateActions({ token }: EstimateActionsProps) {
const router = useRouter();
const [activeForm, setActiveForm] = useState<Exclude<EstimateAction, "approve"> | null>(null);
const [message, setMessage] = useState("");
const [pendingAction, setPendingAction] = useState<EstimateAction | null>(null);
const [feedback, setFeedback] = useState<{ type: "success" | "error"; text: string } | null>(null);
async function submitAction(action: EstimateAction, actionMessage = "") {
setPendingAction(action);
setFeedback(null);
try {
const response = await fetch(`/api/status/${encodeURIComponent(token)}/estimate/${action}`, {
method: "POST",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify({ message: actionMessage.trim() || null }),
});
const responseMessage = await readActionMessage(response);
if (!response.ok) {
setFeedback({ type: "error", text: responseMessage });
return;
}
setMessage("");
setActiveForm(null);
setFeedback({ type: "success", text: responseMessage });
router.refresh();
} catch {
setFeedback({
type: "error",
text: "Die Aktion konnte momentan nicht gesendet werden. Bitte versuchen Sie es erneut.",
});
} finally {
setPendingAction(null);
}
}
const isPending = pendingAction !== null;
return (
<div className="estimate-actions-panel">
<div className="estimate-action-buttons">
<button
className="button"
disabled={isPending}
type="button"
onClick={() => void submitAction("approve")}
>
{pendingAction === "approve" ? "Wird freigegeben..." : actionLabels.approve}
</button>
<button
className="button light"
disabled={isPending}
type="button"
onClick={() => {
setActiveForm("question");
setFeedback(null);
}}
>
Rückfrage
</button>
<button
className="button danger"
disabled={isPending}
type="button"
onClick={() => {
setActiveForm("decline");
setFeedback(null);
}}
>
Ablehnen
</button>
</div>
{activeForm && (
<form
className="estimate-action-form"
onSubmit={(event) => {
event.preventDefault();
void submitAction(activeForm, message);
}}
>
<label htmlFor={`estimate-${activeForm}-message`}>
{activeForm === "decline" ? "Nachricht zur Ablehnung" : "Ihre Rückfrage"}
</label>
<textarea
id={`estimate-${activeForm}-message`}
name="message"
rows={4}
value={message}
placeholder={activeForm === "decline" ? "Optionaler Hinweis für uns" : "Ihre Frage zum Kostenvoranschlag"}
onChange={(event) => setMessage(event.currentTarget.value)}
/>
<div className="estimate-form-actions">
<button className="button" disabled={isPending} type="submit">
{pendingAction === activeForm ? "Wird gesendet..." : actionLabels[activeForm]}
</button>
<button
className="button light"
disabled={isPending}
type="button"
onClick={() => {
setActiveForm(null);
setMessage("");
}}
>
Abbrechen
</button>
</div>
</form>
)}
{feedback && (
<p className={`estimate-feedback ${feedback.type === "error" ? "error" : "success"}`} role="status">
{feedback.text}
</p>
)}
</div>
);
}

View file

@ -1 +0,0 @@

View file

@ -1,12 +0,0 @@
[
{
"id": "contact_example",
"createdAt": "2026-07-03T00:00:00.000Z",
"status": "new",
"name": "Max Mustermann",
"email": "max@example.invalid",
"phone": "",
"subject": "Allgemeine Anfrage",
"message": "Beispieldatensatz fuer lokale Tests. Nicht fuer produktive Daten verwenden."
}
]

View file

@ -1,18 +0,0 @@
[
{
"id": "repair_example",
"createdAt": "2026-07-03T00:00:00.000Z",
"status": "new",
"name": "Max Mustermann",
"email": "max@example.invalid",
"phone": "",
"manufacturer": "Beispielhersteller",
"model": "Beispielmodell",
"serialNumber": "",
"deviceType": "cb-radio",
"accessories": "Mikrofon",
"opened": "unknown",
"previousWork": "",
"description": "Beispieldatensatz fuer lokale Tests. Nicht fuer produktive Daten verwenden."
}
]

View file

@ -1,30 +1,14 @@
services:
funktechnik-website:
container_name: funktechnik-website
build:
context: .
dockerfile: Dockerfile
ports:
- "3010:3010"
container_name: funktechnik-website
restart: unless-stopped
environment:
NEXT_PUBLIC_SITE_URL: ${NEXT_PUBLIC_SITE_URL:-http://127.0.0.1:3010}
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}
NODE_ENV: production
NEXT_PUBLIC_SITE_URL: ${NEXT_PUBLIC_SITE_URL:-http://localhost:3010}
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-storage:/app/storage
- funktechnik-next-cache:/app/.next/cache
restart: unless-stopped
volumes:
funktechnik-data:
funktechnik-storage:
funktechnik-next-cache:
ports:
- "3010:3010"

View file

@ -1,69 +0,0 @@
import { createHmac, timingSafeEqual } from "crypto";
import { cookies } from "next/headers";
export const adminSessionCookie = "fs_admin_session";
const sessionMaxAgeSeconds = 60 * 60 * 8;
function secret() {
return process.env.ADMIN_SESSION_SECRET ?? "dev-admin-session-secret-change-me";
}
function adminEmail() {
return process.env.ADMIN_EMAIL;
}
function adminPassword() {
return process.env.ADMIN_PASSWORD;
}
function sign(value: string) {
return createHmac("sha256", secret()).update(value).digest("base64url");
}
function safeEqual(left: string, right: string) {
const leftBuffer = Buffer.from(left);
const rightBuffer = Buffer.from(right);
return leftBuffer.length === rightBuffer.length && timingSafeEqual(leftBuffer, rightBuffer);
}
export function validateAdminCredentials(email: string, password: string) {
const configuredEmail = adminEmail();
const configuredPassword = adminPassword();
if (!configuredEmail || !configuredPassword) return false;
return safeEqual(email.trim().toLowerCase(), configuredEmail.toLowerCase()) && safeEqual(password, configuredPassword);
}
export function createAdminSession() {
const expiresAt = Date.now() + sessionMaxAgeSeconds * 1000;
const payload = Buffer.from(JSON.stringify({ sub: adminEmail() ?? "admin", exp: expiresAt })).toString("base64url");
return `${payload}.${sign(payload)}`;
}
export function verifyAdminSession(token?: string) {
if (!token) return false;
const [payload, signature] = token.split(".");
if (!payload || !signature || !safeEqual(signature, sign(payload))) return false;
try {
const session = JSON.parse(Buffer.from(payload, "base64url").toString("utf8")) as { exp?: number };
return typeof session.exp === "number" && session.exp > Date.now();
} catch {
return false;
}
}
export async function requireAdminSession() {
const cookieStore = await cookies();
return verifyAdminSession(cookieStore.get(adminSessionCookie)?.value);
}
export function adminCookieOptions() {
return {
httpOnly: true,
sameSite: "lax" as const,
secure: process.env.AUTH_COOKIE_SECURE === "true",
path: "/",
maxAge: sessionMaxAgeSeconds,
};
}

View file

@ -1,155 +0,0 @@
import { randomUUID } from "crypto";
import { mkdir, readFile, readdir, rm, stat, writeFile } from "fs/promises";
import path from "path";
import { migrateLegacyUploads, uploadDirectory } from "@/lib/runtime/config";
const maxUploadSize = 5 * 1024 * 1024;
const allowedMimeTypes = new Map([
["image/jpeg", [".jpg", ".jpeg"]],
["image/png", [".png"]],
["image/webp", [".webp"]],
["application/pdf", [".pdf"]],
]);
export type MediaFile = {
name: string;
url: string;
type: "image" | "pdf";
extension: string;
size: number;
uploadedAt: string;
isImage: boolean;
isPdf: boolean;
};
export function getMediaMimeType(fileName: string) {
const extension = path.extname(fileName).toLowerCase();
if (extension === ".jpg" || extension === ".jpeg") return "image/jpeg";
if (extension === ".png") return "image/png";
if (extension === ".webp") return "image/webp";
if (extension === ".pdf") return "application/pdf";
return "";
}
export async function ensureUploadDirectory() {
await mkdir(uploadDirectory, { recursive: true });
const probe = path.join(uploadDirectory, `.write-check-${Date.now()}`);
await writeFile(probe, "ok", "utf8");
await rm(probe, { force: true });
}
export function validateUpload(file: File) {
const extensions = allowedMimeTypes.get(file.type);
const extension = path.extname(file.name).toLowerCase();
if (!extensions || !extensions.includes(extension)) {
return "Bitte eine JPG-, PNG-, WebP- oder PDF-Datei hochladen.";
}
if (file.size <= 0) {
return "Die Datei ist leer.";
}
if (file.size > maxUploadSize) {
return "Die Datei darf maximal 5 MB groß sein.";
}
return "";
}
export function createSafeUploadName(originalName: string) {
const extension = path.extname(originalName).toLowerCase();
const rawBaseName = path.basename(originalName, extension);
const safeBaseName = rawBaseName
.toLowerCase()
.normalize("NFKD")
.replace(/[\u0300-\u036f]/g, "")
.replace(/[^a-z0-9]+/g, "-")
.replace(/^-+|-+$/g, "")
.slice(0, 80) || "upload";
return `${Date.now()}-${randomUUID().slice(0, 8)}-${safeBaseName}${extension}`;
}
export function resolveUploadTarget(fileName: string) {
const target = path.resolve(uploadDirectory, fileName);
const uploadRoot = path.resolve(uploadDirectory);
if (!target.startsWith(`${uploadRoot}${path.sep}`)) {
throw new Error("Ungültiger Upload-Pfad.");
}
return target;
}
function isAllowedStoredFile(fileName: string) {
const extension = path.extname(fileName).toLowerCase();
const allowedExtensions = [...allowedMimeTypes.values()].flat();
return !fileName.startsWith(".") && allowedExtensions.includes(extension);
}
export async function listMediaFiles(): Promise<MediaFile[]> {
await migrateLegacyUploads();
await mkdir(uploadDirectory, { recursive: true });
const entries = await readdir(uploadDirectory);
const visibleEntries = entries.filter(isAllowedStoredFile);
const files = await Promise.all(visibleEntries.map(async (entry) => {
const fileStat = await stat(path.join(uploadDirectory, entry));
const extension = path.extname(entry).toLowerCase();
const isImage = [".jpg", ".jpeg", ".png", ".webp"].includes(extension);
const isPdf = extension === ".pdf";
return {
name: entry,
url: `/api/media/${encodeURIComponent(entry)}`,
type: isPdf ? "pdf" as const : "image" as const,
extension: extension.replace(".", "").toUpperCase() || "Datei",
size: fileStat.size,
uploadedAt: fileStat.birthtime.toISOString(),
isImage,
isPdf,
};
}));
return files.sort((left, right) => right.uploadedAt.localeCompare(left.uploadedAt));
}
export async function saveMediaFile(file: File) {
const error = validateUpload(file);
if (error) return { error };
await ensureUploadDirectory();
const safeName = createSafeUploadName(file.name);
const target = resolveUploadTarget(safeName);
await writeFile(target, Buffer.from(await file.arrayBuffer()));
return { fileName: safeName };
}
export async function readMediaFile(fileName: string) {
if (!isAllowedStoredFile(fileName) || path.basename(fileName) !== fileName) {
return null;
}
const mimeType = getMediaMimeType(fileName);
if (!mimeType) return null;
const target = resolveUploadTarget(fileName);
try {
const [data, fileStat] = await Promise.all([readFile(target), stat(target)]);
return { data, mimeType, size: fileStat.size, uploadedAt: fileStat.birthtime.toISOString() };
} catch {
return null;
}
}
export async function deleteMediaFile(fileName: string) {
if (!isAllowedStoredFile(fileName) || path.basename(fileName) !== fileName) {
return { error: "Ungültiger Dateiname." };
}
await rm(resolveUploadTarget(fileName), { force: true });
return {};
}

View file

@ -1,119 +0,0 @@
import { mkdir, readFile, writeFile } from "fs/promises";
import path from "path";
import { configDirectory, dataDirectory } from "@/lib/runtime/config";
import type { ContactInquiry, InquiryStatus, RepairInquiry, SiteSettings } from "./types";
async function ensureDataDir() {
await mkdir(dataDirectory, { recursive: true });
}
async function readJson<T>(fileName: string, fallback: T): Promise<T> {
await ensureDataDir();
try {
const file = await readFile(path.join(dataDirectory, fileName), "utf8");
return JSON.parse(file) as T;
} catch {
return fallback;
}
}
async function writeJson<T>(fileName: string, value: T) {
await ensureDataDir();
await writeFile(path.join(dataDirectory, fileName), `${JSON.stringify(value, null, 2)}\n`, "utf8");
}
function id(prefix: string) {
return `${prefix}_${Date.now().toString(36)}_${Math.random().toString(36).slice(2, 8)}`;
}
function text(value: FormDataEntryValue | null) {
return typeof value === "string" ? value.trim() : "";
}
export async function getContactInquiries() {
return readJson<ContactInquiry[]>("contact-inquiries.json", []);
}
export async function getRepairInquiries() {
return readJson<RepairInquiry[]>("repair-inquiries.json", []);
}
export async function addContactInquiry(form: FormData) {
const inquiries = await getContactInquiries();
const inquiry: ContactInquiry = {
id: id("contact"),
createdAt: new Date().toISOString(),
status: "new",
name: text(form.get("name")),
email: text(form.get("email")),
phone: text(form.get("phone")),
subject: text(form.get("subject")) || "Kontaktanfrage",
message: text(form.get("message")),
};
await writeJson("contact-inquiries.json", [inquiry, ...inquiries]);
return inquiry;
}
export async function addRepairInquiry(form: FormData) {
const inquiries = await getRepairInquiries();
const inquiry: RepairInquiry = {
id: id("repair"),
createdAt: new Date().toISOString(),
status: "new",
name: text(form.get("name")),
email: text(form.get("email")),
phone: text(form.get("phone")),
manufacturer: text(form.get("manufacturer")),
model: text(form.get("model")),
serialNumber: text(form.get("serialNumber")),
deviceType: text(form.get("deviceType")),
accessories: text(form.get("accessories")),
opened: text(form.get("opened")),
previousWork: text(form.get("previousWork")),
description: text(form.get("description")),
};
await writeJson("repair-inquiries.json", [inquiry, ...inquiries]);
return inquiry;
}
export async function updateContactStatus(id: string, status: InquiryStatus) {
const inquiries = await getContactInquiries();
await writeJson("contact-inquiries.json", inquiries.map((item) => item.id === id ? { ...item, status } : item));
}
export async function updateRepairStatus(id: string, status: InquiryStatus) {
const inquiries = await getRepairInquiries();
await writeJson("repair-inquiries.json", inquiries.map((item) => item.id === id ? { ...item, status } : item));
}
export async function getSiteSettings() {
return readJson<SiteSettings>("site-settings.json", {
companyName: "Funktechnik Schubert",
phone: "",
email: "info@funktechnik-schubert.de",
address: "",
openingHours: "",
googleMapsEmbedUrl: "",
socialLinks: "",
});
}
export async function saveSiteSettings(form: FormData) {
const settings: SiteSettings = {
companyName: text(form.get("companyName")),
phone: text(form.get("phone")),
email: text(form.get("email")),
address: text(form.get("address")),
openingHours: text(form.get("openingHours")),
googleMapsEmbedUrl: text(form.get("googleMapsEmbedUrl")),
socialLinks: text(form.get("socialLinks")),
};
await writeJson("site-settings.json", settings);
return settings;
}
export async function ensureConfigDir() {
await mkdir(configDirectory, { recursive: true });
}
export { text };

View file

@ -1,56 +0,0 @@
export type InquiryStatus = "new" | "in_progress" | "done";
export type ContactInquiry = {
id: string;
createdAt: string;
status: InquiryStatus;
name: string;
email: string;
phone?: string;
subject?: string;
message: string;
};
export type RepairInquiry = {
id: string;
createdAt: string;
status: InquiryStatus;
name: string;
email: string;
phone?: string;
manufacturer: string;
model: string;
serialNumber?: string;
deviceType: string;
accessories?: string;
opened?: string;
previousWork?: string;
description: string;
};
export type SiteSettings = {
companyName: string;
phone: string;
email: string;
address: string;
openingHours: string;
googleMapsEmbedUrl: string;
socialLinks: string;
};
export type SmtpSettings = {
host: string;
port: string;
username: string;
password?: string;
security: "starttls" | "tls" | "none";
fromAddress: string;
replyToAddress: string;
recipientAddress: string;
bccAddress?: string;
lastTestAt?: string;
lastTestStatus?: "success" | "error";
lastDeliveryAt?: string;
lastDeliveryStatus?: "success" | "error";
lastError?: string;
};

View file

@ -1,205 +0,0 @@
import type { ContentDocuments, SeoContent } from "./types";
const now = "2026-01-01T00:00:00.000Z";
function seo(title: string, description: string, path: string): SeoContent {
return {
metaTitle: title,
metaDescription: description,
keywords: "Funktechnik Schubert, Funkgeräte Reparatur, CB Funk Service, Amateurfunk Service, Funkgeräte Abgleich, Messtechnik",
openGraphTitle: title,
openGraphDescription: description,
socialImage: "/funktechnik_schubert_logo.jpg",
canonicalUrl: path,
};
}
export const defaultContent: ContentDocuments = {
home: {
eyebrow: "Funktechnik, Messtechnik, Werkstattservice",
title: "Funktechnik Schubert",
subtitle: "Service, Reparatur und Diagnose für Funkgeräte, Messtechnik und Kommunikationstechnik.",
heroText: "Von CB-Funk und Amateurfunk bis zur professionellen Funktechnik: Wir unterstützen bei Fehlersuche, Abgleich, Modulationsproblemen, Frequenzproblemen und technischer Dokumentation.",
heroImage: "/workbench-signal.svg",
intro: "Von der ersten Fehlerbeschreibung bis zur dokumentierten Prüfung: Funktechnik Schubert verbindet technische Erfahrung, geeignete Messmittel und strukturierte Kommunikation.",
paragraphs: [],
cta: {
text: "Direkte technische Einschätzung statt anonymer Abwicklung",
primary: { label: "Reparatur anfragen", href: "/reparatur" },
secondary: { label: "Leistungen ansehen", href: "/leistungen" },
},
listTitle: "Technische Schwerpunkte",
list: ["CB-Funk", "Amateurfunk", "Messtechnik", "Abgleich", "Diagnose", "Service Manuals"],
badges: ["CB-Funk", "Amateurfunk", "Messtechnik", "Abgleich", "Diagnose", "Service Manuals"],
features: [
{ title: "Funkgeräte-Service", text: "Sichtprüfung, Funktionsprüfung und technische Beurteilung für CB-Funk, Amateurfunk, Betriebsfunk sowie ältere und moderne Geräte." },
{ title: "Fehlersuche & Reparatur", text: "Systematische Diagnose bei Empfangsproblemen, Sendeproblemen, PLL-Fehlern, Frequenzabweichungen und Audio-/NF-Störungen." },
{ title: "Abgleich & Messtechnik", text: "Frequenzabgleich, Hub- und Modulationsprüfung, Sendeleistung, Empfindlichkeit und RF-Messung mit geeigneter Messtechnik." },
],
services: [
{ title: "Funkgeräte-Service", text: "Prüfung und technische Einschätzung für CB-Funk, Amateurfunk und Kommunikationstechnik." },
{ title: "Messtechnik", text: "Messungen mit geeigneter Werkstattpraxis und nachvollziehbarer Dokumentation." },
{ title: "Serviceunterlagen", text: "Einordnung von Schaltplänen, Service Manuals und Abgleichhinweisen." },
],
promise: {
eyebrow: "Persönlicher Support",
title: "Direkte technische Einschätzung statt anonymer Abwicklung",
text: "Viele Fehlerbilder lassen sich bereits mit einer klaren Beschreibung und den richtigen Gerätedaten eingrenzen.",
bullets: [
"Technischer Fokus auf Funkgeräte und Kommunikationstechnik",
"Nachvollziehbare Diagnose mit Blick auf Messwerte und Fehlerbild",
"Sauberer Umgang mit Service Manuals, Schaltplänen und Abgleichanleitungen",
"Klare Rückmeldung zu Zustand, Aufwand und sinnvollen nächsten Schritten",
],
button: { label: "Reparaturformular öffnen", href: "/reparatur" },
},
footerText: "Service und technische Unterstützung für Funkgeräte, Messtechnik und Kommunikationstechnik.",
faq: [],
seo: seo("Funktechnik Schubert", "Service, Reparatur, Diagnose und Abgleich von Funktechnik, CB-Funk, Amateurfunk und Kommunikationstechnik.", "/"),
updatedAt: now,
},
services: {
eyebrow: "Leistungen",
title: "Service für Funkgeräte und Kommunikationstechnik",
subtitle: "Technische Unterstützung für Geräte, bei denen Diagnose, Messtechnik, Erfahrung und saubere Dokumentation zählen.",
heroText: "Leistungen für private und professionelle Funktechnik mit klarem Fokus auf Diagnose, Abgleich und Reparatur.",
heroImage: "/workbench-signal.svg",
intro: "Die Leistungen können im Adminbereich sortiert, erweitert und ein- oder ausgeblendet werden.",
paragraphs: [],
cta: { text: "Reparaturfall vorbereiten", primary: { label: "Reparatur anfragen", href: "/reparatur" }, secondary: { label: "Kontakt aufnehmen", href: "/kontakt" } },
listTitle: "Leistungen",
list: [],
services: [
{ title: "Funkgeräte-Service", description: "Service für CB-Funk, Amateurfunk, Betriebsfunk sowie ältere und moderne Funkgeräte.", icon: "radio", sortOrder: 10, visible: true },
{ title: "Fehlersuche & Reparatur", description: "Strukturierte Analyse bei Modulationsproblemen, Frequenzabweichung, PLL-Problemen und Empfangs- oder Sendeproblemen.", icon: "tools", sortOrder: 20, visible: true },
{ title: "Abgleich & Prüfung", description: "Frequenzabgleich, Hub- und Modulationsprüfung, Sendeleistung, Empfindlichkeit und Funktionsprüfung.", icon: "meter", sortOrder: 30, visible: true },
{ title: "Messtechnik & Diagnose", description: "Systematische Fehlersuche mit Oszilloskop, Frequenzzähler, Signalgenerator, RF-Messung und Modulationsmessung.", icon: "scope", sortOrder: 40, visible: true },
{ title: "Serviceunterlagen", description: "Einordnung von Schaltplänen, Service Manuals, Abgleichanleitungen und Reparaturhinweisen.", icon: "manual", sortOrder: 50, visible: true },
],
faq: [],
seo: seo("Leistungen", "Funkgeräte-Service, Diagnose, Abgleich und Dokumentation für Kommunikationstechnik.", "/leistungen"),
updatedAt: now,
},
"radio-service": {
eyebrow: "Funkgeräte-Service",
title: "Prüfung, Diagnose und Abgleich",
subtitle: "Funkgeräte zeigen Fehler oft erst im Zusammenspiel von Empfang, Sendeteil, Versorgung, Antennenanpassung, Bedienung und Abgleich.",
heroText: "Der Service betrachtet diese Zusammenhänge strukturiert und dokumentiert die technischen Schritte nachvollziehbar.",
heroImage: "/workbench-signal.svg",
intro: "Eine gute Reparatur beginnt mit vollständigen Angaben zu Gerät, Fehlerbild und Vorgeschichte.",
paragraphs: [],
cta: { text: "Reparaturprozess starten", primary: { label: "Reparatur anfragen", href: "/reparatur" }, secondary: { label: "Kontakt", href: "/kontakt" } },
listTitle: "Typische Fehlerbilder",
list: [],
brands: ["CB-Funk", "Amateurfunk", "Betriebsfunk"],
symptoms: [
"Kein oder schwacher Empfang",
"Keine, leise oder verzerrte Modulation",
"Frequenzversatz, PLL-Rasten oder instabile Kanäle",
"Sendeprobleme, schwankende Leistung oder auffällige Erwärmung",
"Audio-/NF-Probleme, Displayfehler oder Aussetzer durch Bedienelemente",
"Unklare Vorarbeiten, fehlende Serviceunterlagen oder bereits geöffnete Geräte",
],
workflow: ["Anfrage", "Technische Sichtung", "Messung", "Abstimmung", "Reparatur oder Rückgabe"],
measurements: ["Frequenz", "Sendeleistung", "Hub", "Modulation", "Empfindlichkeit"],
alignment: "Abgleich erfolgt anhand technischer Unterlagen und sinnvoller Messpunkte.",
repair: "Reparaturen werden vor Durchführung technisch eingeordnet und abgestimmt.",
faq: [],
seo: seo("Funkgeräte-Service", "Werkstattservice für CB-Funk, Amateurfunk und Funkgeräte-Abgleich.", "/funkgeraete-service"),
updatedAt: now,
},
repair: {
eyebrow: "Reparaturannahme",
title: "Reparatur anfragen",
subtitle: "Beschreiben Sie Gerät, Fehlerbild, Zubehör und bisherige Vorarbeiten.",
heroText: "Die Anfrage wird strukturiert erfasst und für eine spätere technische Bearbeitung vorbereitet.",
heroImage: "/workbench-signal.svg",
intro: "Bitte senden Sie Geräte erst nach Rückmeldung ein. Vollständige Gerätedaten und eine präzise Fehlerbeschreibung helfen, den Aufwand besser einzuschätzen.",
paragraphs: [
{ title: "Vor der Einsendung", text: "Bitte senden Sie Geräte erst nach Rückmeldung ein. Vollständige Gerätedaten und eine präzise Fehlerbeschreibung helfen, den Aufwand besser einzuschätzen." },
],
cta: { text: "Reparatur vorbereiten", primary: { label: "Formular ausfüllen", href: "/reparatur" }, secondary: { label: "Kontakt", href: "/kontakt" } },
listTitle: "Wichtige Angaben",
list: [
"Hersteller, Modell, Geräteart und Seriennummer bereithalten, soweit vorhanden",
"Fehler möglichst konkret beschreiben: Empfang, Sendung, Modulation, Frequenz, Anzeige oder Versorgung",
"Zubehör wie Netzteil, Mikrofon, Antennenadapter oder Kabel angeben",
"Bereits geöffnete Geräte und durchgeführte Arbeiten ehrlich nennen",
"Fotos können später nach Rückmeldung ergänzt werden",
],
faq: [],
seo: seo("Reparaturannahme", "Reparaturanfrage für Funkgeräte strukturiert vorbereiten.", "/reparatur"),
updatedAt: now,
},
about: {
eyebrow: "Über uns",
title: "Technik verstehen, Fehler nachvollziehbar lösen",
subtitle: "Funktechnik Schubert richtet sich an Kunden, die bei Funkgeräten und Kommunikationstechnik eine persönliche, technische Einschätzung suchen.",
heroText: "Im Mittelpunkt stehen saubere Diagnose, transparente Kommunikation und respektvoller Umgang mit bestehenden Geräten.",
heroImage: "/workbench-signal.svg",
intro: "Technischer Service lebt von Genauigkeit, Erfahrung und klarer Rückmeldung.",
paragraphs: [
{ title: "Arbeitsweise", text: "Im Mittelpunkt stehen saubere Diagnose, transparente Kommunikation und der respektvolle Umgang mit bestehenden Geräten und Serviceunterlagen." },
{ title: "Fokus", text: "Der Schwerpunkt liegt auf Funktechnik, Elektronikservice, Abgleichfragen und technischer Unterstützung rund um Kommunikationstechnik." },
],
cta: { text: "Mehr zur Werkstatt", primary: { label: "Kontakt aufnehmen", href: "/kontakt" }, secondary: { label: "Leistungen", href: "/leistungen" } },
listTitle: "Grundsätze",
list: ["Nachvollziehbare technische Einschätzung", "Klare Kommunikation", "Sorgfältiger Umgang mit Geräten"],
companyDescription: "Funktechnik Schubert bietet technischen Service für Funkgeräte, Messtechnik und Kommunikationstechnik.",
workshopDescription: "Die Werkstatt arbeitet strukturiert mit Messmitteln, Dokumentation und technischer Prüfung.",
philosophy: "Reparatur und Service sollen nachvollziehbar, ehrlich und technisch sauber bleiben.",
faq: [],
seo: seo("Über uns", "Funktechnik Schubert steht für technischen Service und klare Kommunikation.", "/ueber-uns"),
updatedAt: now,
},
contact: {
eyebrow: "Kontakt",
title: "Kontakt aufnehmen",
subtitle: "Beschreiben Sie kurz Ihr Anliegen. Für Reparaturen nutzen Sie idealerweise die strukturierte Reparaturannahme.",
heroText: "Je genauer Gerät, Anliegen und gewünschte Unterstützung beschrieben werden, desto gezielter kann die Rückmeldung erfolgen.",
heroImage: "/workbench-signal.svg",
intro: "Direkt und technisch: Nutzen Sie das Kontaktformular oder die Reparaturannahme.",
paragraphs: [],
cta: { text: "Anfrage senden", primary: { label: "Reparatur anfragen", href: "/reparatur" }, secondary: { label: "Leistungen", href: "/leistungen" } },
listTitle: "Kontaktinformationen",
list: [],
phone: "",
email: "",
address: "",
openingHours: "",
googleMapsLink: "",
socialMedia: [],
faq: [],
seo: seo("Kontakt", "Kontakt zu Funktechnik Schubert aufnehmen.", "/kontakt"),
updatedAt: now,
},
settings: {
siteName: "Funktechnik Schubert",
logoUrl: "/funktechnik_schubert_logo.jpg",
footerShortText: "Service und technische Unterstützung für Funkgeräte, Messtechnik und Kommunikationstechnik.",
copyright: "© Funktechnik Schubert",
footerLinks: [
{ label: "Leistungen", href: "/leistungen" },
{ label: "Funkgeräte-Service", href: "/funkgeraete-service" },
{ label: "Reparatur", href: "/reparatur" },
{ label: "Kontakt", href: "/kontakt" },
],
legalLinks: [
{ label: "Impressum", href: "/impressum" },
{ label: "Datenschutz", href: "/datenschutz" },
],
headerCta: { label: "Reparatur anfragen", href: "/reparatur" },
seo: seo("Funktechnik Schubert", "Service, Reparatur, Diagnose und Abgleich von Funktechnik, CB-Funk, Amateurfunk und Kommunikationstechnik.", "/"),
updatedAt: now,
},
};
export const contentFileNames = {
home: "home.json",
services: "services.json",
"radio-service": "radio-service.json",
repair: "repair.json",
about: "about.json",
contact: "contact.json",
settings: "settings.json",
} as const;

View file

@ -1,176 +0,0 @@
import { existsSync } from "fs";
import { mkdir, readdir, readFile, rename, stat, unlink, writeFile } from "fs/promises";
import path from "path";
import { storageDirectory } from "@/lib/runtime/config";
import { listMediaFiles } from "@/lib/admin/media";
import { contentFileNames, defaultContent } from "./defaults";
import type { ContentBackup, ContentDocuments, ContentKey, ContentSummary } from "./types";
export const contentDirectory = path.join(storageDirectory, "content");
export const contentBackupDirectory = path.join(contentDirectory, "backups");
export const contentVersion = "0.4.1";
const contentKeys = Object.keys(contentFileNames) as ContentKey[];
function isRecord(value: unknown): value is Record<string, unknown> {
return typeof value === "object" && value !== null && !Array.isArray(value);
}
function sanitizeString(value: string) {
return value
.replace(/<script[\s\S]*?>[\s\S]*?<\/script>/gi, "")
.replace(/javascript:/gi, "")
.replace(/[<>]/g, "")
.trim();
}
function sanitizeValue(value: unknown): unknown {
if (typeof value === "string") return sanitizeString(value);
if (Array.isArray(value)) return value.map(sanitizeValue);
if (!isRecord(value)) return value;
return Object.fromEntries(Object.entries(value).map(([key, entry]) => [key, sanitizeValue(entry)]));
}
function mergeContent<T>(defaults: T, incoming: unknown): T {
if (Array.isArray(defaults)) return Array.isArray(incoming) ? sanitizeValue(incoming) as T : defaults;
if (!isRecord(defaults)) return sanitizeValue(incoming ?? defaults) as T;
if (!isRecord(incoming)) return defaults;
const merged: Record<string, unknown> = { ...defaults };
for (const [key, value] of Object.entries(defaults)) {
merged[key] = mergeContent(value, incoming[key]);
}
return sanitizeValue(merged) as T;
}
function filePathFor(key: ContentKey) {
return path.join(contentDirectory, contentFileNames[key]);
}
function backupStamp() {
const date = new Date();
const pad = (value: number) => String(value).padStart(2, "0");
return `${date.getFullYear()}${pad(date.getMonth() + 1)}${pad(date.getDate())}-${pad(date.getHours())}${pad(date.getMinutes())}${pad(date.getSeconds())}`;
}
async function pruneBackups(key: ContentKey) {
const prefix = `${key}.`;
const entries = await readdir(contentBackupDirectory).catch(() => []);
const backups = await Promise.all(entries
.filter((entry) => entry.startsWith(prefix) && entry.endsWith(".json"))
.map(async (entry) => {
const fullPath = path.join(contentBackupDirectory, entry);
const fileStat = await stat(fullPath);
return { entry, fullPath, modified: fileStat.mtimeMs };
}));
const obsolete = backups.sort((left, right) => right.modified - left.modified).slice(20);
await Promise.all(obsolete.map((entry) => unlink(entry.fullPath).catch(() => undefined)));
}
async function ensureContentDocument<K extends ContentKey>(key: K) {
await mkdir(contentDirectory, { recursive: true });
await mkdir(contentBackupDirectory, { recursive: true });
const target = filePathFor(key);
if (!existsSync(target)) {
await writeFile(target, `${JSON.stringify(defaultContent[key], null, 2)}\n`, "utf8");
}
}
export async function ensureContentStorage() {
await mkdir(contentDirectory, { recursive: true });
await mkdir(contentBackupDirectory, { recursive: true });
await Promise.all(contentKeys.map((key) => ensureContentDocument(key)));
}
export async function getContent<K extends ContentKey>(key: K): Promise<ContentDocuments[K]> {
await ensureContentDocument(key);
try {
const raw = await readFile(filePathFor(key), "utf8");
const parsed = JSON.parse(raw) as unknown;
return mergeContent(defaultContent[key], parsed);
} catch {
return defaultContent[key];
}
}
export async function getAllContent(): Promise<ContentDocuments> {
const entries = await Promise.all(contentKeys.map(async (key) => [key, await getContent(key)] as const));
return Object.fromEntries(entries) as ContentDocuments;
}
export async function saveContent<K extends ContentKey>(key: K, content: ContentDocuments[K]) {
await ensureContentDocument(key);
const current = filePathFor(key);
const backup = path.join(contentBackupDirectory, `${key}.${backupStamp()}.json`);
if (existsSync(current)) await rename(current, backup);
const next = mergeContent(defaultContent[key], {
...content,
updatedAt: new Date().toISOString(),
});
await writeFile(current, `${JSON.stringify(next, null, 2)}\n`, "utf8");
await pruneBackups(key);
return next;
}
export async function getContentSummary(): Promise<ContentSummary> {
await ensureContentStorage();
const documents = await Promise.all(contentKeys.map(async (key) => {
const content = await getContent(key);
return {
key,
fileName: contentFileNames[key],
title: "title" in content ? content.title : content.siteName,
updatedAt: content.updatedAt,
};
}));
const lastModified = documents
.map((document) => document.updatedAt)
.filter(Boolean)
.sort()
.at(-1) ?? null;
return {
version: contentVersion,
lastModified,
pageCount: documents.length,
documents,
};
}
export async function listContentBackups(limit = 20): Promise<ContentBackup[]> {
await ensureContentStorage();
const entries = await readdir(contentBackupDirectory).catch(() => []);
const backups = await Promise.all(entries
.filter((entry) => entry.endsWith(".json"))
.map(async (entry) => {
const page = entry.split(".")[0] as ContentKey;
const fullPath = path.join(contentBackupDirectory, entry);
const fileStat = await stat(fullPath);
return {
page,
fileName: entry,
createdAt: fileStat.mtime.toISOString(),
};
}));
return backups
.filter((backup) => contentKeys.includes(backup.page))
.sort((left, right) => right.createdAt.localeCompare(left.createdAt))
.slice(0, limit);
}
export async function getContentSystemStatus() {
const [summary, media, backups] = await Promise.all([getContentSummary(), listMediaFiles(), listContentBackups()]);
return {
...summary,
mediaCount: media.length,
backups,
};
}

View file

@ -1,138 +0,0 @@
export type SeoContent = {
metaTitle: string;
metaDescription: string;
keywords: string;
openGraphTitle: string;
openGraphDescription: string;
socialImage: string;
canonicalUrl: string;
};
export type LinkContent = {
label: string;
href: string;
};
export type TextBlockContent = {
title: string;
text: string;
};
export type FaqContent = {
question: string;
answer: string;
};
export type ServiceContent = {
title: string;
description: string;
icon: string;
sortOrder: number;
visible: boolean;
};
export type PageContent = {
eyebrow: string;
title: string;
subtitle: string;
heroText: string;
heroImage: string;
intro: string;
paragraphs: TextBlockContent[];
cta: {
text: string;
primary: LinkContent;
secondary: LinkContent;
};
listTitle: string;
list: string[];
faq: FaqContent[];
seo: SeoContent;
updatedAt: string;
};
export type HomeContent = PageContent & {
badges: string[];
features: TextBlockContent[];
services: TextBlockContent[];
promise: {
eyebrow: string;
title: string;
text: string;
bullets: string[];
button: LinkContent;
};
footerText: string;
};
export type ServicesContent = PageContent & {
services: ServiceContent[];
};
export type RadioServiceContent = PageContent & {
brands: string[];
symptoms: string[];
workflow: string[];
measurements: string[];
alignment: string;
repair: string;
};
export type AboutContent = PageContent & {
companyDescription: string;
workshopDescription: string;
philosophy: string;
};
export type ContactContent = PageContent & {
phone: string;
email: string;
address: string;
openingHours: string;
googleMapsLink: string;
socialMedia: LinkContent[];
};
export type RepairContent = PageContent;
export type SettingsContent = {
siteName: string;
logoUrl: string;
footerShortText: string;
copyright: string;
footerLinks: LinkContent[];
legalLinks: LinkContent[];
headerCta: LinkContent;
seo: SeoContent;
updatedAt: string;
};
export type ContentDocuments = {
home: HomeContent;
services: ServicesContent;
"radio-service": RadioServiceContent;
repair: RepairContent;
about: AboutContent;
contact: ContactContent;
settings: SettingsContent;
};
export type ContentKey = keyof ContentDocuments;
export type ContentSummary = {
version: string;
lastModified: string | null;
pageCount: number;
documents: Array<{
key: ContentKey;
fileName: string;
title: string;
updatedAt: string;
}>;
};
export type ContentBackup = {
page: ContentKey;
fileName: string;
createdAt: string;
};

View file

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

View file

@ -1,100 +0,0 @@
import nodemailer from "nodemailer";
import type SMTPTransport from "nodemailer/lib/smtp-transport";
import type { SmtpSettings } from "@/lib/admin/types";
import { getSmtpSettings, isSmtpConfigured, updateSmtpDeliveryStatus, updateSmtpTestStatus } from "./config";
import { contactTemplate, repairTemplate, testTemplate } from "./templates";
import type { ContactMailInput, MailSendResult, RepairMailInput } from "./types";
function sanitizeMailError(error: unknown) {
const message = error instanceof Error ? error.message : "Unbekannter SMTP-Fehler";
return message
.replace(/AUTH PLAIN\s+\S+/gi, "AUTH PLAIN [redacted]")
.replace(/AUTH LOGIN\s+\S+/gi, "AUTH LOGIN [redacted]")
.replace(/password[=:]\S+/gi, "password=[redacted]")
.slice(0, 500);
}
function createTransport(settings: SmtpSettings) {
const secure = settings.security === "tls";
const requireTLS = settings.security === "starttls";
const options: SMTPTransport.Options = {
host: settings.host,
port: Number(settings.port),
secure,
requireTLS,
auth: {
user: settings.username,
pass: settings.password,
},
};
if (settings.security === "none") {
options.auth = settings.username && settings.password ? options.auth : undefined;
options.ignoreTLS = true;
}
return nodemailer.createTransport(options);
}
async function sendConfiguredMail(input: {
subject: string;
text: string;
html: string;
replyTo?: string;
}): Promise<MailSendResult> {
const settings = await getSmtpSettings();
if (!isSmtpConfigured(settings)) return { ok: false, error: "SMTP nicht konfiguriert." };
try {
const transport = createTransport(settings);
await transport.sendMail({
from: settings.fromAddress,
to: settings.recipientAddress,
bcc: settings.bccAddress || undefined,
replyTo: input.replyTo || settings.replyToAddress || undefined,
subject: input.subject,
text: input.text,
html: input.html,
});
return { ok: true };
} catch (error) {
return { ok: false, error: sanitizeMailError(error) };
}
}
export async function sendTestMail() {
const template = testTemplate();
const result = await sendConfiguredMail(template);
await updateSmtpTestStatus({
lastTestAt: new Date().toISOString(),
lastTestStatus: result.ok ? "success" : "error",
lastError: result.ok ? "" : result.error,
});
return result;
}
export async function sendContactInquiryMail(input: ContactMailInput) {
const template = contactTemplate(input);
const result = await sendConfiguredMail({ ...template, replyTo: input.email });
if (result.error !== "SMTP nicht konfiguriert.") {
await updateSmtpDeliveryStatus({
lastDeliveryAt: new Date().toISOString(),
lastDeliveryStatus: result.ok ? "success" : "error",
lastError: result.ok ? "" : result.error,
});
}
return result;
}
export async function sendRepairInquiryMail(input: RepairMailInput) {
const template = repairTemplate(input);
const result = await sendConfiguredMail({ ...template, replyTo: input.email });
if (result.error !== "SMTP nicht konfiguriert.") {
await updateSmtpDeliveryStatus({
lastDeliveryAt: new Date().toISOString(),
lastDeliveryStatus: result.ok ? "success" : "error",
lastError: result.ok ? "" : result.error,
});
}
return result;
}

View file

@ -1,74 +0,0 @@
import type { ContactMailInput, RepairMailInput } from "./types";
function escapeHtml(value?: string) {
return (value ?? "")
.replace(/&/g, "&amp;")
.replace(/</g, "&lt;")
.replace(/>/g, "&gt;")
.replace(/"/g, "&quot;")
.replace(/'/g, "&#039;");
}
function rows(entries: Array<[string, string | undefined]>) {
return entries
.filter(([, value]) => value)
.map(([label, value]) => `<tr><th align="left" style="padding:6px 12px;border-bottom:1px solid #d9dee6;">${escapeHtml(label)}</th><td style="padding:6px 12px;border-bottom:1px solid #d9dee6;">${escapeHtml(value)}</td></tr>`)
.join("");
}
function textRows(entries: Array<[string, string | undefined]>) {
return entries
.filter(([, value]) => value)
.map(([label, value]) => `${label}: ${value}`)
.join("\n");
}
export function contactTemplate(input: ContactMailInput) {
const subject = `Kontaktanfrage: ${input.subject ?? "Funktechnik Schubert"}`;
const entries: Array<[string, string | undefined]> = [
["Datum", new Date(input.createdAt).toLocaleString("de-DE")],
["Name", input.name],
["E-Mail", input.email],
["Telefon", input.phone],
["Betreff", input.subject],
["Nachricht", input.message],
];
return {
subject,
text: `Neue Kontaktanfrage\n\n${textRows(entries)}`,
html: `<h1>Neue Kontaktanfrage</h1><table cellspacing="0" cellpadding="0">${rows(entries)}</table>`,
};
}
export function repairTemplate(input: RepairMailInput) {
const subject = `Reparaturanfrage: ${input.manufacturer} ${input.model}`;
const entries: Array<[string, string | undefined]> = [
["Datum", new Date(input.createdAt).toLocaleString("de-DE")],
["Name", input.name],
["E-Mail", input.email],
["Telefon", input.phone],
["Hersteller", input.manufacturer],
["Modell", input.model],
["Geräteart", input.deviceType],
["Seriennummer", input.serialNumber],
["Fehlerbeschreibung", input.description],
["Zubehör", input.accessories],
["Gerät geöffnet?", input.opened],
["Vorarbeiten", input.previousWork],
];
return {
subject,
text: `Neue Reparaturanfrage\n\n${textRows(entries)}`,
html: `<h1>Neue Reparaturanfrage</h1><table cellspacing="0" cellpadding="0">${rows(entries)}</table>`,
};
}
export function testTemplate() {
return {
subject: "SMTP Testmail - Funktechnik Schubert",
text: "Diese Testmail wurde aus der Funktechnik Schubert Website-Administration gesendet.",
html: "<h1>SMTP Testmail</h1><p>Diese Testmail wurde aus der Funktechnik Schubert Website-Administration gesendet.</p>",
};
}

View file

@ -1,13 +0,0 @@
import type { ContactInquiry, RepairInquiry, SmtpSettings } from "@/lib/admin/types";
export type PublicSmtpSettings = Omit<SmtpSettings, "password"> & {
hasPassword: boolean;
};
export type MailSendResult = {
ok: boolean;
error?: string;
};
export type ContactMailInput = ContactInquiry;
export type RepairMailInput = RepairInquiry;

View file

@ -1,145 +0,0 @@
import "server-only";
export type OlympusRepairStatusHistoryItem = {
status: string;
status_label: string;
created_at: string;
};
export type OlympusEstimateItem = {
item_type: string;
title: string;
description: string | null;
quantity: string | number;
unit: string;
unit_price_cents: number;
total_cents: number;
};
export type OlympusEstimate = {
estimate_number: string;
status: "draft" | "sent" | "approved" | "declined" | "expired" | "cancelled" | string;
title: string;
customer_message: string | null;
subtotal_cents: number;
tax_cents: number;
total_cents: number;
currency: string;
valid_until: string | null;
items: OlympusEstimateItem[];
};
export type OlympusRepairStatus = {
repair_number: string;
public_status_label: string;
device_manufacturer: string;
device_model: string;
status_history_public: OlympusRepairStatusHistoryItem[];
updated_at: string;
estimate?: OlympusEstimate | null;
};
export type RepairStatusResult =
| { ok: true; status: OlympusRepairStatus }
| { ok: false; reason: "invalid" | "unavailable" };
export type EstimateAction = "approve" | "decline" | "question";
export type EstimateActionResult =
| { ok: true }
| { ok: false; reason: "invalid" | "unavailable" | "failed" };
function getStatusApiBaseUrl() {
return process.env.OLYMPUS_PUBLIC_STATUS_API_URL?.replace(/\/$/, "") ?? "";
}
function getStatusApiToken() {
return process.env.OLYMPUS_PUBLIC_STATUS_API_TOKEN ?? "";
}
export async function fetchOlympusRepairStatus(token: string): Promise<RepairStatusResult> {
const baseUrl = getStatusApiBaseUrl();
if (!baseUrl || !token.trim()) {
return { ok: false, reason: "unavailable" };
}
const headers: HeadersInit = {
Accept: "application/json",
};
const apiToken = getStatusApiToken();
if (apiToken) {
headers.Authorization = `Bearer ${apiToken}`;
}
let response: Response;
try {
response = await fetch(`${baseUrl}/public/repairs/status/${encodeURIComponent(token)}`, {
headers,
cache: "no-store",
});
} catch {
return { ok: false, reason: "unavailable" };
}
if (response.status === 401 || response.status === 403 || response.status === 404) {
return { ok: false, reason: "invalid" };
}
if (!response.ok) {
return { ok: false, reason: "unavailable" };
}
try {
return { ok: true, status: await response.json() as OlympusRepairStatus };
} catch {
return { ok: false, reason: "unavailable" };
}
}
export async function submitOlympusEstimateAction(
token: string,
action: EstimateAction,
message?: string,
): Promise<EstimateActionResult> {
const baseUrl = getStatusApiBaseUrl();
if (!baseUrl || !token.trim()) {
return { ok: false, reason: "unavailable" };
}
const headers: HeadersInit = {
Accept: "application/json",
"Content-Type": "application/json",
};
const apiToken = getStatusApiToken();
if (apiToken) {
headers.Authorization = `Bearer ${apiToken}`;
}
let response: Response;
try {
response = await fetch(`${baseUrl}/public/repairs/status/${encodeURIComponent(token)}/estimate/${action}`, {
method: "POST",
headers,
body: JSON.stringify({ message: message?.trim() || null }),
cache: "no-store",
});
} catch {
return { ok: false, reason: "unavailable" };
}
if (response.status === 401 || response.status === 403 || response.status === 404) {
return { ok: false, reason: "invalid" };
}
if (!response.ok) {
return { ok: false, reason: "failed" };
}
return { ok: true };
}

View file

@ -1,63 +0,0 @@
import { existsSync } from "fs";
import { access, copyFile, mkdir, readdir, rm, writeFile } from "fs/promises";
import path from "path";
import { appVersion } from "./version";
export const dataDirectory = path.join(process.cwd(), "data");
export const storageDirectory = path.join(process.cwd(), "storage");
export const configDirectory = path.join(storageDirectory, "config");
export const contentDirectory = path.join(storageDirectory, "content");
export const contentBackupDirectory = path.join(contentDirectory, "backups");
export const uploadDirectory = path.join(storageDirectory, "uploads", "images");
export const legacyPublicUploadDirectory = path.join(process.cwd(), "public", "uploads", "images");
export function adminConfigurationStatus() {
return process.env.ADMIN_EMAIL && process.env.ADMIN_PASSWORD && process.env.ADMIN_SESSION_SECRET ? "configured" : "missing";
}
export function isDockerEnvironment() {
return process.env.DOCKER_ENV === "true" || process.env.CONTAINER === "true" || existsSync("/.dockerenv");
}
export async function ensureRuntimeDirectories() {
await mkdir(dataDirectory, { recursive: true });
await mkdir(configDirectory, { recursive: true });
await mkdir(contentBackupDirectory, { recursive: true });
await mkdir(uploadDirectory, { recursive: true });
await migrateLegacyUploads();
}
export async function checkStorage() {
await ensureRuntimeDirectories();
const probe = path.join(dataDirectory, `.storage-check-${Date.now()}`);
await writeFile(probe, "ok", "utf8");
await rm(probe, { force: true });
await access(uploadDirectory);
return "ok";
}
export function olympusStatus() {
return process.env.OLYMPUS_INTAKE_API_URL && process.env.OLYMPUS_INTAKE_API_TOKEN ? "configured" : "not_configured";
}
export function smtpStatus() {
return "prepared";
}
export async function migrateLegacyUploads() {
if (!existsSync(legacyPublicUploadDirectory)) return;
await mkdir(uploadDirectory, { recursive: true });
const entries = await readdir(legacyPublicUploadDirectory, { withFileTypes: true });
await Promise.all(entries.map(async (entry) => {
if (!entry.isFile() || entry.name.startsWith(".")) return;
const source = path.join(legacyPublicUploadDirectory, entry.name);
const target = path.join(uploadDirectory, entry.name);
if (!existsSync(target)) await copyFile(source, target);
}));
}
export { appVersion };

View file

@ -1 +0,0 @@
export const appVersion = "0.4.1";

32
package-lock.json generated
View file

@ -1,16 +1,14 @@
{
"name": "funktechnik-schubert-website",
"version": "0.4.1",
"version": "0.1.0",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "funktechnik-schubert-website",
"version": "0.4.1",
"version": "0.1.0",
"dependencies": {
"@types/nodemailer": "^8.0.1",
"next": "16.2.10",
"nodemailer": "^9.0.3",
"react": "19.2.3",
"react-dom": "19.2.3"
},
@ -1345,20 +1343,12 @@
"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",
@ -2711,9 +2701,9 @@
}
},
"node_modules/electron-to-chromium": {
"version": "1.5.387",
"resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.387.tgz",
"integrity": "sha512-TaxwufTFDufvPEoXdhwVrA3UdFWBeWGkYoJ1K8ldF1xe6gKfth6iRNS5lTQ5JPNOHdGQm8PT1QYKUqFLCiUefQ==",
"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==",
"dev": true,
"license": "ISC"
},
@ -4679,15 +4669,6 @@
"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",
@ -5931,6 +5912,7 @@
"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.4.1",
"version": "0.1.0",
"private": true,
"scripts": {
"dev": "next dev -p 3010",
@ -9,9 +9,7 @@
"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

@ -1,22 +0,0 @@
import { NextResponse, type NextRequest } from "next/server";
const adminSessionCookie = "fs_admin_session";
export function proxy(request: NextRequest) {
const { pathname } = request.nextUrl;
if (!pathname.startsWith("/admin") || pathname === "/admin/login") return NextResponse.next();
if (!request.cookies.get(adminSessionCookie)?.value) {
const loginUrl = request.nextUrl.clone();
loginUrl.pathname = "/admin/login";
loginUrl.search = "";
loginUrl.searchParams.set("next", pathname);
return NextResponse.redirect(loginUrl);
}
return NextResponse.next();
}
export const config = {
matcher: ["/admin/:path*"],
};

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.6 MiB

View file

@ -1,13 +0,0 @@
#!/usr/bin/env sh
set -eu
BACKUP_DIR="${BACKUP_DIR:-backups}"
STAMP="$(date +%Y%m%d-%H%M%S)"
TARGET="${BACKUP_DIR}/funktechnik-data-${STAMP}.tar.gz"
mkdir -p "$BACKUP_DIR"
tar -czf "$TARGET" data storage
echo "Backup erstellt: $TARGET"
echo "Hinweis: .env wird aus Sicherheitsgruenden nicht automatisch gesichert."
echo "Bitte .env separat und sicher ausserhalb des Repositories sichern."

View file

@ -1,19 +0,0 @@
#!/usr/bin/env sh
set -eu
COMPOSE="${COMPOSE:-docker compose}"
BASE_URL="${BASE_URL:-http://127.0.0.1:3010}"
echo "Pruefe Docker Compose Konfiguration..."
$COMPOSE config >/dev/null
echo "Baue Container..."
$COMPOSE build
echo "Starte Container..."
$COMPOSE up -d
echo "Fuehre Healthcheck aus..."
BASE_URL="$BASE_URL" scripts/healthcheck.sh
echo "Deployment abgeschlossen."

View file

@ -1,14 +0,0 @@
#!/usr/bin/env sh
set -eu
BASE_URL="${BASE_URL:-http://127.0.0.1:3010}"
HEALTH_URL="${BASE_URL%/}/api/health"
echo "Checking ${HEALTH_URL}"
response="$(curl -fsS "$HEALTH_URL")"
echo "$response"
case "$response" in
*'"status":"ok"'*) exit 0 ;;
*) echo "Healthcheck failed" >&2; exit 1 ;;
esac

View file

@ -1,17 +0,0 @@
#!/usr/bin/env sh
set -eu
if [ "${1:-}" = "" ]; then
echo "Usage: scripts/restore.sh <backup.tar.gz>" >&2
exit 1
fi
BACKUP_FILE="$1"
if [ ! -f "$BACKUP_FILE" ]; then
echo "Backup nicht gefunden: $BACKUP_FILE" >&2
exit 1
fi
tar -xzf "$BACKUP_FILE"
echo "Restore abgeschlossen: $BACKUP_FILE"

View file

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

View file

@ -1 +0,0 @@

View file

@ -1 +0,0 @@

View file

@ -1 +0,0 @@

View file

@ -1 +0,0 @@