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 204a542..7fccc25 100644 --- a/.gitignore +++ b/.gitignore @@ -11,8 +11,13 @@ data/site-settings.json data/smtp-settings.json storage/uploads/images/* storage/config/* +storage/content/* +storage/content/backups/* !storage/.gitkeep !storage/config/.gitkeep +!storage/content/.gitkeep +!storage/content/backups/ +!storage/content/backups/.gitkeep !storage/uploads/.gitkeep !storage/uploads/images/.gitkeep .DS_Store diff --git a/Dockerfile b/Dockerfile index c0bb863..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,7 +26,7 @@ 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/uploads/images /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 diff --git a/README.md b/README.md index 4c9a96d..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.3.0` +Version: `0.4.1` ## Seiten @@ -23,6 +23,7 @@ Version: `0.3.0` - `/reparatur` - `/ueber-uns` - `/kontakt` +- `/status/[token]` - `/impressum` - `/datenschutz` - `/admin/login` @@ -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 speichern und Testmail senden - Medien hochladen - SEO-Übersicht - Systemübersicht -Publishing von Website-Inhalten und Olympus-Übernahme sind bewusst noch nicht aktiv gekoppelt. SMTP-Versand fuer Kontakt- und Reparaturanfragen ist serverseitig angebunden, sofern `/admin/smtp` vollstaendig konfiguriert ist. +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 @@ -171,6 +230,44 @@ Uploads liegen unter `storage/uploads/images/`, werden über `/api/media/[filena Alte Uploads aus `public/uploads/images` werden beim Start einmalig nach `storage/uploads/images` migriert, falls sie dort noch nicht vorhanden sind. +Website-Inhalte liegen unter `storage/content/` und werden nicht committed: + +```text +storage/content/home.json +storage/content/services.json +storage/content/radio-service.json +storage/content/repair.json +storage/content/about.json +storage/content/contact.json +storage/content/settings.json +storage/content/backups/ +``` + +Jede Seite besitzt eine eigene JSON-Datei. React-Komponenten lesen diese Dateien nicht direkt, sondern ausschließlich über `lib/content/service.ts`. Vor jedem Speichern erzeugt der Service eine Backup-Datei unter `storage/content/backups/` und behält pro Dokument die letzten 20 Versionen. Änderungen benötigen keinen Neustart und keinen Docker-Build. + +Bearbeitbar sind aktuell: + +- Startseite mit Hero, Call-to-Actions, Featureboxen, Leistungsboxen und Kundenversprechen +- Leistungen mit beliebig vielen sortierbaren und ein-/ausblendbaren Einträgen +- Funkgeräte-Service mit Einleitung, Marken, Fehlerbildern, Ablauf, Messmöglichkeiten, Abgleich und Reparaturtexten +- Reparaturannahme mit SEO-Daten und Seitentexten +- Über Uns mit Firmenbeschreibung, Werkstattbeschreibung und Philosophie +- Kontakt mit Kontaktinformationen, Öffnungszeiten, Maps-Link und Seitentexten +- Footer, Logo, Copyright, Header-CTA und globale SEO-Einstellungen +- SEO pro Seite inklusive Meta Title, Meta Description, Keywords, OpenGraph und Social Image + +Bilder für Logo und Social Image werden aus dem Medienbestand ausgewählt. Neue Uploads bleiben im privaten Storage und werden über `/api/media/[filename]` ausgeliefert. + +## Roadmap Kundenportal + +Ein vollständiges Kundenportal ist vorbereitet, aber noch nicht öffentlich implementiert. Die öffentliche Statuslink-Seite ist bereits vorhanden. Für spätere Ausbaustufen sind folgende Routen vorgesehen: + +- `/status/[token]`: öffentliche Statuslink-Seite für Olympus +- `/reparatur/status`: Statusabfrage für Reparaturanfragen +- `/portal/login`: geschützter Kundenlogin + +Diese Routen sind bewusst noch nicht angelegt. Die spätere Umsetzung soll serverseitig erfolgen, ohne Tokens im Browser-JavaScript zu speichern und ohne bestehende Admin- oder CMS-Funktionen zu umgehen. + ## Healthcheck ```bash @@ -188,7 +285,7 @@ Antwort: ```json { "status": "ok", - "version": "0.3.0", + "version": "0.4.1", "storage": "ok", "admin": "configured", "smtp": "configured", diff --git a/app/admin/page.tsx b/app/admin/page.tsx index 9142ef2..5062996 100644 --- a/app/admin/page.tsx +++ b/app/admin/page.tsx @@ -1,8 +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; @@ -10,8 +13,23 @@ function countNew(items: T[]) { export default async function AdminDashboardPage() { if (!await requireAdminSession()) redirect("/admin/login"); - const [contacts, repairs, smtpSettings] = await Promise.all([getContactInquiries(), getRepairInquiries(), getSmtpSettings()]); + 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 ( @@ -20,10 +38,13 @@ export default async function AdminDashboardPage() {

Dashboard

-
Kontaktanfragen{countNew(contacts)}neu
-
Reparaturanfragen{countNew(repairs)}neu
-
WebsiteOnlineOK
+
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/system/page.tsx b/app/admin/system/page.tsx index c6f59ff..08aff5a 100644 --- a/app/admin/system/page.tsx +++ b/app/admin/system/page.tsx @@ -3,6 +3,7 @@ import AdminShell from "@/components/admin/AdminShell"; import { requireAdminSession } from "@/lib/admin/auth"; import { adminConfigurationStatus, appVersion, checkStorage, configDirectory, dataDirectory, isDockerEnvironment, olympusStatus, uploadDirectory } from "@/lib/runtime/config"; import { getSmtpSettings, isSmtpConfigured } from "@/lib/mail/config"; +import { getContentSystemStatus } from "@/lib/content/service"; export default async function AdminSystemPage() { if (!await requireAdminSession()) redirect("/admin/login"); @@ -13,7 +14,7 @@ export default async function AdminSystemPage() { } catch { storage = "error"; } - const smtpSettings = await getSmtpSettings(); + const [smtpSettings, contentStatus] = await Promise.all([getSmtpSettings(), getContentSystemStatus()]); const smtpConfigured = isSmtpConfigured(smtpSettings); return ( @@ -37,6 +38,10 @@ export default async function AdminSystemPage() {
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}
@@ -47,6 +52,22 @@ export default async function AdminSystemPage() {
  • 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/contact/route.ts b/app/api/contact/route.ts index 07555e4..6ea6537 100644 --- a/app/api/contact/route.ts +++ b/app/api/contact/route.ts @@ -13,7 +13,7 @@ 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 }); } 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/repair/route.ts b/app/api/repair/route.ts index 5fb6c1b..81c771c 100644 --- a/app/api/repair/route.ts +++ b/app/api/repair/route.ts @@ -14,9 +14,9 @@ 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 }); } 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 25bc51b..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; } @@ -985,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; @@ -1068,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; @@ -1095,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() {