diff --git a/.dockerignore b/.dockerignore index 9b5cf41..145fa92 100644 --- a/.dockerignore +++ b/.dockerignore @@ -6,8 +6,10 @@ npm-debug.log* .git README.md data/*.json -public/uploads/images/* -!public/uploads/images/.gitkeep +storage/uploads/images/* +storage/config/* +!storage/config/.gitkeep +!storage/uploads/images/.gitkeep *.tmp *.temp .DS_Store diff --git a/.env.example b/.env.example index e020556..9046cd5 100644 --- a/.env.example +++ b/.env.example @@ -25,3 +25,13 @@ OLYMPUS_INTAKE_API_URL= # Optional future server-side Olympus intake token. # Leave empty until the Olympus integration is explicitly implemented. OLYMPUS_INTAKE_API_TOKEN= + +# Server-side Olympus base URL for public repair status links. +# Docker example when Hermes is reachable in the same network: http://hermes:8000 +# Production example: https://olympus.example.internal +# This URL must be reachable by the Next.js server, not by the browser. +OLYMPUS_PUBLIC_STATUS_API_URL= + +# Optional server-side token for future protected status API access. +# Leave empty for Olympus v0.8.1 unless the endpoint is protected later. +OLYMPUS_PUBLIC_STATUS_API_TOKEN= diff --git a/.gitignore b/.gitignore index eb72981..7fccc25 100644 --- a/.gitignore +++ b/.gitignore @@ -9,9 +9,17 @@ data/contact-inquiries.json data/repair-inquiries.json data/site-settings.json data/smtp-settings.json -public/uploads/images/* -!public/uploads/.gitkeep -!public/uploads/images/.gitkeep +storage/uploads/images/* +storage/config/* +storage/content/* +storage/content/backups/* +!storage/.gitkeep +!storage/config/.gitkeep +!storage/content/.gitkeep +!storage/content/backups/ +!storage/content/backups/.gitkeep +!storage/uploads/.gitkeep +!storage/uploads/images/.gitkeep .DS_Store *.tmp *.temp diff --git a/Dockerfile b/Dockerfile index 90bfbd4..2dffadb 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.2.2 +ENV NEXT_PUBLIC_APP_VERSION=0.4.1 ENV DOCKER_ENV=true ENV PORT=3010 @@ -26,8 +26,8 @@ COPY --from=builder --chown=nextjs:nodejs /app/.next/standalone ./ COPY --from=builder --chown=nextjs:nodejs /app/.next/static ./.next/static COPY --from=builder --chown=nextjs:nodejs /app/scripts/runtime-start.js ./runtime-start.js -RUN mkdir -p /app/data /app/public/uploads/images /app/.next/cache \ - && chown -R nextjs:nodejs /app/data /app/public/uploads /app/.next/cache +RUN mkdir -p /app/data /app/storage/config /app/storage/content/backups /app/storage/uploads/images /app/.next/cache \ + && chown -R nextjs:nodejs /app/data /app/storage /app/.next/cache USER nextjs EXPOSE 3010 diff --git a/README.md b/README.md index f2de241..8ccb7db 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 Foundation-Datenablage für Anfragen und Einstellungen +- Lokale Storage-Datenablage für Anfragen, Einstellungen, Medien und Website-Inhalte - Docker - Nginx/Reverse-Proxy-fähig - SEO Metadata, Sitemap und robots.txt -Version: `0.2.2` +Version: `0.4.1` ## Seiten @@ -23,6 +23,7 @@ Version: `0.2.2` - `/reparatur` - `/ueber-uns` - `/kontakt` +- `/status/[token]` - `/impressum` - `/datenschutz` - `/admin/login` @@ -87,7 +88,7 @@ Port: Die Runtime-Daten werden über Docker-Volumes gespeichert: - `funktechnik-data` → `/app/data` -- `funktechnik-uploads` → `/app/public/uploads/images` +- `funktechnik-storage` → `/app/storage` - `funktechnik-next-cache` → `/app/.next/cache` Dadurch sind keine manuellen `chmod`- oder `chown`-Befehle notwendig. @@ -109,8 +110,12 @@ Variablen: - `AUTH_COOKIE_SECURE`: `true` in Produktion mit HTTPS, lokal `false` - `OLYMPUS_INTAKE_API_URL`: vorbereitet für spätere serverseitige Olympus-Anbindung - `OLYMPUS_INTAKE_API_TOKEN`: vorbereitet für spätere serverseitige Olympus-Anbindung +- `OLYMPUS_PUBLIC_STATUS_API_URL`: serverseitig erreichbare Olympus-Basis-URL für öffentliche Reparaturstatuslinks +- `OLYMPUS_PUBLIC_STATUS_API_TOKEN`: optionaler serverseitiger Token für spätere geschützte Status-API-Zugriffe -Die Olympus-Variablen sind nur für eine spätere serverseitige Integration vorbereitet. Sie werden nicht im Browser verwendet. +Die Intake-Variablen sind für eine spätere serverseitige Integration vorbereitet. Die Status-Variablen werden für `/status/[token]` serverseitig verwendet. Sie werden nicht im Browser verwendet. + +Für Reparaturstatuslinks muss `OLYMPUS_PUBLIC_STATUS_API_URL` vom Next.js-Server erreichbar sein. In Docker kann das je nach Netzwerk z. B. `http://hermes:8000` sein. Produktiv sollte eine interne Server-zu-Server-Adresse oder eine abgesicherte Olympus-URL verwendet werden. Für lokale Entwicklung kann `AUTH_COOKIE_SECURE=false` bleiben. Produktiv muss HTTPS verwendet und `AUTH_COOKIE_SECURE=true` gesetzt werden. `ADMIN_SESSION_SECRET` muss produktiv ein langer zufälliger Wert sein. @@ -125,8 +130,62 @@ Für lokale Entwicklung kann `AUTH_COOKIE_SECURE=false` bleiben. Produktiv muss - `POST /api/admin/settings` - `POST /api/admin/smtp` - `GET/POST /api/admin/media` +- `GET/PUT /api/admin/content` +- `GET /api/content/settings` +- `GET /api/media/[filename]` - `GET /api/health` +## Öffentlicher Reparaturstatus + +Olympus CRM erzeugt sichere Statuslinks für Reparaturen. Die Website stellt dafür die öffentliche Route bereit: + +```text +/status/ +``` + +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 @@ -138,14 +197,14 @@ Funktionen: - Dashboard mit Kennzahlen - Kontaktanfragen verwalten - Reparaturanfragen verwalten -- Website-Inhaltsverwaltung vorbereitet +- Website-Inhalte pflegen und sofort veröffentlichen - Firmendaten pflegen -- SMTP-Konfiguration vorbereiten +- SMTP-Konfiguration speichern und Testmail senden - Medien hochladen - SEO-Übersicht - Systemübersicht -SMTP-Versand, Publishing von Website-Inhalten und Olympus-Übernahme sind bewusst noch nicht aktiv gekoppelt. +Website-Inhalte werden unter `/admin/website` gepflegt und beim Speichern sofort auf der öffentlichen Website wirksam. Die Content-Architektur ist bewusst über einen zentralen `ContentService` gekapselt, damit später Olympus CMS oder PostgreSQL als Backend angebunden werden können. SMTP-Versand fuer Kontakt- und Reparaturanfragen ist serverseitig angebunden, sofern `/admin/smtp` vollstaendig konfiguriert ist. ## Runtime Data @@ -163,10 +222,51 @@ Echte Runtime-Dateien werden nicht committed: data/contact-inquiries.json data/repair-inquiries.json data/site-settings.json -data/smtp-settings.json ``` -Uploads unter `public/uploads/images/` werden ebenfalls nicht committed. +SMTP-Konfiguration wird unter `storage/config/smtp.json` gespeichert und nicht committed. Das SMTP-Passwort wird nicht im Admin-Formular ausgegeben. + +Uploads liegen unter `storage/uploads/images/`, werden über `/api/media/[filename]` ausgeliefert und nicht committed. + +Alte Uploads aus `public/uploads/images` werden beim Start einmalig nach `storage/uploads/images` migriert, falls sie dort noch nicht vorhanden sind. + +Website-Inhalte liegen unter `storage/content/` und werden nicht committed: + +```text +storage/content/home.json +storage/content/services.json +storage/content/radio-service.json +storage/content/repair.json +storage/content/about.json +storage/content/contact.json +storage/content/settings.json +storage/content/backups/ +``` + +Jede Seite besitzt eine eigene JSON-Datei. React-Komponenten lesen diese Dateien nicht direkt, sondern ausschließlich über `lib/content/service.ts`. Vor jedem Speichern erzeugt der Service eine Backup-Datei unter `storage/content/backups/` und behält pro Dokument die letzten 20 Versionen. Änderungen benötigen keinen Neustart und keinen Docker-Build. + +Bearbeitbar sind aktuell: + +- Startseite mit Hero, Call-to-Actions, Featureboxen, Leistungsboxen und Kundenversprechen +- Leistungen mit beliebig vielen sortierbaren und ein-/ausblendbaren Einträgen +- Funkgeräte-Service mit Einleitung, Marken, Fehlerbildern, Ablauf, Messmöglichkeiten, Abgleich und Reparaturtexten +- Reparaturannahme mit SEO-Daten und Seitentexten +- Über Uns mit Firmenbeschreibung, Werkstattbeschreibung und Philosophie +- Kontakt mit Kontaktinformationen, Öffnungszeiten, Maps-Link und Seitentexten +- Footer, Logo, Copyright, Header-CTA und globale SEO-Einstellungen +- SEO pro Seite inklusive Meta Title, Meta Description, Keywords, OpenGraph und Social Image + +Bilder für Logo und Social Image werden aus dem Medienbestand ausgewählt. Neue Uploads bleiben im privaten Storage und werden über `/api/media/[filename]` ausgeliefert. + +## Roadmap Kundenportal + +Ein vollständiges Kundenportal ist vorbereitet, aber noch nicht öffentlich implementiert. Die öffentliche Statuslink-Seite ist bereits vorhanden. Für spätere Ausbaustufen sind folgende Routen vorgesehen: + +- `/status/[token]`: öffentliche Statuslink-Seite für Olympus +- `/reparatur/status`: Statusabfrage für Reparaturanfragen +- `/portal/login`: geschützter Kundenlogin + +Diese Routen sind bewusst noch nicht angelegt. Die spätere Umsetzung soll serverseitig erfolgen, ohne Tokens im Browser-JavaScript zu speichern und ohne bestehende Admin- oder CMS-Funktionen zu umgehen. ## Healthcheck @@ -185,13 +285,50 @@ Antwort: ```json { "status": "ok", - "version": "0.2.2", + "version": "0.4.1", "storage": "ok", "admin": "configured", + "smtp": "configured", "timestamp": "..." } ``` +## SMTP + +SMTP wird im Adminbereich unter `/admin/smtp` konfiguriert. Die Konfiguration wird persistent unter `storage/config/smtp.json` gespeichert und liegt damit im Docker-Storage-Volume, nicht in `public/`. + +Pflichtfelder: + +- SMTP Host +- SMTP Port +- SMTP Benutzername +- SMTP Passwort +- Verschlüsselung +- Absenderadresse +- Empfängeradresse + +Für Apple Mail/iCloud Mail: + +```text +Host: smtp.mail.me.com +Port: 587 +Verschlüsselung: STARTTLS +Benutzername: vollständige E-Mail-Adresse +Passwort: app-spezifisches Passwort +``` + +Nicht das normale Apple-ID-Passwort verwenden. In der Apple-ID-Verwaltung ein app-spezifisches Passwort erzeugen und dieses als SMTP-Passwort speichern. + +Nach dem Speichern kann im Adminbereich eine Testmail gesendet werden. Kontakt- und Reparaturanfragen werden weiterhin lokal gespeichert. Wenn SMTP konfiguriert ist, wird zusaetzlich eine E-Mail an die konfigurierte Empfaengeradresse gesendet. Schlaegt der Mailversand fehl, bleibt die Anfrage gespeichert; der Besucher sieht keine technische Fehlermeldung. + +Troubleshooting: + +- Host, Port und Verschlüsselung prüfen +- bei Apple Mail STARTTLS und Port 587 verwenden +- vollständige E-Mail-Adresse als Benutzername verwenden +- app-spezifisches Passwort neu erzeugen +- letzte Testmail und letzte Fehlermeldung unter `/admin/smtp` prüfen + ## Deployment ```bash @@ -223,7 +360,7 @@ scripts/healthcheck.sh scripts/backup.sh ``` -Das Backup enthält `data/` und `public/uploads/`. Die `.env` wird bewusst nicht automatisch gesichert. Sie muss separat sicher abgelegt werden. +Das Backup enthält `data/` und `storage/`. Die `.env` wird bewusst nicht automatisch gesichert. Sie muss separat sicher abgelegt werden. ## Restore @@ -257,6 +394,5 @@ certbot --nginx -d funktechnik-schubert.de -d www.funktechnik-schubert.de ## Offene manuelle Punkte - TODO: Rechtliche Angaben ergänzen -- Produktives Kontakt-/Mail-System anbinden - Spätere Olympus-Reparaturannahme serverseitig anbinden - Finale Domain und HTTPS-Konfiguration setzen diff --git a/app/admin/medien/page.tsx b/app/admin/medien/page.tsx index f7232b7..9f90659 100644 --- a/app/admin/medien/page.tsx +++ b/app/admin/medien/page.tsx @@ -1,26 +1,12 @@ -import Image from "next/image"; import { redirect } from "next/navigation"; import AdminShell from "@/components/admin/AdminShell"; +import MediaManager from "@/components/admin/MediaManager"; import { requireAdminSession } from "@/lib/admin/auth"; import { listMediaFiles } from "@/lib/admin/media"; -function formatBytes(size: number) { - if (size < 1024) return `${size} B`; - if (size < 1024 * 1024) return `${(size / 1024).toFixed(1)} KB`; - return `${(size / 1024 / 1024).toFixed(1)} MB`; -} - -function uploadErrorMessage(error?: string) { - if (!error) return ""; - if (error === "missing-file") return "Bitte wählen Sie eine Datei aus."; - if (error === "storage") return "Die Datei konnte nicht gespeichert werden. Bitte Upload-Speicher und Rechte prüfen."; - return error; -} - -export default async function AdminMediaPage({ searchParams }: { searchParams: Promise<{ uploaded?: string; error?: string }> }) { +export default async function AdminMediaPage() { if (!await requireAdminSession()) redirect("/admin/login"); - const [files, params] = await Promise.all([listMediaFiles(), searchParams]); - const error = uploadErrorMessage(params.error); + const files = await listMediaFiles(); return ( @@ -28,41 +14,7 @@ export default async function AdminMediaPage({ searchParams }: { searchParams: P

Assets

Medienverwaltung

- {params.uploaded === "1" &&

Datei wurde erfolgreich hochgeladen.

} - {error &&

Upload fehlgeschlagen: {error}

} -
- - -
-
-

Uploads

- {files.length === 0 ? ( -
-

Noch keine Medien vorhanden

-

Hochgeladene Bilder und PDF-Dateien erscheinen hier mit Vorschau und Metadaten.

-
- ) : ( -
- {files.map((file) => ( - - ))} -
- )} -
+
); } diff --git a/app/admin/page.tsx b/app/admin/page.tsx index b479b0d..5062996 100644 --- a/app/admin/page.tsx +++ b/app/admin/page.tsx @@ -1,7 +1,11 @@ import { redirect } from "next/navigation"; import AdminShell from "@/components/admin/AdminShell"; import { requireAdminSession } from "@/lib/admin/auth"; +import { listMediaFiles } from "@/lib/admin/media"; import { getContactInquiries, getRepairInquiries } from "@/lib/admin/store"; +import { getContentSummary } from "@/lib/content/service"; +import { getSmtpSettings, isSmtpConfigured } from "@/lib/mail/config"; +import { appVersion, checkStorage } from "@/lib/runtime/config"; function countNew(items: T[]) { return items.filter((item) => item.status === "new").length; @@ -9,8 +13,23 @@ function countNew(items: T[]) { export default async function AdminDashboardPage() { if (!await requireAdminSession()) redirect("/admin/login"); - const [contacts, repairs] = await Promise.all([getContactInquiries(), getRepairInquiries()]); + let healthStatus = "OK"; + + try { + await checkStorage(); + } catch { + healthStatus = "Fehler"; + } + + const [contacts, repairs, smtpSettings, mediaFiles, contentSummary] = await Promise.all([ + getContactInquiries(), + getRepairInquiries(), + getSmtpSettings(), + listMediaFiles(), + getContentSummary(), + ]); const latest = [...contacts, ...repairs].sort((a, b) => b.createdAt.localeCompare(a.createdAt)).slice(0, 6); + const contentModified = contentSummary.lastModified ? new Date(contentSummary.lastModified).toLocaleString("de-DE") : "keine Änderung"; return ( @@ -19,10 +38,13 @@ export default async function AdminDashboardPage() {

Dashboard

-
Kontaktanfragen{countNew(contacts)}neu
-
Reparaturanfragen{countNew(repairs)}neu
-
WebsiteOnlineOK
-
SMTPVorbereitetnicht aktiv
+
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

Letzte Anfragen

diff --git a/app/admin/smtp/page.tsx b/app/admin/smtp/page.tsx index ae13637..662eeb9 100644 --- a/app/admin/smtp/page.tsx +++ b/app/admin/smtp/page.tsx @@ -1,7 +1,8 @@ import { redirect } from "next/navigation"; import AdminShell from "@/components/admin/AdminShell"; +import SmtpSettingsForm from "@/components/admin/SmtpSettingsForm"; import { requireAdminSession } from "@/lib/admin/auth"; -import { getSmtpSettings } from "@/lib/admin/store"; +import { getSmtpSettings, isSmtpConfigured, toPublicSmtpSettings } from "@/lib/mail/config"; export default async function AdminSmtpPage() { if (!await requireAdminSession()) redirect("/admin/login"); @@ -13,16 +14,7 @@ export default async function AdminSmtpPage() {

E-Mail

SMTP Einstellungen

-
- - - - - - - - -
+ ); } diff --git a/app/admin/system/page.tsx b/app/admin/system/page.tsx index 4e9b3ad..08aff5a 100644 --- a/app/admin/system/page.tsx +++ b/app/admin/system/page.tsx @@ -1,7 +1,9 @@ import { redirect } from "next/navigation"; import AdminShell from "@/components/admin/AdminShell"; import { requireAdminSession } from "@/lib/admin/auth"; -import { adminConfigurationStatus, appVersion, checkStorage, dataDirectory, isDockerEnvironment, olympusStatus, smtpStatus, uploadDirectory } from "@/lib/runtime/config"; +import { adminConfigurationStatus, appVersion, checkStorage, configDirectory, dataDirectory, isDockerEnvironment, olympusStatus, uploadDirectory } from "@/lib/runtime/config"; +import { getSmtpSettings, isSmtpConfigured } from "@/lib/mail/config"; +import { getContentSystemStatus } from "@/lib/content/service"; export default async function AdminSystemPage() { if (!await requireAdminSession()) redirect("/admin/login"); @@ -12,6 +14,8 @@ export default async function AdminSystemPage() { } catch { storage = "error"; } + const [smtpSettings, contentStatus] = await Promise.all([getSmtpSettings(), getContentSystemStatus()]); + const smtpConfigured = isSmtpConfigured(smtpSettings); return ( @@ -27,20 +31,43 @@ 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
{smtpStatus()}
+
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}

Betriebsregeln

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

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 f3d309f..4fd4f99 100644 --- a/app/admin/website/page.tsx +++ b/app/admin/website/page.tsx @@ -1,18 +1,13 @@ import { redirect } from "next/navigation"; import AdminShell from "@/components/admin/AdminShell"; +import ContentManager from "@/components/admin/ContentManager"; import { requireAdminSession } from "@/lib/admin/auth"; - -const editablePages = [ - ["Startseite", "Hero, Vorteile, Textblöcke"], - ["Leistungen", "Funktechnik, Messtechnik, Servicebereiche"], - ["Funkgeräte-Service", "Diagnose, Abgleich, Fehlerbilder"], - ["Reparatur", "Reparaturannahme und Hinweise"], - ["Über uns", "Profil und Werkstattbeschreibung"], - ["Kontakt", "Kontakttext und Hinweise"], -] as const; +import { listMediaFiles } from "@/lib/admin/media"; +import { getAllContent } from "@/lib/content/service"; export default async function AdminWebsitePage() { if (!await requireAdminSession()) redirect("/admin/login"); + const [content, mediaFiles] = await Promise.all([getAllContent(), listMediaFiles()]); return ( @@ -20,19 +15,7 @@ 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 new file mode 100644 index 0000000..5725549 --- /dev/null +++ b/app/api/admin/content/route.ts @@ -0,0 +1,27 @@ +import { NextResponse } from "next/server"; +import { requireAdminSession } from "@/lib/admin/auth"; +import { getAllContent, saveContent } from "@/lib/content/service"; +import type { ContentDocuments, ContentKey } from "@/lib/content/types"; + +const keys: ContentKey[] = ["home", "services", "radio-service", "repair", "about", "contact", "settings"]; + +function isContentKey(value: unknown): value is ContentKey { + return typeof value === "string" && keys.includes(value as ContentKey); +} + +export async function GET() { + if (!await requireAdminSession()) return NextResponse.json({ message: "Nicht autorisiert." }, { status: 401 }); + return NextResponse.json({ content: await getAllContent() }); +} + +export async function PUT(request: Request) { + if (!await requireAdminSession()) return NextResponse.json({ message: "Nicht autorisiert." }, { status: 401 }); + + const body = await request.json() as { key?: unknown; content?: unknown }; + if (!isContentKey(body.key) || !body.content) { + return NextResponse.json({ message: "Ungültige Content-Anfrage." }, { status: 400 }); + } + + const saved = await saveContent(body.key, body.content as ContentDocuments[typeof body.key]); + return NextResponse.json({ message: "Inhalt gespeichert.", key: body.key, content: saved }); +} diff --git a/app/api/admin/media/route.ts b/app/api/admin/media/route.ts index 134e0b8..3ba158a 100644 --- a/app/api/admin/media/route.ts +++ b/app/api/admin/media/route.ts @@ -1,13 +1,6 @@ import { NextResponse } from "next/server"; import { requireAdminSession } from "@/lib/admin/auth"; -import { listMediaFiles, saveMediaFile } from "@/lib/admin/media"; - -function relativeRedirect(target: string) { - return new NextResponse(null, { - status: 303, - headers: { Location: target }, - }); -} +import { deleteMediaFile, listMediaFiles, saveMediaFile } from "@/lib/admin/media"; export async function GET() { if (!await requireAdminSession()) return NextResponse.json({ message: "Nicht autorisiert." }, { status: 401 }); @@ -20,15 +13,27 @@ export async function POST(request: Request) { const file = form.get("file"); if (!(file instanceof File)) { - return relativeRedirect("/admin/medien?error=missing-file"); + return NextResponse.json({ message: "Bitte wählen Sie eine Datei aus." }, { status: 400 }); } try { const result = await saveMediaFile(file); - if (result.error) return relativeRedirect(`/admin/medien?error=${encodeURIComponent(result.error)}`); + if (result.error) return NextResponse.json({ message: result.error }, { status: 400 }); } catch { - return relativeRedirect("/admin/medien?error=storage"); + return NextResponse.json({ message: "Die Datei konnte nicht gespeichert werden. Bitte Upload-Speicher und Rechte prüfen." }, { status: 500 }); } - return relativeRedirect("/admin/medien?uploaded=1"); + return NextResponse.json({ message: "Datei erfolgreich hochgeladen.", files: await listMediaFiles() }); +} + +export async function DELETE(request: Request) { + if (!await requireAdminSession()) return NextResponse.json({ message: "Nicht autorisiert." }, { status: 401 }); + + const body = await request.json() as { name?: string }; + if (!body.name) return NextResponse.json({ message: "Dateiname fehlt." }, { status: 400 }); + + const result = await deleteMediaFile(body.name); + if (result.error) return NextResponse.json({ message: result.error }, { status: 400 }); + + return NextResponse.json({ message: "Datei gelöscht.", files: await listMediaFiles() }); } diff --git a/app/api/admin/smtp/route.ts b/app/api/admin/smtp/route.ts index ea05bb2..5b4f368 100644 --- a/app/api/admin/smtp/route.ts +++ b/app/api/admin/smtp/route.ts @@ -1,9 +1,26 @@ import { NextResponse } from "next/server"; import { requireAdminSession } from "@/lib/admin/auth"; -import { saveSmtpSettings } from "@/lib/admin/store"; +import { saveSmtpSettings, toPublicSmtpSettings } from "@/lib/mail/config"; +import { sendTestMail } from "@/lib/mail/smtp"; export async function POST(request: Request) { if (!await requireAdminSession()) return NextResponse.json({ message: "Nicht autorisiert." }, { status: 401 }); - await saveSmtpSettings(await request.formData()); - return new NextResponse(null, { status: 303, headers: { Location: "/admin/smtp?saved=1" } }); + const form = await request.formData(); + const action = String(form.get("action") ?? "save"); + + if (action === "test") { + const settings = await saveSmtpSettings(form); + const result = await sendTestMail(); + return NextResponse.json({ + message: result.ok ? "Test-E-Mail wurde gesendet." : result.error ?? "Test-E-Mail fehlgeschlagen.", + ok: result.ok, + settings: toPublicSmtpSettings(settings), + }, { status: result.ok ? 200 : 400 }); + } + + const settings = await saveSmtpSettings(form); + return NextResponse.json({ + message: "SMTP-Konfiguration gespeichert.", + settings: toPublicSmtpSettings(settings), + }); } diff --git a/app/api/contact/route.ts b/app/api/contact/route.ts index ee384f7..6ea6537 100644 --- a/app/api/contact/route.ts +++ b/app/api/contact/route.ts @@ -1,5 +1,6 @@ import { NextResponse } from "next/server"; import { addContactInquiry } from "@/lib/admin/store"; +import { sendContactInquiryMail } from "@/lib/mail/smtp"; function text(value: FormDataEntryValue | null) { return typeof value === "string" ? value.trim() : ""; @@ -12,11 +13,12 @@ export async function POST(request: Request) { const subject = text(form.get("subject")); const message = text(form.get("message")); - if (!name || !email.includes("@") || !subject || message.length < 10 || form.get("privacy") !== "on") { + if (!name || !email.includes("@") || !subject || message.length < 10 || form.get("privacyAccepted") !== "on") { return NextResponse.json({ message: "Bitte füllen Sie alle Pflichtfelder aus." }, { status: 400 }); } - await addContactInquiry(form); + const inquiry = await addContactInquiry(form); + await sendContactInquiryMail(inquiry); return NextResponse.json({ message: "Ihre Nachricht wurde erfasst. Sie erhalten eine Rückmeldung.", diff --git a/app/api/content/settings/route.ts b/app/api/content/settings/route.ts new file mode 100644 index 0000000..9da8ad9 --- /dev/null +++ b/app/api/content/settings/route.ts @@ -0,0 +1,7 @@ +import { NextResponse } from "next/server"; +import { getContent } from "@/lib/content/service"; + +export async function GET() { + const settings = await getContent("settings"); + return NextResponse.json({ settings }); +} diff --git a/app/api/health/route.ts b/app/api/health/route.ts index 30791a0..68745af 100644 --- a/app/api/health/route.ts +++ b/app/api/health/route.ts @@ -1,5 +1,6 @@ import { NextResponse } from "next/server"; import { adminConfigurationStatus, appVersion, checkStorage } from "@/lib/runtime/config"; +import { getSmtpSettings, isSmtpConfigured } from "@/lib/mail/config"; export async function GET() { let storage = "ok"; @@ -10,11 +11,14 @@ export async function GET() { storage = "error"; } + const smtpSettings = await getSmtpSettings(); + return NextResponse.json({ status: storage === "ok" ? "ok" : "error", version: appVersion, storage, admin: adminConfigurationStatus(), + smtp: isSmtpConfigured(smtpSettings) ? "configured" : "missing", timestamp: new Date().toISOString(), }); } diff --git a/app/api/media/[filename]/route.ts b/app/api/media/[filename]/route.ts new file mode 100644 index 0000000..4d4c315 --- /dev/null +++ b/app/api/media/[filename]/route.ts @@ -0,0 +1,22 @@ +import { NextResponse } from "next/server"; +import { readMediaFile } from "@/lib/admin/media"; + +export async function GET(_request: Request, { params }: { params: Promise<{ filename: string }> }) { + const { filename } = await params; + const media = await readMediaFile(decodeURIComponent(filename)); + + if (!media) { + return NextResponse.json({ message: "Datei nicht gefunden." }, { status: 404 }); + } + + return new NextResponse(media.data, { + status: 200, + headers: { + "Content-Type": media.mimeType, + "Content-Length": String(media.size), + "Content-Disposition": "inline", + "Cache-Control": "private, max-age=300", + "X-Content-Type-Options": "nosniff", + }, + }); +} diff --git a/app/api/repair/route.ts b/app/api/repair/route.ts index 6b98806..81c771c 100644 --- a/app/api/repair/route.ts +++ b/app/api/repair/route.ts @@ -1,5 +1,6 @@ import { NextResponse } from "next/server"; import { addRepairInquiry } from "@/lib/admin/store"; +import { sendRepairInquiryMail } from "@/lib/mail/smtp"; function required(value: FormDataEntryValue | null) { return typeof value === "string" && value.trim().length > 0; @@ -13,13 +14,14 @@ export async function POST(request: Request) { const model = form.get("model"); const deviceType = form.get("deviceType"); const description = form.get("description"); - const privacy = form.get("privacy"); + const privacyAccepted = form.get("privacyAccepted"); - if (!required(name) || !required(email) || !required(manufacturer) || !required(model) || !required(deviceType) || !required(description) || privacy !== "on") { + if (!required(name) || !required(email) || !required(manufacturer) || !required(model) || !required(deviceType) || !required(description) || privacyAccepted !== "on") { return NextResponse.json({ message: "Bitte füllen Sie alle Pflichtfelder aus." }, { status: 400 }); } - await addRepairInquiry(form); + const inquiry = await addRepairInquiry(form); + await sendRepairInquiryMail(inquiry); return NextResponse.json({ message: "Ihre Reparaturanfrage wurde erfasst. Sie erhalten nach Prüfung eine Rückmeldung.", diff --git a/app/api/status/[token]/estimate/action-response.ts b/app/api/status/[token]/estimate/action-response.ts new file mode 100644 index 0000000..27e9345 --- /dev/null +++ b/app/api/status/[token]/estimate/action-response.ts @@ -0,0 +1,36 @@ +import { NextResponse } from "next/server"; +import type { EstimateActionResult } from "@/lib/olympus/status"; + +export function estimateActionResponse(result: EstimateActionResult, successMessage: string) { + if (result.ok) { + return NextResponse.json({ message: successMessage }); + } + + if (result.reason === "invalid") { + return NextResponse.json( + { message: "Dieser Statuslink ist ungültig oder nicht mehr aktiv." }, + { status: 404 }, + ); + } + + if (result.reason === "unavailable") { + return NextResponse.json( + { message: "Der Reparaturstatus ist momentan nicht abrufbar." }, + { status: 502 }, + ); + } + + return NextResponse.json( + { message: "Die Aktion konnte nicht abgeschlossen werden. Bitte versuchen Sie es erneut." }, + { status: 400 }, + ); +} + +export async function readOptionalMessage(request: Request) { + try { + const body = await request.json() as { message?: unknown }; + return typeof body.message === "string" ? body.message : ""; + } catch { + return ""; + } +} diff --git a/app/api/status/[token]/estimate/approve/route.ts b/app/api/status/[token]/estimate/approve/route.ts new file mode 100644 index 0000000..a3ea9c8 --- /dev/null +++ b/app/api/status/[token]/estimate/approve/route.ts @@ -0,0 +1,15 @@ +import { submitOlympusEstimateAction } from "@/lib/olympus/status"; +import { estimateActionResponse } from "../action-response"; + +type RouteContext = { + params: Promise<{ + token: string; + }>; +}; + +export async function POST(_request: Request, { params }: RouteContext) { + const { token } = await params; + const result = await submitOlympusEstimateAction(token, "approve"); + + return estimateActionResponse(result, "Kostenvoranschlag wurde freigegeben."); +} diff --git a/app/api/status/[token]/estimate/decline/route.ts b/app/api/status/[token]/estimate/decline/route.ts new file mode 100644 index 0000000..0e18d1a --- /dev/null +++ b/app/api/status/[token]/estimate/decline/route.ts @@ -0,0 +1,16 @@ +import { submitOlympusEstimateAction } from "@/lib/olympus/status"; +import { estimateActionResponse, readOptionalMessage } from "../action-response"; + +type RouteContext = { + params: Promise<{ + token: string; + }>; +}; + +export async function POST(request: Request, { params }: RouteContext) { + const { token } = await params; + const message = await readOptionalMessage(request); + const result = await submitOlympusEstimateAction(token, "decline", message); + + return estimateActionResponse(result, "Kostenvoranschlag wurde abgelehnt."); +} diff --git a/app/api/status/[token]/estimate/question/route.ts b/app/api/status/[token]/estimate/question/route.ts new file mode 100644 index 0000000..4ff10f4 --- /dev/null +++ b/app/api/status/[token]/estimate/question/route.ts @@ -0,0 +1,16 @@ +import { submitOlympusEstimateAction } from "@/lib/olympus/status"; +import { estimateActionResponse, readOptionalMessage } from "../action-response"; + +type RouteContext = { + params: Promise<{ + token: string; + }>; +}; + +export async function POST(request: Request, { params }: RouteContext) { + const { token } = await params; + const message = await readOptionalMessage(request); + const result = await submitOlympusEstimateAction(token, "question", message); + + return estimateActionResponse(result, "Ihre Rückfrage wurde gesendet."); +} diff --git a/app/datenschutz/page.tsx b/app/datenschutz/page.tsx index 265ff71..31e2d04 100644 --- a/app/datenschutz/page.tsx +++ b/app/datenschutz/page.tsx @@ -10,15 +10,15 @@ export default function DatenschutzPage() { Informationen zur Verarbeitung personenbezogener Daten.
-

TODO: Rechtliche Angaben ergänzen

+

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

Verantwortlicher

-

TODO: Rechtliche Angaben ergänzen

+

Die Angaben zum Verantwortlichen werden durch den Betreiber gepflegt.

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

-

TODO: Rechtliche Angaben ergänzen

+

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

Betroffenenrechte

-

TODO: Rechtliche Angaben ergänzen

+

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

diff --git a/app/funkgeraete-service/page.tsx b/app/funkgeraete-service/page.tsx index ef23bd7..e814446 100644 --- a/app/funkgeraete-service/page.tsx +++ b/app/funkgeraete-service/page.tsx @@ -1,33 +1,41 @@ -import type { Metadata } from "next"; import Link from "next/link"; import PageHero from "@/components/PageHero"; -import { createMetadata } from "../seo"; +import { defaultContent } from "@/lib/content/defaults"; +import { getContent } from "@/lib/content/service"; +import { createContentMetadata } from "../seo"; -export const metadata: Metadata = createMetadata("Funkgeräte-Service", "Werkstattservice für CB-Funk, Amateurfunk und Funkgeräte-Abgleich.", "/funkgeraete-service"); +export const dynamic = "force-dynamic"; + +export async function generateMetadata() { + const content = await getContent("radio-service"); + return createContentMetadata(content.seo, defaultContent["radio-service"].seo); +} + +export default async function RadioServicePage() { + const content = await getContent("radio-service"); -export default function RadioServicePage() { return ( <> - - Funkgeräte zeigen Fehler oft erst im Zusammenspiel von Empfang, Sendeteil, Versorgung, Antennenanpassung, Bedienung und Abgleich. Der Service betrachtet diese Zusammenhänge strukturiert. + + {content.subtitle}
-

Typische Fehlerbilder

+

{content.listTitle}

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

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 +

{content.cta.text}

+

{content.intro}

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

{content.alignment}

+

{content.repair}

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

TODO: Rechtliche Angaben ergänzen

+

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

Anbieterkennzeichnung

-

TODO: Rechtliche Angaben ergänzen

+

Die vollständige Anbieterkennzeichnung wird durch den Betreiber gepflegt.

Kontakt

-

TODO: Rechtliche Angaben ergänzen

+

Die rechtlich relevanten Kontaktdaten werden durch den Betreiber gepflegt.

Umsatzsteuer / Registerangaben

-

TODO: Rechtliche Angaben ergänzen

+

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

diff --git a/app/kontakt/page.tsx b/app/kontakt/page.tsx index 67ac7d2..da1c722 100644 --- a/app/kontakt/page.tsx +++ b/app/kontakt/page.tsx @@ -1,21 +1,41 @@ -import type { Metadata } from "next"; import PageHero from "@/components/PageHero"; import ContactForm from "@/components/ContactForm"; -import { createMetadata } from "../seo"; +import { defaultContent } from "@/lib/content/defaults"; +import { getContent } from "@/lib/content/service"; +import { createContentMetadata } from "../seo"; -export const metadata: Metadata = createMetadata("Kontakt", "Kontakt zu Funktechnik Schubert aufnehmen.", "/kontakt"); +export const dynamic = "force-dynamic"; + +export async function generateMetadata() { + const content = await getContent("contact"); + return createContentMetadata(content.seo, defaultContent.contact.seo); +} + +export default async function ContactPage() { + const content = await getContent("contact"); + const contactDetails = [ + content.phone && `Telefon: ${content.phone}`, + content.email && `E-Mail: ${content.email}`, + content.address && `Adresse: ${content.address}`, + content.openingHours && `Öffnungszeiten: ${content.openingHours}`, + ].filter(Boolean); -export default function ContactPage() { return ( <> - - Beschreiben Sie kurz Ihr Anliegen. Für Reparaturen nutzen Sie idealerweise die strukturierte Reparaturannahme. + + {content.subtitle}
-

Direkt und technisch

-

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

+

{content.cta.text}

+

{content.heroText}

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

Karte öffnen

}
diff --git a/app/layout.tsx b/app/layout.tsx index 55e8ba9..7d808f5 100644 --- a/app/layout.tsx +++ b/app/layout.tsx @@ -1,12 +1,14 @@ import type { Metadata } from "next"; import SiteFrame from "@/components/SiteFrame"; import "./globals.css"; -import { createMetadata } from "./seo"; +import { defaultContent } from "@/lib/content/defaults"; +import { getContent } from "@/lib/content/service"; +import { createContentMetadata } from "./seo"; -export const metadata: Metadata = createMetadata( - "Funktechnik Schubert", - "Service, Reparatur, Diagnose und Abgleich von Funktechnik, CB-Funk, Amateurfunk und Kommunikationstechnik.", -); +export async function generateMetadata(): Promise { + const settings = await getContent("settings"); + return createContentMetadata(settings.seo, defaultContent.settings.seo); +} export default function RootLayout({ children }: Readonly<{ children: React.ReactNode }>) { return ( diff --git a/app/leistungen/page.tsx b/app/leistungen/page.tsx index 7794dad..092e4e2 100644 --- a/app/leistungen/page.tsx +++ b/app/leistungen/page.tsx @@ -1,27 +1,30 @@ -import type { Metadata } from "next"; import PageHero from "@/components/PageHero"; import ServiceCard from "@/components/ServiceCard"; -import { createMetadata } from "../seo"; +import { defaultContent } from "@/lib/content/defaults"; +import { getContent } from "@/lib/content/service"; +import { createContentMetadata } from "../seo"; -export const metadata: Metadata = createMetadata("Leistungen", "Funkgeräte-Service, Diagnose, Abgleich und Dokumentation für Kommunikationstechnik.", "/leistungen"); +export const dynamic = "force-dynamic"; -const services = [ - ["Funkgeräte-Service", "Service für CB-Funk, Amateurfunk, Betriebsfunk sowie ältere und moderne Funkgeräte. Dazu gehören Sichtprüfung, Funktionsprüfung und eine technische Einschätzung des Gerätezustands."], - ["Fehlersuche & Reparatur", "Strukturierte Analyse bei fehlender Modulation, Frequenzabweichung, PLL-Problemen, Empfangs- oder Sendeproblemen, Audio-/NF-Fehlern und Anzeige- oder Displayfehlern."], - ["Abgleich & Prüfung", "Frequenzabgleich, Hub- und Modulationsprüfung, Sendeleistung, Empfindlichkeit und Funktionsprüfung nach technischen Unterlagen und sinnvoller Werkstattpraxis."], - ["Messtechnik & Diagnose", "Systematische Fehlersuche mit geeigneter Messtechnik wie Oszilloskop, Frequenzzähler, Signalgenerator, RF-Messung und Modulationsmessung."], - ["Serviceunterlagen", "Sorgfältige Einordnung von Schaltplänen, Service Manuals, Abgleichanleitungen, Dokumentation und Reparaturhinweisen für nachvollziehbare Arbeitsschritte."], -] as const; +export async function generateMetadata() { + const content = await getContent("services"); + return createContentMetadata(content.seo, defaultContent.services.seo); +} + +export default async function ServicesPage() { + const content = await getContent("services"); + const services = content.services + .filter((service) => service.visible) + .sort((left, right) => left.sortOrder - right.sortOrder); -export default function ServicesPage() { return ( <> - - Technische Unterstützung für Geräte, bei denen Diagnose, Messtechnik, Erfahrung und saubere Dokumentation zählen. + + {content.subtitle}
- {services.map(([title, text]) => {text})} + {services.map((service) => {service.description})}
diff --git a/app/page.tsx b/app/page.tsx index 859a1a6..0095e36 100644 --- a/app/page.tsx +++ b/app/page.tsx @@ -1,26 +1,36 @@ import Link from "next/link"; import ServiceCard from "@/components/ServiceCard"; +import { defaultContent } from "@/lib/content/defaults"; +import { getContent } from "@/lib/content/service"; +import { createContentMetadata } from "./seo"; + +export const dynamic = "force-dynamic"; + +export async function generateMetadata() { + const content = await getContent("home"); + return createContentMetadata(content.seo, defaultContent.home.seo); +} + +export default async function HomePage() { + const content = await getContent("home"); -export default function HomePage() { return ( <> -
+
-

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.eyebrow}

+

{content.title}

+

{content.subtitle}

+

{content.heroText}

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

Leistungsbereiche

-

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.cta.text}

+

{content.intro}

- 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. + {content.features.map((feature) => {feature.text})}
@@ -43,20 +51,17 @@ export default function HomePage() {
-

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.eyebrow}

+

{content.promise.title}

+

{content.promise.text}

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

Warum Funktechnik Schubert?

+

{content.promise.title}

    -
  • 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
  • + {content.promise.bullets.map((item) =>
  • {item}
  • )}
diff --git a/app/reparatur/page.tsx b/app/reparatur/page.tsx index 22989f5..ff1cf59 100644 --- a/app/reparatur/page.tsx +++ b/app/reparatur/page.tsx @@ -1,27 +1,32 @@ -import type { Metadata } from "next"; import PageHero from "@/components/PageHero"; import RepairForm from "@/components/RepairForm"; -import { createMetadata } from "../seo"; +import { defaultContent } from "@/lib/content/defaults"; +import { getContent } from "@/lib/content/service"; +import { createContentMetadata } from "../seo"; -export const metadata: Metadata = createMetadata("Reparaturannahme", "Reparaturanfrage für Funkgeräte strukturiert vorbereiten.", "/reparatur"); +export const dynamic = "force-dynamic"; + +export async function generateMetadata() { + const content = await getContent("repair"); + return createContentMetadata(content.seo, defaultContent.repair.seo); +} + +export default async function RepairPage() { + const content = await getContent("repair"); + const paragraph = content.paragraphs[0]; -export default function RepairPage() { return ( <> - - Beschreiben Sie Gerät, Fehlerbild, Zubehör und bisherige Vorarbeiten. Die Anfrage wird strukturiert erfasst und für eine spätere technische Bearbeitung vorbereitet. + + {content.subtitle}
-

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.

+

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

+

{paragraph?.text || content.intro}

    -
  • 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
  • + {content.list.map((item) =>
  • {item}
  • )}
diff --git a/app/seo.ts b/app/seo.ts index 0d2de93..8fb7d2d 100644 --- a/app/seo.ts +++ b/app/seo.ts @@ -1,4 +1,5 @@ import type { Metadata } from "next"; +import type { SeoContent } from "@/lib/content/types"; const siteName = "Funktechnik Schubert"; const defaultTitle = "Funktechnik Schubert | Funkgeräte-Service & Messtechnik"; @@ -59,4 +60,36 @@ export function createMetadata(title: string, description: string, path = "/"): }; } +export function createContentMetadata(seo: SeoContent, fallbackSeo?: SeoContent): Metadata { + const resolvedSeo = { + ...fallbackSeo, + ...Object.fromEntries(Object.entries(seo).filter(([, value]) => value.trim() !== "")), + } as SeoContent; + const path = resolvedSeo.canonicalUrl || "/"; + const url = new URL(path, siteUrl).toString(); + const title = resolvedSeo.metaTitle || siteName; + const description = resolvedSeo.metaDescription || defaultTitle; + + return { + ...createMetadata(title, description, path), + keywords: resolvedSeo.keywords ? resolvedSeo.keywords.split(",").map((keyword) => keyword.trim()).filter(Boolean) : keywords, + openGraph: { + type: "website", + locale: "de_DE", + url, + siteName, + title: resolvedSeo.openGraphTitle || title, + description: resolvedSeo.openGraphDescription || description, + images: [ + { + url: resolvedSeo.socialImage || "/funktechnik_schubert_logo.jpg", + width: 1200, + height: 630, + alt: resolvedSeo.openGraphTitle || title, + }, + ], + }, + }; +} + export { defaultTitle, siteName, siteUrl }; diff --git a/app/status/[token]/page.tsx b/app/status/[token]/page.tsx new file mode 100644 index 0000000..00fdf7a --- /dev/null +++ b/app/status/[token]/page.tsx @@ -0,0 +1,268 @@ +import type { Metadata } from "next"; +import Link from "next/link"; +import { EstimateActions } from "@/components/status/EstimateActions"; +import { + fetchOlympusRepairStatus, + type OlympusEstimate, + type OlympusRepairStatus, +} from "@/lib/olympus/status"; + +type PageProps = { + params: Promise<{ + token: string; + }>; +}; + +export const dynamic = "force-dynamic"; + +export const metadata: Metadata = { + title: "Reparaturstatus | Funktechnik Schubert", + description: "Öffentlicher Reparaturstatus für Kunden von Funktechnik Schubert.", + robots: { + index: false, + follow: false, + }, +}; + +function formatDate(value: string) { + return new Intl.DateTimeFormat("de-DE", { + dateStyle: "medium", + timeStyle: "short", + }).format(new Date(value)); +} + +function formatDateOnly(value: string | null) { + if (!value) { + return "Nicht angegeben"; + } + + return new Intl.DateTimeFormat("de-DE", { + dateStyle: "medium", + }).format(new Date(value)); +} + +function formatMoney(cents: number, currency: string) { + return new Intl.NumberFormat("de-DE", { + style: "currency", + currency: currency || "EUR", + }).format(cents / 100); +} + +function estimateStatusLabel(status: string) { + const labels: Record = { + 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 2b68689..51b630b 100644 --- a/app/ueber-uns/page.tsx +++ b/app/ueber-uns/page.tsx @@ -1,25 +1,35 @@ -import type { Metadata } from "next"; import PageHero from "@/components/PageHero"; -import { createMetadata } from "../seo"; +import { defaultContent } from "@/lib/content/defaults"; +import { getContent } from "@/lib/content/service"; +import { createContentMetadata } from "../seo"; -export const metadata: Metadata = createMetadata("Über uns", "Funktechnik Schubert steht für technischen Service und klare Kommunikation.", "/ueber-uns"); +export const dynamic = "force-dynamic"; + +export async function generateMetadata() { + const content = await getContent("about"); + return createContentMetadata(content.seo, defaultContent.about.seo); +} + +export default async function AboutPage() { + const content = await getContent("about"); + const blocks = content.paragraphs.length > 0 ? content.paragraphs : [ + { title: content.companyDescription, text: content.workshopDescription }, + { title: content.philosophy, text: content.intro }, + ]; -export default function AboutPage() { return ( <> - - Funktechnik Schubert richtet sich an Kunden, die bei Funkgeräten und Kommunikationstechnik eine persönliche, technische Einschätzung suchen. + + {content.subtitle}
-
-

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.

-
+ {blocks.map((block) => ( +
+

{block.title}

+

{block.text}

+
+ ))}
diff --git a/components/ContactForm.tsx b/components/ContactForm.tsx index 12082e6..4fe05c0 100644 --- a/components/ContactForm.tsx +++ b/components/ContactForm.tsx @@ -1,8 +1,9 @@ "use client"; -import { useState, type FormEvent } from "react"; +import { useRef, useState, type FormEvent } from "react"; export default function ContactForm() { + const formRef = useRef(null); const [message, setMessage] = useState(""); const [error, setError] = useState(""); const [pending, setPending] = useState(false); @@ -16,8 +17,9 @@ export default function ContactForm() { const email = String(form.get("email") ?? "").trim(); const subject = String(form.get("subject") ?? "").trim(); const text = String(form.get("message") ?? "").trim(); + const privacyAccepted = form.get("privacyAccepted") === "on"; - if (!name || !email.includes("@") || !subject || text.length < 10 || form.get("privacy") !== "on") { + if (!name || !email.includes("@") || !subject || text.length < 10 || !privacyAccepted) { setError("Bitte füllen Sie alle Pflichtfelder aus und bestätigen Sie die Datenschutz-Hinweise."); return; } @@ -27,17 +29,17 @@ export default function ContactForm() { const response = await fetch("/api/contact", { method: "POST", body: form }); const result = await response.json() as { message?: string }; if (!response.ok) throw new Error(result.message ?? "Nachricht konnte nicht gesendet werden."); - event.currentTarget.reset(); + formRef.current?.reset(); setMessage(result.message ?? "Ihre Nachricht wurde vorbereitet."); } catch (err) { - setError(err instanceof Error ? err.message : "Nachricht konnte nicht gesendet werden."); + setError(err instanceof Error ? err.message : "Nachricht konnte nicht gesendet werden. Bitte versuchen Sie es später erneut."); } finally { setPending(false); } } return ( -
+
@@ -59,7 +61,7 @@ export default function ContactForm() {