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 b537029..7fccc25 100644 --- a/.gitignore +++ b/.gitignore @@ -12,6 +12,7 @@ data/smtp-settings.json storage/uploads/images/* storage/config/* storage/content/* +storage/content/backups/* !storage/.gitkeep !storage/config/.gitkeep !storage/content/.gitkeep diff --git a/Dockerfile b/Dockerfile index 94b7f82..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.4.0 +ENV NEXT_PUBLIC_APP_VERSION=0.4.1 ENV DOCKER_ENV=true ENV PORT=3010 diff --git a/README.md b/README.md index 591218f..8ccb7db 100644 --- a/README.md +++ b/README.md @@ -13,7 +13,7 @@ Eigenständige öffentliche Firmenwebsite für Funktechnik Schubert. Dieses Proj - Nginx/Reverse-Proxy-fähig - SEO Metadata, Sitemap und robots.txt -Version: `0.4.0` +Version: `0.4.1` ## Seiten @@ -23,6 +23,7 @@ Version: `0.4.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. @@ -130,6 +135,57 @@ 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 +- 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 @@ -180,6 +236,7 @@ Website-Inhalte liegen unter `storage/content/` und werden nicht committed: 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 @@ -193,6 +250,7 @@ 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 @@ -200,6 +258,16 @@ Bearbeitbar sind aktuell: 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 @@ -217,7 +285,7 @@ Antwort: ```json { "status": "ok", - "version": "0.4.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 35d1cea..08aff5a 100644 --- a/app/admin/system/page.tsx +++ b/app/admin/system/page.tsx @@ -52,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/api/admin/content/route.ts b/app/api/admin/content/route.ts index dc2c9b8..5725549 100644 --- a/app/api/admin/content/route.ts +++ b/app/api/admin/content/route.ts @@ -3,7 +3,7 @@ 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", "about", "contact", "settings"]; +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); 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/funkgeraete-service/page.tsx b/app/funkgeraete-service/page.tsx index 2f58d11..e814446 100644 --- a/app/funkgeraete-service/page.tsx +++ b/app/funkgeraete-service/page.tsx @@ -1,5 +1,6 @@ import Link from "next/link"; import PageHero from "@/components/PageHero"; +import { defaultContent } from "@/lib/content/defaults"; import { getContent } from "@/lib/content/service"; import { createContentMetadata } from "../seo"; @@ -7,7 +8,7 @@ export const dynamic = "force-dynamic"; export async function generateMetadata() { const content = await getContent("radio-service"); - return createContentMetadata(content.seo); + return createContentMetadata(content.seo, defaultContent["radio-service"].seo); } export default async function RadioServicePage() { diff --git a/app/globals.css b/app/globals.css index 8ffb79d..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; } @@ -1039,6 +1405,11 @@ h3 { 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); @@ -1094,6 +1465,27 @@ h3 { 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; } @@ -1110,6 +1502,10 @@ h3 { margin-bottom: 0; } +.content-preview-toolbar .eyebrow { + margin-bottom: 4px; +} + .content-preview-toolbar div { display: flex; flex-wrap: wrap; @@ -1233,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; @@ -1273,4 +1703,16 @@ h3 { .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/kontakt/page.tsx b/app/kontakt/page.tsx index 4dd02b2..da1c722 100644 --- a/app/kontakt/page.tsx +++ b/app/kontakt/page.tsx @@ -1,5 +1,6 @@ import PageHero from "@/components/PageHero"; import ContactForm from "@/components/ContactForm"; +import { defaultContent } from "@/lib/content/defaults"; import { getContent } from "@/lib/content/service"; import { createContentMetadata } from "../seo"; @@ -7,7 +8,7 @@ export const dynamic = "force-dynamic"; export async function generateMetadata() { const content = await getContent("contact"); - return createContentMetadata(content.seo); + return createContentMetadata(content.seo, defaultContent.contact.seo); } export default async function ContactPage() { diff --git a/app/layout.tsx b/app/layout.tsx index 565bf30..7d808f5 100644 --- a/app/layout.tsx +++ b/app/layout.tsx @@ -1,12 +1,13 @@ import type { Metadata } from "next"; import SiteFrame from "@/components/SiteFrame"; import "./globals.css"; +import { defaultContent } from "@/lib/content/defaults"; import { getContent } from "@/lib/content/service"; import { createContentMetadata } from "./seo"; export async function generateMetadata(): Promise { const settings = await getContent("settings"); - return createContentMetadata(settings.seo); + return createContentMetadata(settings.seo, defaultContent.settings.seo); } export default function RootLayout({ children }: Readonly<{ children: React.ReactNode }>) { diff --git a/app/leistungen/page.tsx b/app/leistungen/page.tsx index 20afa89..092e4e2 100644 --- a/app/leistungen/page.tsx +++ b/app/leistungen/page.tsx @@ -1,5 +1,6 @@ import PageHero from "@/components/PageHero"; import ServiceCard from "@/components/ServiceCard"; +import { defaultContent } from "@/lib/content/defaults"; import { getContent } from "@/lib/content/service"; import { createContentMetadata } from "../seo"; @@ -7,7 +8,7 @@ export const dynamic = "force-dynamic"; export async function generateMetadata() { const content = await getContent("services"); - return createContentMetadata(content.seo); + return createContentMetadata(content.seo, defaultContent.services.seo); } export default async function ServicesPage() { diff --git a/app/page.tsx b/app/page.tsx index 4d43644..0095e36 100644 --- a/app/page.tsx +++ b/app/page.tsx @@ -1,5 +1,6 @@ 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"; @@ -7,7 +8,7 @@ export const dynamic = "force-dynamic"; export async function generateMetadata() { const content = await getContent("home"); - return createContentMetadata(content.seo); + return createContentMetadata(content.seo, defaultContent.home.seo); } export default async function HomePage() { @@ -15,7 +16,10 @@ export default async function HomePage() { return ( <> -
    +

    {content.eyebrow}

    {content.title}

    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 2baa7ef..8fb7d2d 100644 --- a/app/seo.ts +++ b/app/seo.ts @@ -60,28 +60,32 @@ export function createMetadata(title: string, description: string, path = "/"): }; } -export function createContentMetadata(seo: SeoContent): Metadata { - const path = seo.canonicalUrl || "/"; +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 = seo.metaTitle || siteName; - const description = seo.metaDescription || defaultTitle; + const title = resolvedSeo.metaTitle || siteName; + const description = resolvedSeo.metaDescription || defaultTitle; return { ...createMetadata(title, description, path), - keywords: seo.keywords ? seo.keywords.split(",").map((keyword) => keyword.trim()).filter(Boolean) : keywords, + keywords: resolvedSeo.keywords ? resolvedSeo.keywords.split(",").map((keyword) => keyword.trim()).filter(Boolean) : keywords, openGraph: { type: "website", locale: "de_DE", url, siteName, - title: seo.openGraphTitle || title, - description: seo.openGraphDescription || description, + title: resolvedSeo.openGraphTitle || title, + description: resolvedSeo.openGraphDescription || description, images: [ { - url: seo.socialImage || "/funktechnik_schubert_logo.jpg", + url: resolvedSeo.socialImage || "/funktechnik_schubert_logo.jpg", width: 1200, height: 630, - alt: seo.openGraphTitle || title, + alt: resolvedSeo.openGraphTitle || title, }, ], }, 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 74a8c31..51b630b 100644 --- a/app/ueber-uns/page.tsx +++ b/app/ueber-uns/page.tsx @@ -1,4 +1,5 @@ import PageHero from "@/components/PageHero"; +import { defaultContent } from "@/lib/content/defaults"; import { getContent } from "@/lib/content/service"; import { createContentMetadata } from "../seo"; @@ -6,7 +7,7 @@ export const dynamic = "force-dynamic"; export async function generateMetadata() { const content = await getContent("about"); - return createContentMetadata(content.seo); + return createContentMetadata(content.seo, defaultContent.about.seo); } export default async function AboutPage() { diff --git a/components/Footer.tsx b/components/Footer.tsx index 926df5b..00932ee 100644 --- a/components/Footer.tsx +++ b/components/Footer.tsx @@ -61,7 +61,7 @@ export default function Footer() {
    {settings.siteName}; -const tabs: Array<{ id: ContentTabId; key: ContentKey; label: string }> = [ - { id: "home", key: "home", label: "Startseite" }, - { id: "services", key: "services", label: "Leistungen" }, - { id: "radio-service", key: "radio-service", label: "Funkgeräte-Service" }, - { id: "about", key: "about", label: "Über Uns" }, - { id: "contact", key: "contact", label: "Kontakt" }, - { id: "footer", key: "settings", label: "Footer" }, - { id: "global-seo", key: "settings", label: "SEO" }, - { id: "general-settings", key: "settings", label: "Allgemeine Einstellungen" }, +const tabs: Array<{ id: ContentTabId; label: string }> = [ + { id: "home", label: "Startseite" }, + { id: "services", label: "Leistungen" }, + { id: "radio-service", label: "Funkgeräte-Service" }, + { id: "about", label: "Über uns" }, + { id: "contact", label: "Kontakt" }, + { id: "footer", label: "Footer" }, + { id: "seo", label: "SEO" }, +]; + +const seoPages: Array<{ key: SeoPageKey; label: string }> = [ + { key: "home", label: "Startseite" }, + { key: "services", label: "Leistungen" }, + { key: "radio-service", label: "Funkgeräte-Service" }, + { key: "repair", label: "Reparatur" }, + { key: "about", label: "Über uns" }, + { key: "contact", label: "Kontakt" }, ]; type Props = { @@ -28,6 +39,12 @@ type ApiResponse = { content?: ContentDocuments[K]; }; +function contentKeyForTab(tab: ContentTabId, seoPage: SeoPageKey): ContentKey { + if (tab === "footer") return "settings"; + if (tab === "seo") return seoPage; + return tab; +} + function linesToText(lines: string[]) { return lines.join("\n"); } @@ -58,23 +75,41 @@ function TextArea({ label, value, onChange, rows = 4 }: { label: string; value: ); } -function ImageSelect({ label, value, mediaFiles, onChange }: { label: string; value: string; mediaFiles: MediaFile[]; onChange: (value: string) => void }) { +function SectionTitle({ children }: { children: string }) { + return

    {children}

    ; +} + +function ImagePicker({ label, value, mediaFiles, onChange, fallback = "" }: { label: string; value: string; mediaFiles: MediaFile[]; fallback?: string; onChange: (value: string) => void }) { const images = mediaFiles.filter((file) => file.isImage); + const preview = value || fallback; return ( - +
    + + {preview && ( +
    + + +
    + )} +
    ); } -function SectionTitle({ children }: { children: string }) { - return

    {children}

    ; +function SaveActions({ pending, onSave }: { pending: boolean; onSave: () => void }) { + return ( +
    + +
    + ); } function BlocksEditor({ blocks, onChange }: { blocks: TextBlockContent[]; onChange: (blocks: TextBlockContent[]) => void }) { @@ -96,7 +131,7 @@ function BlocksEditor({ blocks, onChange }: { blocks: TextBlockContent[]; onChan ); } -function PageFields({ page, mediaFiles, onChange }: { page: PageContent; mediaFiles: MediaFile[]; onChange: (page: PageContent) => void }) { +function PageFields({ page, onChange }: { page: PageContent; onChange: (page: PageContent) => void }) { return ( <> Hero @@ -118,20 +153,24 @@ function PageFields({ page, mediaFiles, onChange }: { page: PageContent; mediaFi onChange({ ...page, cta: { ...page.cta, secondary: updateLink(page.cta.secondary, "label", value) } })} /> onChange({ ...page, cta: { ...page.cta, secondary: updateLink(page.cta.secondary, "href", value) } })} />
    - SEO -
    - onChange({ ...page, seo: { ...page.seo, metaTitle: value } })} /> - onChange({ ...page, seo: { ...page.seo, metaDescription: value } })} /> - onChange({ ...page, seo: { ...page.seo, keywords: value } })} /> - onChange({ ...page, seo: { ...page.seo, openGraphTitle: value } })} /> - onChange({ ...page, seo: { ...page.seo, openGraphDescription: value } })} /> - onChange({ ...page, seo: { ...page.seo, socialImage: value } })} /> - onChange({ ...page, seo: { ...page.seo, canonicalUrl: value } })} /> -
    ); } +function SeoFields({ seo, mediaFiles, onChange }: { seo: SeoContent; mediaFiles: MediaFile[]; onChange: (seo: SeoContent) => void }) { + return ( +
    + onChange({ ...seo, metaTitle: value })} /> + onChange({ ...seo, metaDescription: value })} /> + onChange({ ...seo, keywords: value })} /> + onChange({ ...seo, openGraphTitle: value })} /> + onChange({ ...seo, openGraphDescription: value })} /> + onChange({ ...seo, socialImage: value })} /> + onChange({ ...seo, canonicalUrl: value })} /> +
    + ); +} + function ServicesEditor({ services, onChange }: { services: ServiceContent[]; onChange: (services: ServiceContent[]) => void }) { function update(index: number, field: keyof ServiceContent, value: string | number | boolean) { onChange(services.map((service, current) => current === index ? { ...service, [field]: value } : service)); @@ -165,14 +204,16 @@ function ServicesEditor({ services, onChange }: { services: ServiceContent[]; on export default function ContentManager({ initialContent, mediaFiles }: Props) { const [content, setContent] = useState(initialContent); const [activeTab, setActiveTab] = useState("home"); + const [seoPage, setSeoPage] = useState("home"); const [viewport, setViewport] = useState("desktop"); const [message, setMessage] = useState(""); const [error, setError] = useState(""); const [pending, setPending] = useState(false); - const active = tabs.find((tab) => tab.id === activeTab)?.key ?? "home"; + const active = contentKeyForTab(activeTab, seoPage); const current = content[active]; - const activeLabel = useMemo(() => tabs.find((tab) => tab.id === activeTab)?.label ?? active, [active, activeTab]); + const activeLabel = useMemo(() => tabs.find((tab) => tab.id === activeTab)?.label ?? "Startseite", [activeTab]); + const pagePreview = "title" in current ? current : content.home; function update(key: K, value: ContentDocuments[K]) { setContent((previous) => ({ ...previous, [key]: value })); @@ -190,17 +231,17 @@ export default function ContentManager({ initialContent, mediaFiles }: Props) { body: JSON.stringify({ key: active, content: current }), }); const result = await response.json() as ApiResponse; - if (!response.ok || !result.content) throw new Error(result.message ?? "Inhalt konnte nicht gespeichert werden."); + if (!response.ok || !result.content) throw new Error("Inhalt konnte nicht gespeichert werden."); update(active, result.content); - setMessage(result.message ?? "Inhalt gespeichert."); - } catch (err) { - setError(err instanceof Error ? err.message : "Inhalt konnte nicht gespeichert werden."); + setMessage("Inhalt wurde gespeichert."); + } catch { + setError("Der Inhalt konnte nicht gespeichert werden. Bitte prüfen Sie die Eingaben und versuchen Sie es erneut."); } finally { setPending(false); } } - const pagePreview = "title" in current ? current : content.home; + const heroBackground = content.home.heroImage || "/workbench-signal.svg"; return (
    @@ -208,8 +249,8 @@ export default function ContentManager({ initialContent, mediaFiles }: Props) { {error &&

    {error}

    }
    - +
    - {"title" in current && ( - update(active, value as ContentDocuments[typeof active])} /> - )} - - {active === "home" && ( + {activeTab === "home" && ( <> + update("home", { ...content.home, ...value })} /> + Medien + update("home", { ...content.home, heroImage: value })} /> Homepage Builder