diff --git a/.dockerignore b/.dockerignore index 145fa92..9b5cf41 100644 --- a/.dockerignore +++ b/.dockerignore @@ -6,10 +6,8 @@ npm-debug.log* .git README.md data/*.json -storage/uploads/images/* -storage/config/* -!storage/config/.gitkeep -!storage/uploads/images/.gitkeep +public/uploads/images/* +!public/uploads/images/.gitkeep *.tmp *.temp .DS_Store diff --git a/.env.example b/.env.example index 9046cd5..e020556 100644 --- a/.env.example +++ b/.env.example @@ -25,13 +25,3 @@ 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= diff --git a/.gitignore b/.gitignore index 7fccc25..eb72981 100644 --- a/.gitignore +++ b/.gitignore @@ -9,17 +9,9 @@ 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 +public/uploads/images/* +!public/uploads/.gitkeep +!public/uploads/images/.gitkeep .DS_Store *.tmp *.temp diff --git a/Dockerfile b/Dockerfile index 2dffadb..90bfbd4 100644 --- a/Dockerfile +++ b/Dockerfile @@ -14,7 +14,7 @@ FROM node:22-alpine AS runner WORKDIR /app ENV NODE_ENV=production ENV NEXT_TELEMETRY_DISABLED=1 -ENV NEXT_PUBLIC_APP_VERSION=0.4.1 +ENV NEXT_PUBLIC_APP_VERSION=0.2.2 ENV DOCKER_ENV=true ENV PORT=3010 @@ -26,8 +26,8 @@ COPY --from=builder --chown=nextjs:nodejs /app/.next/standalone ./ COPY --from=builder --chown=nextjs:nodejs /app/.next/static ./.next/static COPY --from=builder --chown=nextjs:nodejs /app/scripts/runtime-start.js ./runtime-start.js -RUN mkdir -p /app/data /app/storage/config /app/storage/content/backups /app/storage/uploads/images /app/.next/cache \ - && chown -R nextjs:nodejs /app/data /app/storage /app/.next/cache +RUN mkdir -p /app/data /app/public/uploads/images /app/.next/cache \ + && chown -R nextjs:nodejs /app/data /app/public/uploads /app/.next/cache USER nextjs EXPOSE 3010 diff --git a/README.md b/README.md index 8ccb7db..f2de241 100644 --- a/README.md +++ b/README.md @@ -8,12 +8,12 @@ Eigenständige öffentliche Firmenwebsite für Funktechnik Schubert. Dieses Proj - TypeScript strict - Serverseitige API-Routen für Kontakt und Reparaturannahme - Geschützter Admin-Bereich unter `/admin` -- Lokale Storage-Datenablage für Anfragen, Einstellungen, Medien und Website-Inhalte +- Lokale Foundation-Datenablage für Anfragen und Einstellungen - Docker - Nginx/Reverse-Proxy-fähig - SEO Metadata, Sitemap und robots.txt -Version: `0.4.1` +Version: `0.2.2` ## Seiten @@ -23,7 +23,6 @@ Version: `0.4.1` - `/reparatur` - `/ueber-uns` - `/kontakt` -- `/status/[token]` - `/impressum` - `/datenschutz` - `/admin/login` @@ -88,7 +87,7 @@ Port: Die Runtime-Daten werden über Docker-Volumes gespeichert: - `funktechnik-data` → `/app/data` -- `funktechnik-storage` → `/app/storage` +- `funktechnik-uploads` → `/app/public/uploads/images` - `funktechnik-next-cache` → `/app/.next/cache` Dadurch sind keine manuellen `chmod`- oder `chown`-Befehle notwendig. @@ -110,12 +109,8 @@ Variablen: - `AUTH_COOKIE_SECURE`: `true` in Produktion mit HTTPS, lokal `false` - `OLYMPUS_INTAKE_API_URL`: vorbereitet für spätere serverseitige Olympus-Anbindung - `OLYMPUS_INTAKE_API_TOKEN`: vorbereitet für spätere serverseitige Olympus-Anbindung -- `OLYMPUS_PUBLIC_STATUS_API_URL`: serverseitig erreichbare Olympus-Basis-URL für öffentliche Reparaturstatuslinks -- `OLYMPUS_PUBLIC_STATUS_API_TOKEN`: optionaler serverseitiger Token für spätere geschützte Status-API-Zugriffe -Die 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. +Die Olympus-Variablen sind nur für eine spätere serverseitige Integration vorbereitet. Sie werden nicht im Browser verwendet. Für lokale Entwicklung kann `AUTH_COOKIE_SECURE=false` bleiben. Produktiv muss HTTPS verwendet und `AUTH_COOKIE_SECURE=true` gesetzt werden. `ADMIN_SESSION_SECRET` muss produktiv ein langer zufälliger Wert sein. @@ -130,62 +125,8 @@ Für lokale Entwicklung kann `AUTH_COOKIE_SECURE=false` bleiben. Produktiv muss - `POST /api/admin/settings` - `POST /api/admin/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/ -``` - -Die Seite ruft Olympus ausschließlich serverseitig auf: - -```text -${OLYMPUS_PUBLIC_STATUS_API_URL}/public/repairs/status/ -``` - -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//estimate/approve -POST /api/status//estimate/decline -POST /api/status//estimate/question -``` - -Die Website leitet diese Anfragen serverseitig an Olympus weiter: - -```text -${OLYMPUS_PUBLIC_STATUS_API_URL}/public/repairs/status//estimate/ -``` - -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 @@ -197,14 +138,14 @@ Funktionen: - Dashboard mit Kennzahlen - Kontaktanfragen verwalten - Reparaturanfragen verwalten -- Website-Inhalte pflegen und sofort veröffentlichen +- Website-Inhaltsverwaltung vorbereitet - Firmendaten pflegen -- SMTP-Konfiguration speichern und Testmail senden +- SMTP-Konfiguration vorbereiten - 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. +SMTP-Versand, Publishing von Website-Inhalten und Olympus-Übernahme sind bewusst noch nicht aktiv gekoppelt. ## Runtime Data @@ -222,51 +163,10 @@ Echte Runtime-Dateien werden nicht committed: data/contact-inquiries.json data/repair-inquiries.json data/site-settings.json +data/smtp-settings.json ``` -SMTP-Konfiguration wird unter `storage/config/smtp.json` gespeichert und nicht committed. Das SMTP-Passwort wird nicht im Admin-Formular ausgegeben. - -Uploads 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. +Uploads unter `public/uploads/images/` werden ebenfalls nicht committed. ## Healthcheck @@ -285,50 +185,13 @@ Antwort: ```json { "status": "ok", - "version": "0.4.1", + "version": "0.2.2", "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 @@ -360,7 +223,7 @@ scripts/healthcheck.sh scripts/backup.sh ``` -Das Backup enthält `data/` und `storage/`. Die `.env` wird bewusst nicht automatisch gesichert. Sie muss separat sicher abgelegt werden. +Das Backup enthält `data/` und `public/uploads/`. Die `.env` wird bewusst nicht automatisch gesichert. Sie muss separat sicher abgelegt werden. ## Restore @@ -394,5 +257,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 diff --git a/app/admin/page.tsx b/app/admin/page.tsx index 5062996..b479b0d 100644 --- a/app/admin/page.tsx +++ b/app/admin/page.tsx @@ -1,11 +1,7 @@ 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(items: T[]) { return items.filter((item) => item.status === "new").length; @@ -13,23 +9,8 @@ function countNew(items: T[]) { 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 [contacts, repairs] = await Promise.all([getContactInquiries(), getRepairInquiries()]); 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 ( @@ -38,13 +19,10 @@ export default async function AdminDashboardPage() {

Dashboard

-
Kontaktanfragen{contacts.length}{countNew(contacts)} neu
-
Reparaturanfragen{repairs.length}{countNew(repairs)} neu
-
Medien{mediaFiles.length}im Storage
-
Content{contentSummary.pageCount}{contentModified}
-
SMTP{isSmtpConfigured(smtpSettings) ? "OK" : "Fehlt"}{smtpSettings.lastTestStatus ?? "kein Test"}
-
Versionv{appVersion}Website
-
Health{healthStatus}Storage und Runtime
+
Kontaktanfragen{countNew(contacts)}neu
+
Reparaturanfragen{countNew(repairs)}neu
+
WebsiteOnlineOK
+
SMTPVorbereitetnicht aktiv

Letzte Anfragen

diff --git a/app/admin/smtp/page.tsx b/app/admin/smtp/page.tsx index 662eeb9..ae13637 100644 --- a/app/admin/smtp/page.tsx +++ b/app/admin/smtp/page.tsx @@ -1,8 +1,7 @@ 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"; +import { getSmtpSettings } from "@/lib/admin/store"; export default async function AdminSmtpPage() { if (!await requireAdminSession()) redirect("/admin/login"); @@ -14,7 +13,16 @@ export default async function AdminSmtpPage() {

E-Mail

SMTP Einstellungen

- +
+ + + + + + + + +
); } diff --git a/app/admin/system/page.tsx b/app/admin/system/page.tsx index 08aff5a..4e9b3ad 100644 --- a/app/admin/system/page.tsx +++ b/app/admin/system/page.tsx @@ -1,9 +1,7 @@ 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"; +import { adminConfigurationStatus, appVersion, checkStorage, dataDirectory, isDockerEnvironment, olympusStatus, smtpStatus, uploadDirectory } from "@/lib/runtime/config"; export default async function AdminSystemPage() { if (!await requireAdminSession()) redirect("/admin/login"); @@ -14,8 +12,6 @@ export default async function AdminSystemPage() { } catch { storage = "error"; } - const [smtpSettings, contentStatus] = await Promise.all([getSmtpSettings(), getContentSystemStatus()]); - const smtpConfigured = isSmtpConfigured(smtpSettings); return ( @@ -31,43 +27,20 @@ export default async function AdminSystemPage() {
Docker Environment
{isDockerEnvironment() ? "ja" : "nein"}
Storage Status
{storage}
Data Directory
{dataDirectory}
-
Config Directory
{configDirectory}
Upload Directory
{uploadDirectory}
Admin Konfiguration
{adminConfigurationStatus()}
Olympus Verbindung
{olympusStatus()}
-
SMTP
{smtpConfigured ? "configured" : "missing"}
-
Letzte SMTP-Testmail
{smtpSettings.lastTestAt ? `${new Date(smtpSettings.lastTestAt).toLocaleString("de-DE")} (${smtpSettings.lastTestStatus})` : "keine"}
-
Letzter SMTP-Formularversand
{smtpSettings.lastDeliveryAt ? `${new Date(smtpSettings.lastDeliveryAt).toLocaleString("de-DE")} (${smtpSettings.lastDeliveryStatus})` : "keiner"}
-
Content Version
{contentStatus.version}
-
Letzte Content-Änderung
{contentStatus.lastModified ? new Date(contentStatus.lastModified).toLocaleString("de-DE") : "keine"}
-
Anzahl Seiten
{contentStatus.pageCount}
-
Anzahl Medien
{contentStatus.mediaCount}
+
SMTP
{smtpStatus()}

Betriebsregeln

  • Runtime-Daten liegen unter data/ und werden nicht versioniert.
  • -
  • Uploads liegen unter storage/uploads/images/ und werden nicht versioniert.
  • -
  • SMTP-Versand ist serverseitig aktiv, sofern eine vollständige Konfiguration gespeichert ist.
  • +
  • Uploads liegen unter public/uploads/images/ und werden nicht versioniert.
  • +
  • Olympus- und SMTP-Integration sind vorbereitet, aber nicht aktiv implementiert.
-
-

Letzte Content-Backups

- - - - {contentStatus.backups.map((backup) => ( - - - - - - ))} - {contentStatus.backups.length === 0 && } - -
SeiteDatumDateiname
{backup.page}{new Date(backup.createdAt).toLocaleString("de-DE")}{backup.fileName}
Noch keine Content-Backups vorhanden.
-
); } diff --git a/app/admin/website/page.tsx b/app/admin/website/page.tsx index 4fd4f99..f3d309f 100644 --- a/app/admin/website/page.tsx +++ b/app/admin/website/page.tsx @@ -1,13 +1,18 @@ 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"; + +const editablePages = [ + ["Startseite", "Hero, Vorteile, Textblöcke"], + ["Leistungen", "Funktechnik, Messtechnik, Servicebereiche"], + ["Funkgeräte-Service", "Diagnose, Abgleich, Fehlerbilder"], + ["Reparatur", "Reparaturannahme und Hinweise"], + ["Über uns", "Profil und Werkstattbeschreibung"], + ["Kontakt", "Kontakttext und Hinweise"], +] as const; export default async function AdminWebsitePage() { if (!await requireAdminSession()) redirect("/admin/login"); - const [content, mediaFiles] = await Promise.all([getAllContent(), listMediaFiles()]); return ( @@ -15,7 +20,19 @@ export default async function AdminWebsitePage() {

Website

Website-Inhalte

- +
+

Editierbare Inhalte

+

Diese Foundation bereitet die Inhaltsverwaltung vor. Die öffentliche Website bleibt bis zur finalen Publishing-Funktion weiterhin quellcodebasiert.

+
+ {editablePages.map(([title, description]) => ( +
+

{title}

+

{description}

+ +
+ ))} +
+
); } diff --git a/app/api/admin/content/route.ts b/app/api/admin/content/route.ts deleted file mode 100644 index 5725549..0000000 --- a/app/api/admin/content/route.ts +++ /dev/null @@ -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 }); -} diff --git a/app/api/admin/smtp/route.ts b/app/api/admin/smtp/route.ts index 5b4f368..ea05bb2 100644 --- a/app/api/admin/smtp/route.ts +++ b/app/api/admin/smtp/route.ts @@ -1,26 +1,9 @@ import { NextResponse } from "next/server"; import { requireAdminSession } from "@/lib/admin/auth"; -import { saveSmtpSettings, toPublicSmtpSettings } from "@/lib/mail/config"; -import { sendTestMail } from "@/lib/mail/smtp"; +import { saveSmtpSettings } from "@/lib/admin/store"; 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), - }); + await saveSmtpSettings(await request.formData()); + return new NextResponse(null, { status: 303, headers: { Location: "/admin/smtp?saved=1" } }); } diff --git a/app/api/contact/route.ts b/app/api/contact/route.ts index 6ea6537..ee384f7 100644 --- a/app/api/contact/route.ts +++ b/app/api/contact/route.ts @@ -1,6 +1,5 @@ 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() : ""; @@ -13,12 +12,11 @@ export async function POST(request: Request) { const subject = text(form.get("subject")); const message = text(form.get("message")); - if (!name || !email.includes("@") || !subject || message.length < 10 || form.get("privacyAccepted") !== "on") { + if (!name || !email.includes("@") || !subject || message.length < 10 || form.get("privacy") !== "on") { return NextResponse.json({ message: "Bitte füllen Sie alle Pflichtfelder aus." }, { status: 400 }); } - const inquiry = await addContactInquiry(form); - await sendContactInquiryMail(inquiry); + await addContactInquiry(form); return NextResponse.json({ message: "Ihre Nachricht wurde erfasst. Sie erhalten eine Rückmeldung.", diff --git a/app/api/content/settings/route.ts b/app/api/content/settings/route.ts deleted file mode 100644 index 9da8ad9..0000000 --- a/app/api/content/settings/route.ts +++ /dev/null @@ -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 }); -} diff --git a/app/api/health/route.ts b/app/api/health/route.ts index 68745af..30791a0 100644 --- a/app/api/health/route.ts +++ b/app/api/health/route.ts @@ -1,6 +1,5 @@ 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"; @@ -11,14 +10,11 @@ export async function GET() { storage = "error"; } - const smtpSettings = await getSmtpSettings(); - return NextResponse.json({ status: storage === "ok" ? "ok" : "error", version: appVersion, storage, admin: adminConfigurationStatus(), - smtp: isSmtpConfigured(smtpSettings) ? "configured" : "missing", timestamp: new Date().toISOString(), }); } diff --git a/app/api/media/[filename]/route.ts b/app/api/media/[filename]/route.ts deleted file mode 100644 index 4d4c315..0000000 --- a/app/api/media/[filename]/route.ts +++ /dev/null @@ -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", - }, - }); -} diff --git a/app/api/repair/route.ts b/app/api/repair/route.ts index 81c771c..6b98806 100644 --- a/app/api/repair/route.ts +++ b/app/api/repair/route.ts @@ -1,6 +1,5 @@ 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 +13,13 @@ 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); + await addRepairInquiry(form); return NextResponse.json({ message: "Ihre Reparaturanfrage wurde erfasst. Sie erhalten nach Prüfung eine Rückmeldung.", diff --git a/app/api/status/[token]/estimate/action-response.ts b/app/api/status/[token]/estimate/action-response.ts deleted file mode 100644 index 27e9345..0000000 --- a/app/api/status/[token]/estimate/action-response.ts +++ /dev/null @@ -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 ""; - } -} diff --git a/app/api/status/[token]/estimate/approve/route.ts b/app/api/status/[token]/estimate/approve/route.ts deleted file mode 100644 index a3ea9c8..0000000 --- a/app/api/status/[token]/estimate/approve/route.ts +++ /dev/null @@ -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."); -} diff --git a/app/api/status/[token]/estimate/decline/route.ts b/app/api/status/[token]/estimate/decline/route.ts deleted file mode 100644 index 0e18d1a..0000000 --- a/app/api/status/[token]/estimate/decline/route.ts +++ /dev/null @@ -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."); -} diff --git a/app/api/status/[token]/estimate/question/route.ts b/app/api/status/[token]/estimate/question/route.ts deleted file mode 100644 index 4ff10f4..0000000 --- a/app/api/status/[token]/estimate/question/route.ts +++ /dev/null @@ -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."); -} diff --git a/app/datenschutz/page.tsx b/app/datenschutz/page.tsx index 31e2d04..265ff71 100644 --- a/app/datenschutz/page.tsx +++ b/app/datenschutz/page.tsx @@ -10,15 +10,15 @@ export default function DatenschutzPage() { Informationen zur Verarbeitung personenbezogener Daten.
-

Datenschutzhinweise müssen vor produktiver Veröffentlichung durch den Betreiber ergänzt und rechtlich geprüft werden.

+

TODO: Rechtliche Angaben ergänzen

Verantwortlicher

-

Die Angaben zum Verantwortlichen werden durch den Betreiber gepflegt.

+

TODO: Rechtliche Angaben ergänzen

Kontakt- und Reparaturanfragen

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.

Hosting und Server-Logs

-

Informationen zum Hosting und zur Verarbeitung technischer Serverdaten werden durch den Betreiber gepflegt.

+

TODO: Rechtliche Angaben ergänzen

Betroffenenrechte

-

Hinweise zu Betroffenenrechten werden durch den Betreiber rechtlich geprüft und gepflegt.

+

TODO: Rechtliche Angaben ergänzen

diff --git a/app/funkgeraete-service/page.tsx b/app/funkgeraete-service/page.tsx index e814446..ef23bd7 100644 --- a/app/funkgeraete-service/page.tsx +++ b/app/funkgeraete-service/page.tsx @@ -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 ( <> - - {content.subtitle} + + Funkgeräte zeigen Fehler oft erst im Zusammenspiel von Empfang, Sendeteil, Versorgung, Antennenanpassung, Bedienung und Abgleich. Der Service betrachtet diese Zusammenhänge strukturiert.
-

{content.listTitle}

+

Typische Fehlerbilder

    - {content.symptoms.map((symptom) =>
  • {symptom}
  • )} +
  • 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
-

{content.cta.text}

-

{content.intro}

-
    - {content.measurements.map((measurement) =>
  • {measurement}
  • )} -
-

{content.alignment}

-

{content.repair}

- {content.cta.primary.label} +

Messung und Einordnung

+

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.

+ Reparatur anfragen
diff --git a/app/globals.css b/app/globals.css index 174bfdc..25bc51b 100644 --- a/app/globals.css +++ b/app/globals.css @@ -488,372 +488,6 @@ h3 { line-height: 1; } -.status-page { - min-height: calc(100vh - 78px); - padding: 72px 0; - background: - linear-gradient(135deg, rgba(244, 246, 248, 0.96), rgba(232, 238, 246, 0.94)), - url("/workbench-signal.svg"); - background-position: center; - background-size: cover; -} - -.status-page-inner { - display: grid; - align-items: start; -} - -.status-card { - border: 1px solid rgba(8, 42, 96, 0.12); - border-radius: var(--radius); - background: rgba(255, 255, 255, 0.97); - padding: 34px; - box-shadow: var(--shadow); -} - -.status-card-narrow { - width: min(760px, 100%); - margin: 0 auto; -} - -.status-card h1 { - margin-bottom: 10px; - color: var(--ink); - font-size: clamp(36px, 5vw, 58px); - line-height: 1; -} - -.status-title-row { - display: flex; - align-items: flex-start; - justify-content: space-between; - gap: 24px; - margin-bottom: 26px; -} - -.status-badge, -.status-pill { - display: inline-flex; - min-height: 32px; - align-items: center; - border-radius: 999px; - border: 1px solid rgba(30, 159, 184, 0.24); - background: rgba(30, 159, 184, 0.1); - color: var(--navy); - font-size: 13px; - font-weight: 800; - padding: 0 12px; - white-space: nowrap; -} - -.status-summary { - display: grid; - grid-template-columns: repeat(4, minmax(0, 1fr)); - gap: 14px; - margin: 0 0 34px; -} - -.status-summary div { - border: 1px solid var(--line); - border-radius: var(--radius); - background: #f8fafc; - padding: 16px; -} - -.status-summary dt { - margin-bottom: 8px; - color: var(--muted); - font-size: 12px; - font-weight: 800; - text-transform: uppercase; -} - -.status-summary dd { - margin: 0; - color: var(--ink); - font-size: 17px; - font-weight: 800; -} - -.status-timeline-section h2 { - margin-bottom: 20px; - font-size: clamp(26px, 3vw, 34px); -} - -.public-status-timeline { - display: grid; - gap: 0; - margin: 0; - padding: 0; - list-style: none; -} - -.public-status-timeline li { - position: relative; - border-left: 2px solid #d7e0ea; - padding: 0 0 18px 26px; -} - -.public-status-timeline li:last-child { - padding-bottom: 0; -} - -.timeline-dot { - position: absolute; - left: -8px; - top: 15px; - width: 14px; - height: 14px; - border: 3px solid #fff; - border-radius: 999px; - background: var(--steel); - box-shadow: 0 0 0 1px rgba(8, 42, 96, 0.12); -} - -.public-status-timeline li.is-current .timeline-dot { - background: var(--orange); -} - -.timeline-content { - display: flex; - align-items: flex-start; - justify-content: space-between; - gap: 18px; - border: 1px solid var(--line); - border-radius: var(--radius); - background: #fff; - padding: 16px; -} - -.timeline-title { - margin-bottom: 8px; - color: var(--ink); - font-size: 17px; - font-weight: 800; -} - -.timeline-content time, -.status-muted { - color: var(--muted); - font-size: 14px; -} - -.estimate-card { - display: grid; - gap: 22px; - border: 1px solid rgba(8, 42, 96, 0.12); - border-radius: var(--radius); - background: #f8fafc; - margin: 0 0 34px; - padding: 24px; -} - -.estimate-header, -.estimate-item, -.estimate-totals div, -.estimate-form-actions { - display: flex; - align-items: flex-start; - justify-content: space-between; - gap: 18px; -} - -.estimate-header h2 { - margin: 0 0 8px; - color: var(--ink); - font-size: clamp(26px, 3vw, 34px); -} - -.estimate-status { - display: inline-flex; - min-height: 32px; - align-items: center; - border: 1px solid rgba(232, 124, 46, 0.28); - border-radius: 999px; - background: rgba(232, 124, 46, 0.12); - color: #8b420d; - font-size: 13px; - font-weight: 800; - padding: 0 12px; - white-space: nowrap; -} - -.estimate-message, -.estimate-status-text { - border-left: 4px solid var(--steel); - background: #fff; - color: var(--ink); - margin: 0; - padding: 14px 16px; -} - -.estimate-meta { - display: grid; - grid-template-columns: repeat(2, minmax(0, 1fr)); - gap: 12px; - margin: 0; -} - -.estimate-meta div { - border: 1px solid var(--line); - border-radius: 8px; - background: #fff; - padding: 14px; -} - -.estimate-meta dt, -.estimate-totals dt { - color: var(--muted); - font-size: 12px; - font-weight: 800; - text-transform: uppercase; -} - -.estimate-meta dd, -.estimate-totals dd { - margin: 6px 0 0; - color: var(--ink); - font-weight: 800; -} - -.estimate-items { - display: grid; - gap: 12px; -} - -.estimate-item { - border: 1px solid var(--line); - border-radius: 8px; - background: #fff; - padding: 16px; -} - -.estimate-item h3 { - margin: 6px 0; - color: var(--ink); - font-size: 18px; -} - -.estimate-item p { - margin: 0; - color: var(--muted); -} - -.estimate-item-type { - color: var(--steel); - font-size: 12px; - font-weight: 800; - text-transform: uppercase; -} - -.estimate-item-price { - display: grid; - gap: 6px; - min-width: 150px; - text-align: right; -} - -.estimate-item-price span { - color: var(--muted); - font-size: 14px; -} - -.estimate-item-price strong { - color: var(--ink); - font-size: 18px; -} - -.estimate-totals { - display: grid; - gap: 8px; - width: min(380px, 100%); - justify-self: end; - margin: 0; -} - -.estimate-totals div { - border-bottom: 1px solid var(--line); - padding: 8px 0; -} - -.estimate-totals .estimate-total-highlight { - border-bottom: 0; - color: var(--ink); - font-size: 20px; -} - -.estimate-actions-panel { - display: grid; - gap: 16px; - border-top: 1px solid var(--line); - padding-top: 18px; -} - -.estimate-action-buttons { - display: flex; - flex-wrap: wrap; - gap: 12px; -} - -.button.danger { - border-color: #a33a2d; - background: #a33a2d; - color: #fff; -} - -.button.danger:hover { - background: #842f25; -} - -.button:disabled { - cursor: not-allowed; - opacity: 0.64; -} - -.estimate-action-form { - display: grid; - gap: 10px; - border: 1px solid var(--line); - border-radius: 8px; - background: #fff; - padding: 16px; -} - -.estimate-action-form label { - color: var(--ink); - font-weight: 800; -} - -.estimate-action-form textarea { - width: 100%; - border: 1px solid #cfd8e5; - border-radius: 8px; - color: var(--ink); - padding: 12px; - resize: vertical; -} - -.estimate-form-actions { - justify-content: flex-start; - flex-wrap: wrap; -} - -.estimate-feedback { - border: 1px solid rgba(46, 125, 50, 0.24); - border-radius: 8px; - background: rgba(46, 125, 50, 0.08); - color: #1b5e20; - font-weight: 800; - margin: 0; - padding: 12px 14px; -} - -.estimate-feedback.error { - border-color: rgba(163, 58, 45, 0.26); - background: rgba(163, 58, 45, 0.08); - color: #842f25; -} - .admin-login-page { min-height: 100vh; display: grid; @@ -996,7 +630,7 @@ h3 { .admin-kpis { display: grid; - grid-template-columns: repeat(auto-fit, minmax(180px, 1fr)); + grid-template-columns: repeat(4, minmax(0, 1fr)); gap: 14px; margin-bottom: 18px; } @@ -1351,201 +985,6 @@ h3 { overflow-wrap: anywhere; } -.content-manager { - display: grid; - gap: 18px; -} - -.content-editor-layout { - display: grid; - grid-template-columns: 220px minmax(0, 1fr); - gap: 18px; - align-items: start; -} - -.content-tabs { - position: sticky; - top: 18px; - display: grid; - gap: 6px; -} - -.content-tabs button, -.content-preview-toolbar button { - border: 1px solid var(--line); - border-radius: 8px; - background: #fff; - color: var(--navy); - font-weight: 800; - padding: 10px 12px; - text-align: left; -} - -.content-tabs button[aria-current="page"], -.content-preview-toolbar button[aria-current="true"] { - border-color: var(--navy); - background: var(--navy); - color: #fff; -} - -.content-editor { - display: grid; - gap: 16px; - min-width: 0; -} - -.content-editor-top { - display: flex; - align-items: center; - justify-content: space-between; - gap: 16px; -} - -.content-editor-top h2 { - margin-bottom: 0; -} - -.content-save-actions { - display: flex; - justify-content: flex-end; -} - -.content-editor-heading { - margin: 14px 0 0; - border-top: 1px solid var(--line); - padding-top: 16px; - color: var(--navy); -} - -.content-field-grid { - display: grid; - grid-template-columns: repeat(2, minmax(0, 1fr)); - gap: 14px; -} - -.content-field-grid label, -.content-editor label { - display: grid; - gap: 7px; - color: var(--ink); - font-size: 14px; - font-weight: 800; -} - -.content-field-grid input, -.content-field-grid select, -.content-field-grid textarea, -.content-editor input, -.content-editor select, -.content-editor textarea { - width: 100%; - border: 1px solid #cfd8e5; - border-radius: 8px; - background: #fff; - padding: 11px 12px; - color: var(--ink); -} - -.content-editor textarea { - min-height: 110px; - resize: vertical; -} - -.content-repeaters { - display: grid; - gap: 12px; -} - -.content-repeater { - display: grid; - gap: 12px; - border: 1px solid var(--line); - border-radius: 8px; - background: #f8fafc; - padding: 14px; -} - -.content-image-picker { - display: grid; - gap: 10px; -} - -.content-image-preview { - display: grid; - grid-template-columns: minmax(140px, 240px) auto; - gap: 12px; - align-items: end; -} - -.content-image-preview img { - width: 100%; - aspect-ratio: 16 / 9; - border: 1px solid var(--line); - border-radius: 8px; - background: #f8fafc; - object-fit: cover; -} - -.content-preview-card { - overflow: hidden; -} - -.content-preview-toolbar { - display: flex; - align-items: center; - justify-content: space-between; - gap: 16px; - margin-bottom: 18px; -} - -.content-preview-toolbar h2 { - margin-bottom: 0; -} - -.content-preview-toolbar .eyebrow { - margin-bottom: 4px; -} - -.content-preview-toolbar div { - display: flex; - flex-wrap: wrap; - gap: 8px; -} - -.content-preview { - width: 100%; - max-width: 100%; - min-height: 320px; - border: 1px solid var(--line); - border-radius: 8px; - background: - linear-gradient(90deg, rgba(6, 24, 52, 0.95), rgba(8, 42, 96, 0.72)), - url("/workbench-signal.svg"), - #07172d; - background-size: cover; - color: #fff; - padding: 34px; -} - -.content-preview.tablet { - max-width: 760px; - margin: 0 auto; -} - -.content-preview.mobile { - max-width: 390px; - margin: 0 auto; -} - -.content-preview h1 { - font-size: clamp(30px, 5vw, 52px); -} - -.content-preview .lead, -.content-preview p { - color: rgba(255, 255, 255, 0.82); -} - @media (max-width: 1080px) and (min-width: 861px) { .brand-logo { width: 236px; @@ -1629,44 +1068,10 @@ h3 { .admin-shell, .admin-kpis, .admin-detail-grid, - .media-grid, - .status-summary { + .media-grid { grid-template-columns: 1fr; } - .status-page { - padding: 38px 0; - } - - .status-card { - padding: 22px; - } - - .status-title-row, - .timeline-content, - .estimate-header, - .estimate-item { - display: grid; - } - - .status-badge, - .estimate-status { - width: fit-content; - } - - .estimate-meta { - grid-template-columns: 1fr; - } - - .estimate-item-price { - min-width: 0; - text-align: left; - } - - .estimate-totals { - justify-self: stretch; - } - .admin-sidebar { position: static; height: auto; @@ -1690,29 +1095,4 @@ h3 { grid-template-columns: 1fr; gap: 4px; } - - .content-editor-layout { - grid-template-columns: 1fr; - } - - .content-tabs { - position: static; - grid-template-columns: repeat(2, minmax(0, 1fr)); - } - - .content-field-grid { - grid-template-columns: 1fr; - } - - .content-editor-top, - .content-preview-toolbar, - .content-image-preview { - grid-template-columns: 1fr; - align-items: stretch; - } - - .content-editor-top, - .content-preview-toolbar { - display: grid; - } } diff --git a/app/impressum/page.tsx b/app/impressum/page.tsx index 688fb72..b413f8f 100644 --- a/app/impressum/page.tsx +++ b/app/impressum/page.tsx @@ -10,13 +10,13 @@ export default function ImpressumPage() { Rechtliche Angaben für die öffentliche Firmenwebsite.
-

Rechtliche Angaben müssen vor produktiver Veröffentlichung durch den Betreiber ergänzt und geprüft werden.

+

TODO: Rechtliche Angaben ergänzen

Anbieterkennzeichnung

-

Die vollständige Anbieterkennzeichnung wird durch den Betreiber gepflegt.

+

TODO: Rechtliche Angaben ergänzen

Kontakt

-

Die rechtlich relevanten Kontaktdaten werden durch den Betreiber gepflegt.

+

TODO: Rechtliche Angaben ergänzen

Umsatzsteuer / Registerangaben

-

Umsatzsteuer- und Registerangaben werden durch den Betreiber gepflegt, sofern sie erforderlich sind.

+

TODO: Rechtliche Angaben ergänzen

diff --git a/app/kontakt/page.tsx b/app/kontakt/page.tsx index da1c722..67ac7d2 100644 --- a/app/kontakt/page.tsx +++ b/app/kontakt/page.tsx @@ -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 ( <> - - {content.subtitle} + + Beschreiben Sie kurz Ihr Anliegen. Für Reparaturen nutzen Sie idealerweise die strukturierte Reparaturannahme.
-

{content.cta.text}

-

{content.heroText}

- {contactDetails.length > 0 && ( -
    - {contactDetails.map((detail) =>
  • {detail}
  • )} -
- )} - {content.googleMapsLink &&

Karte öffnen

} +

Direkt und technisch

+

Je genauer Gerät, Anliegen und gewünschte Unterstützung beschrieben werden, desto gezielter kann die Rückmeldung erfolgen.

diff --git a/app/layout.tsx b/app/layout.tsx index 7d808f5..55e8ba9 100644 --- a/app/layout.tsx +++ b/app/layout.tsx @@ -1,14 +1,12 @@ import type { Metadata } from "next"; import SiteFrame from "@/components/SiteFrame"; 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 { - 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 ( diff --git a/app/leistungen/page.tsx b/app/leistungen/page.tsx index 092e4e2..7794dad 100644 --- a/app/leistungen/page.tsx +++ b/app/leistungen/page.tsx @@ -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 ( <> - - {content.subtitle} + + Technische Unterstützung für Geräte, bei denen Diagnose, Messtechnik, Erfahrung und saubere Dokumentation zählen.
- {services.map((service) => {service.description})} + {services.map(([title, text]) => {text})}
diff --git a/app/page.tsx b/app/page.tsx index 0095e36..859a1a6 100644 --- a/app/page.tsx +++ b/app/page.tsx @@ -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 ( <> -
+
-

{content.eyebrow}

-

{content.title}

-

{content.subtitle}

-

{content.heroText}

+

Funktechnik, Messtechnik, Werkstattservice

+

Funktechnik Schubert

+

Service, Reparatur und Diagnose für Funkgeräte, Messtechnik und Kommunikationstechnik.

+

Von CB-Funk und Amateurfunk bis zur professionellen Funktechnik: Wir unterstützen bei Fehlersuche, Abgleich, Modulationsproblemen, Frequenzproblemen und technischer Dokumentation.

- {content.badges.map((badge) => {badge})} + CB-Funk + Amateurfunk + Messtechnik + Abgleich + Diagnose + Service Manuals
- {content.cta.primary.label} - {content.cta.secondary.label} + Reparatur anfragen + Leistungen ansehen
@@ -39,11 +29,13 @@ export default async function HomePage() {

Leistungsbereiche

-

{content.cta.text}

-

{content.intro}

+

Werkstattservice für Funktechnik und Messtechnik

+

Von der ersten Fehlerbeschreibung bis zur dokumentierten Prüfung: Funktechnik Schubert verbindet technische Erfahrung, geeignete Messmittel und strukturierte Kommunikation.

- {content.features.map((feature) => {feature.text})} + Sichtprüfung, Funktionsprüfung und technische Beurteilung für CB-Funk, Amateurfunk, Betriebsfunk sowie ältere und moderne Geräte. + Systematische Diagnose bei Empfangsproblemen, Sendeproblemen, PLL-Fehlern, Frequenzabweichungen und Audio-/NF-Störungen. + Frequenzabgleich, Hub- und Modulationsprüfung, Sendeleistung, Empfindlichkeit und RF-Messung mit geeigneter Messtechnik.
@@ -51,17 +43,20 @@ export default async function HomePage() {
-

{content.promise.eyebrow}

-

{content.promise.title}

-

{content.promise.text}

+

Persönlicher Support

+

Direkte technische Einschätzung statt anonymer Abwicklung

+

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.

- {content.promise.button.label} + Reparaturformular öffnen
-

{content.promise.title}

+

Warum Funktechnik Schubert?

    - {content.promise.bullets.map((item) =>
  • {item}
  • )} +
  • 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
diff --git a/app/reparatur/page.tsx b/app/reparatur/page.tsx index ff1cf59..22989f5 100644 --- a/app/reparatur/page.tsx +++ b/app/reparatur/page.tsx @@ -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 ( <> - - {content.subtitle} + + Beschreiben Sie Gerät, Fehlerbild, Zubehör und bisherige Vorarbeiten. Die Anfrage wird strukturiert erfasst und für eine spätere technische Bearbeitung vorbereitet.
-

{paragraph?.title || content.cta.text}

-

{paragraph?.text || content.intro}

+

Vor der Einsendung

+

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.

    - {content.list.map((item) =>
  • {item}
  • )} +
  • 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
diff --git a/app/seo.ts b/app/seo.ts index 8fb7d2d..0d2de93 100644 --- a/app/seo.ts +++ b/app/seo.ts @@ -1,5 +1,4 @@ import type { Metadata } from "next"; -import type { SeoContent } from "@/lib/content/types"; const siteName = "Funktechnik Schubert"; const defaultTitle = "Funktechnik Schubert | Funkgeräte-Service & Messtechnik"; @@ -60,36 +59,4 @@ export function createMetadata(title: string, description: string, path = "/"): }; } -export function createContentMetadata(seo: SeoContent, fallbackSeo?: SeoContent): Metadata { - const resolvedSeo = { - ...fallbackSeo, - ...Object.fromEntries(Object.entries(seo).filter(([, value]) => value.trim() !== "")), - } as SeoContent; - const path = resolvedSeo.canonicalUrl || "/"; - const url = new URL(path, siteUrl).toString(); - const title = resolvedSeo.metaTitle || siteName; - const description = resolvedSeo.metaDescription || defaultTitle; - - return { - ...createMetadata(title, description, path), - keywords: resolvedSeo.keywords ? resolvedSeo.keywords.split(",").map((keyword) => keyword.trim()).filter(Boolean) : keywords, - openGraph: { - type: "website", - locale: "de_DE", - url, - siteName, - title: resolvedSeo.openGraphTitle || title, - description: resolvedSeo.openGraphDescription || description, - images: [ - { - url: resolvedSeo.socialImage || "/funktechnik_schubert_logo.jpg", - width: 1200, - height: 630, - alt: resolvedSeo.openGraphTitle || title, - }, - ], - }, - }; -} - export { defaultTitle, siteName, siteUrl }; diff --git a/app/status/[token]/page.tsx b/app/status/[token]/page.tsx deleted file mode 100644 index 00fdf7a..0000000 --- a/app/status/[token]/page.tsx +++ /dev/null @@ -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 = { - 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 = { - 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 = { - 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 ( -
-
-
-

Kostenvoranschlag

-

{estimate.title || "Kostenvoranschlag"}

-

Nummer {estimate.estimate_number}

-
- {estimateStatusLabel(estimate.status)} -
- - {estimate.customer_message &&

{estimate.customer_message}

} - -
-
-
Gültig bis
-
{formatDateOnly(estimate.valid_until)}
-
-
-
Status
-
{estimateStatusLabel(estimate.status)}
-
-
- -
- {estimate.items.map((item, index) => ( -
-
- {estimateItemTypeLabel(item.item_type)} -

{item.title}

- {item.description &&

{item.description}

} -
-
- - {item.quantity} {item.unit} - - {formatMoney(item.total_cents, estimate.currency)} -
-
- ))} -
- -
-
-
Zwischensumme
-
{formatMoney(estimate.subtotal_cents, estimate.currency)}
-
-
-
Mehrwertsteuer
-
{formatMoney(estimate.tax_cents, estimate.currency)}
-
-
-
Gesamt
-
{formatMoney(estimate.total_cents, estimate.currency)}
-
-
- -

{estimateStatusText(estimate.status)}

- - {estimate.status === "sent" && } -
- ); -} - -function StatusError({ title, text }: { title: string; text: string }) { - return ( -
-
-
-

Reparaturstatus

-

{title}

-

{text}

-
- Reparatur anfragen - Kontakt aufnehmen -
-
-
-
- ); -} - -function StatusTimeline({ status }: { status: OlympusRepairStatus }) { - if (status.status_history_public.length === 0) { - return

Noch keine Statushistorie vorhanden.

; - } - - const latestIndex = status.status_history_public.length - 1; - - return ( -
    - {status.status_history_public.map((item, index) => ( -
  1. -
  2. - ))} -
- ); -} - -export default async function RepairStatusPage({ params }: PageProps) { - const { token } = await params; - const result = await fetchOlympusRepairStatus(token); - - if (!result.ok && result.reason === "invalid") { - return ( - - ); - } - - if (!result.ok) { - return ( - - ); - } - - const status = result.status; - const device = `${status.device_manufacturer} ${status.device_model}`.trim(); - - return ( -
-
-
-

Reparaturstatus

-
-
-

Reparaturstatus

-

Hier sehen Sie den aktuellen Stand Ihrer Reparatur.

-
- {status.public_status_label} -
- -
-
-
Reparaturnummer
-
{status.repair_number}
-
-
-
Gerät
-
{device || "Nicht angegeben"}
-
-
-
Aktueller Status
-
{status.public_status_label}
-
-
-
Letzte Aktualisierung
-
{formatDate(status.updated_at)}
-
-
- - - -
-

Statusverlauf

- -
-
-
-
- ); -} diff --git a/app/ueber-uns/page.tsx b/app/ueber-uns/page.tsx index 51b630b..2b68689 100644 --- a/app/ueber-uns/page.tsx +++ b/app/ueber-uns/page.tsx @@ -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 ( <> - - {content.subtitle} + + Funktechnik Schubert richtet sich an Kunden, die bei Funkgeräten und Kommunikationstechnik eine persönliche, technische Einschätzung suchen.
- {blocks.map((block) => ( -
-

{block.title}

-

{block.text}

-
- ))} +
+

Arbeitsweise

+

Im Mittelpunkt stehen saubere Diagnose, transparente Kommunikation und der respektvolle Umgang mit bestehenden Geräten und Serviceunterlagen.

+
+
+

Fokus

+

Der Schwerpunkt liegt auf Funktechnik, Elektronikservice, Abgleichfragen und technischer Unterstützung rund um Kommunikationstechnik.

+
diff --git a/components/ContactForm.tsx b/components/ContactForm.tsx index 4fe05c0..12082e6 100644 --- a/components/ContactForm.tsx +++ b/components/ContactForm.tsx @@ -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(null); const [message, setMessage] = useState(""); const [error, setError] = useState(""); const [pending, setPending] = useState(false); @@ -17,9 +16,8 @@ export default function ContactForm() { const email = String(form.get("email") ?? "").trim(); const subject = String(form.get("subject") ?? "").trim(); const text = String(form.get("message") ?? "").trim(); - const privacyAccepted = form.get("privacyAccepted") === "on"; - if (!name || !email.includes("@") || !subject || text.length < 10 || !privacyAccepted) { + if (!name || !email.includes("@") || !subject || text.length < 10 || form.get("privacy") !== "on") { setError("Bitte füllen Sie alle Pflichtfelder aus und bestätigen Sie die Datenschutz-Hinweise."); return; } @@ -29,17 +27,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 ( -
+
@@ -61,7 +59,7 @@ export default function ContactForm() {