From efdc4d5be10e921f15eaa6a8de7547e0d8893dc4 Mon Sep 17 00:00:00 2001 From: Schubert Ferenc Date: Sat, 4 Jul 2026 21:06:43 +0200 Subject: [PATCH] feat(status): add public repair status page --- .env.example | 10 +++ README.md | 40 ++++++++- app/globals.css | 170 +++++++++++++++++++++++++++++++++++- app/status/[token]/page.tsx | 137 +++++++++++++++++++++++++++++ docker-compose.yml | 2 + lib/olympus/status.ts | 70 +++++++++++++++ 6 files changed, 425 insertions(+), 4 deletions(-) create mode 100644 app/status/[token]/page.tsx create mode 100644 lib/olympus/status.ts 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/README.md b/README.md index bdea9cb..fb3a74b 100644 --- a/README.md +++ b/README.md @@ -23,6 +23,7 @@ Version: `0.4.1` - `/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. @@ -130,6 +135,35 @@ Für lokale Entwicklung kann `AUTH_COOKIE_SECURE=false` bleiben. Produktiv muss - `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 + +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 @@ -204,9 +238,9 @@ Bilder für Logo und Social Image werden aus dem Medienbestand ausgewählt. Neue ## Roadmap Kundenportal -Ein Kundenportal ist vorbereitet, aber noch nicht öffentlich implementiert. Für spätere Ausbaustufen sind folgende Routen vorgesehen: +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`: öffentliche Status-Einstiegsseite +- `/status/[token]`: öffentliche Statuslink-Seite für Olympus - `/reparatur/status`: Statusabfrage für Reparaturanfragen - `/portal/login`: geschützter Kundenlogin diff --git a/app/globals.css b/app/globals.css index 1405840..e8ef982 100644 --- a/app/globals.css +++ b/app/globals.css @@ -488,6 +488,156 @@ 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; +} + .admin-login-page { min-height: 100vh; display: grid; @@ -1263,10 +1413,28 @@ 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 { + display: grid; + } + + .status-badge { + width: fit-content; + } + .admin-sidebar { position: static; height: auto; diff --git a/app/status/[token]/page.tsx b/app/status/[token]/page.tsx new file mode 100644 index 0000000..1962f85 --- /dev/null +++ b/app/status/[token]/page.tsx @@ -0,0 +1,137 @@ +import type { Metadata } from "next"; +import Link from "next/link"; +import { fetchOlympusRepairStatus, 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 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/docker-compose.yml b/docker-compose.yml index f96f7b6..9cc6fc3 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -15,6 +15,8 @@ services: AUTH_COOKIE_SECURE: ${AUTH_COOKIE_SECURE:-false} OLYMPUS_INTAKE_API_URL: ${OLYMPUS_INTAKE_API_URL:-} OLYMPUS_INTAKE_API_TOKEN: ${OLYMPUS_INTAKE_API_TOKEN:-} + OLYMPUS_PUBLIC_STATUS_API_URL: ${OLYMPUS_PUBLIC_STATUS_API_URL:-} + OLYMPUS_PUBLIC_STATUS_API_TOKEN: ${OLYMPUS_PUBLIC_STATUS_API_TOKEN:-} DOCKER_ENV: "true" volumes: - funktechnik-data:/app/data diff --git a/lib/olympus/status.ts b/lib/olympus/status.ts new file mode 100644 index 0000000..b0ac01e --- /dev/null +++ b/lib/olympus/status.ts @@ -0,0 +1,70 @@ +import "server-only"; + +export type OlympusRepairStatusHistoryItem = { + status: string; + status_label: string; + created_at: string; +}; + +export type OlympusRepairStatus = { + repair_number: string; + public_status_label: string; + device_manufacturer: string; + device_model: string; + status_history_public: OlympusRepairStatusHistoryItem[]; + updated_at: string; +}; + +export type RepairStatusResult = + | { ok: true; status: OlympusRepairStatus } + | { ok: false; reason: "invalid" | "unavailable" }; + +function getStatusApiBaseUrl() { + return process.env.OLYMPUS_PUBLIC_STATUS_API_URL?.replace(/\/$/, "") ?? ""; +} + +function getStatusApiToken() { + return process.env.OLYMPUS_PUBLIC_STATUS_API_TOKEN ?? ""; +} + +export async function fetchOlympusRepairStatus(token: string): Promise { + const baseUrl = getStatusApiBaseUrl(); + + if (!baseUrl || !token.trim()) { + return { ok: false, reason: "unavailable" }; + } + + const headers: HeadersInit = { + Accept: "application/json", + }; + const apiToken = getStatusApiToken(); + + if (apiToken) { + headers.Authorization = `Bearer ${apiToken}`; + } + + let response: Response; + + try { + response = await fetch(`${baseUrl}/public/repairs/status/${encodeURIComponent(token)}`, { + headers, + cache: "no-store", + }); + } catch { + return { ok: false, reason: "unavailable" }; + } + + if (response.status === 401 || response.status === 403 || response.status === 404) { + return { ok: false, reason: "invalid" }; + } + + if (!response.ok) { + return { ok: false, reason: "unavailable" }; + } + + try { + return { ok: true, status: await response.json() as OlympusRepairStatus }; + } catch { + return { ok: false, reason: "unavailable" }; + } +}