diff --git a/.env.example b/.env.example index 9046cd5..e020556 100644 --- a/.env.example +++ b/.env.example @@ -25,13 +25,3 @@ OLYMPUS_INTAKE_API_URL= # Optional future server-side Olympus intake token. # Leave empty until the Olympus integration is explicitly implemented. OLYMPUS_INTAKE_API_TOKEN= - -# Server-side Olympus base URL for public repair status links. -# Docker example when Hermes is reachable in the same network: http://hermes:8000 -# Production example: https://olympus.example.internal -# This URL must be reachable by the Next.js server, not by the browser. -OLYMPUS_PUBLIC_STATUS_API_URL= - -# Optional server-side token for future protected status API access. -# Leave empty for Olympus v0.8.1 unless the endpoint is protected later. -OLYMPUS_PUBLIC_STATUS_API_TOKEN= diff --git a/.gitignore b/.gitignore index 7fccc25..b537029 100644 --- a/.gitignore +++ b/.gitignore @@ -12,7 +12,6 @@ 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 2dffadb..94b7f82 100644 --- a/Dockerfile +++ b/Dockerfile @@ -14,7 +14,7 @@ FROM node:22-alpine AS runner WORKDIR /app ENV NODE_ENV=production ENV NEXT_TELEMETRY_DISABLED=1 -ENV NEXT_PUBLIC_APP_VERSION=0.4.1 +ENV NEXT_PUBLIC_APP_VERSION=0.4.0 ENV DOCKER_ENV=true ENV PORT=3010 diff --git a/README.md b/README.md index 8ccb7db..591218f 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.1` +Version: `0.4.0` ## Seiten @@ -23,7 +23,6 @@ Version: `0.4.1` - `/reparatur` - `/ueber-uns` - `/kontakt` -- `/status/[token]` - `/impressum` - `/datenschutz` - `/admin/login` @@ -110,12 +109,8 @@ Variablen: - `AUTH_COOKIE_SECURE`: `true` in Produktion mit HTTPS, lokal `false` - `OLYMPUS_INTAKE_API_URL`: vorbereitet für spätere serverseitige Olympus-Anbindung - `OLYMPUS_INTAKE_API_TOKEN`: vorbereitet für spätere serverseitige Olympus-Anbindung -- `OLYMPUS_PUBLIC_STATUS_API_URL`: serverseitig erreichbare Olympus-Basis-URL für öffentliche Reparaturstatuslinks -- `OLYMPUS_PUBLIC_STATUS_API_TOKEN`: optionaler serverseitiger Token für spätere geschützte Status-API-Zugriffe -Die Intake-Variablen sind für eine spätere serverseitige Integration vorbereitet. Die Status-Variablen werden für `/status/[token]` serverseitig verwendet. Sie werden nicht im Browser verwendet. - -Für Reparaturstatuslinks muss `OLYMPUS_PUBLIC_STATUS_API_URL` vom Next.js-Server erreichbar sein. In Docker kann das je nach Netzwerk z. B. `http://hermes:8000` sein. Produktiv sollte eine interne Server-zu-Server-Adresse oder eine abgesicherte Olympus-URL verwendet werden. +Die Olympus-Variablen sind nur für eine spätere serverseitige Integration vorbereitet. Sie werden nicht im Browser verwendet. Für lokale Entwicklung kann `AUTH_COOKIE_SECURE=false` bleiben. Produktiv muss HTTPS verwendet und `AUTH_COOKIE_SECURE=true` gesetzt werden. `ADMIN_SESSION_SECRET` muss produktiv ein langer zufälliger Wert sein. @@ -135,57 +130,6 @@ 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 @@ -236,7 +180,6 @@ 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 @@ -250,7 +193,6 @@ 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 @@ -258,16 +200,6 @@ 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 @@ -285,7 +217,7 @@ Antwort: ```json { "status": "ok", - "version": "0.4.1", + "version": "0.4.0", "storage": "ok", "admin": "configured", "smtp": "configured", diff --git a/app/admin/page.tsx b/app/admin/page.tsx index 5062996..9142ef2 100644 --- a/app/admin/page.tsx +++ b/app/admin/page.tsx @@ -1,11 +1,8 @@ import { redirect } from "next/navigation"; import AdminShell from "@/components/admin/AdminShell"; import { requireAdminSession } from "@/lib/admin/auth"; -import { listMediaFiles } from "@/lib/admin/media"; import { getContactInquiries, getRepairInquiries } from "@/lib/admin/store"; -import { getContentSummary } from "@/lib/content/service"; import { getSmtpSettings, isSmtpConfigured } from "@/lib/mail/config"; -import { appVersion, checkStorage } from "@/lib/runtime/config"; function countNew(items: T[]) { return items.filter((item) => item.status === "new").length; @@ -13,23 +10,8 @@ function countNew(items: T[]) { export default async function AdminDashboardPage() { if (!await requireAdminSession()) redirect("/admin/login"); - let healthStatus = "OK"; - - try { - await checkStorage(); - } catch { - healthStatus = "Fehler"; - } - - const [contacts, repairs, smtpSettings, mediaFiles, contentSummary] = await Promise.all([ - getContactInquiries(), - getRepairInquiries(), - getSmtpSettings(), - listMediaFiles(), - getContentSummary(), - ]); + const [contacts, repairs, smtpSettings] = await Promise.all([getContactInquiries(), getRepairInquiries(), getSmtpSettings()]); const latest = [...contacts, ...repairs].sort((a, b) => b.createdAt.localeCompare(a.createdAt)).slice(0, 6); - const contentModified = contentSummary.lastModified ? new Date(contentSummary.lastModified).toLocaleString("de-DE") : "keine Änderung"; return ( @@ -38,13 +20,10 @@ export default async function AdminDashboardPage() {

Dashboard

-
Kontaktanfragen{contacts.length}{countNew(contacts)} neu
-
Reparaturanfragen{repairs.length}{countNew(repairs)} neu
-
Medien{mediaFiles.length}im Storage
-
Content{contentSummary.pageCount}{contentModified}
+
Kontaktanfragen{countNew(contacts)}neu
+
Reparaturanfragen{countNew(repairs)}neu
+
WebsiteOnlineOK
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 08aff5a..35d1cea 100644 --- a/app/admin/system/page.tsx +++ b/app/admin/system/page.tsx @@ -52,22 +52,6 @@ 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 5725549..dc2c9b8 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", "repair", "about", "contact", "settings"]; +const keys: ContentKey[] = ["home", "services", "radio-service", "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 deleted file mode 100644 index 27e9345..0000000 --- a/app/api/status/[token]/estimate/action-response.ts +++ /dev/null @@ -1,36 +0,0 @@ -import { NextResponse } from "next/server"; -import type { EstimateActionResult } from "@/lib/olympus/status"; - -export function estimateActionResponse(result: EstimateActionResult, successMessage: string) { - if (result.ok) { - return NextResponse.json({ message: successMessage }); - } - - if (result.reason === "invalid") { - return NextResponse.json( - { message: "Dieser Statuslink ist ungültig oder nicht mehr aktiv." }, - { status: 404 }, - ); - } - - if (result.reason === "unavailable") { - return NextResponse.json( - { message: "Der Reparaturstatus ist momentan nicht abrufbar." }, - { status: 502 }, - ); - } - - return NextResponse.json( - { message: "Die Aktion konnte nicht abgeschlossen werden. Bitte versuchen Sie es erneut." }, - { status: 400 }, - ); -} - -export async function readOptionalMessage(request: Request) { - try { - const body = await request.json() as { message?: unknown }; - return typeof body.message === "string" ? body.message : ""; - } catch { - return ""; - } -} diff --git a/app/api/status/[token]/estimate/approve/route.ts b/app/api/status/[token]/estimate/approve/route.ts deleted file mode 100644 index a3ea9c8..0000000 --- a/app/api/status/[token]/estimate/approve/route.ts +++ /dev/null @@ -1,15 +0,0 @@ -import { submitOlympusEstimateAction } from "@/lib/olympus/status"; -import { estimateActionResponse } from "../action-response"; - -type RouteContext = { - params: Promise<{ - token: string; - }>; -}; - -export async function POST(_request: Request, { params }: RouteContext) { - const { token } = await params; - const result = await submitOlympusEstimateAction(token, "approve"); - - return estimateActionResponse(result, "Kostenvoranschlag wurde freigegeben."); -} diff --git a/app/api/status/[token]/estimate/decline/route.ts b/app/api/status/[token]/estimate/decline/route.ts deleted file mode 100644 index 0e18d1a..0000000 --- a/app/api/status/[token]/estimate/decline/route.ts +++ /dev/null @@ -1,16 +0,0 @@ -import { submitOlympusEstimateAction } from "@/lib/olympus/status"; -import { estimateActionResponse, readOptionalMessage } from "../action-response"; - -type RouteContext = { - params: Promise<{ - token: string; - }>; -}; - -export async function POST(request: Request, { params }: RouteContext) { - const { token } = await params; - const message = await readOptionalMessage(request); - const result = await submitOlympusEstimateAction(token, "decline", message); - - return estimateActionResponse(result, "Kostenvoranschlag wurde abgelehnt."); -} diff --git a/app/api/status/[token]/estimate/question/route.ts b/app/api/status/[token]/estimate/question/route.ts deleted file mode 100644 index 4ff10f4..0000000 --- a/app/api/status/[token]/estimate/question/route.ts +++ /dev/null @@ -1,16 +0,0 @@ -import { submitOlympusEstimateAction } from "@/lib/olympus/status"; -import { estimateActionResponse, readOptionalMessage } from "../action-response"; - -type RouteContext = { - params: Promise<{ - token: string; - }>; -}; - -export async function POST(request: Request, { params }: RouteContext) { - const { token } = await params; - const message = await readOptionalMessage(request); - const result = await submitOlympusEstimateAction(token, "question", message); - - return estimateActionResponse(result, "Ihre Rückfrage wurde gesendet."); -} diff --git a/app/funkgeraete-service/page.tsx b/app/funkgeraete-service/page.tsx index e814446..2f58d11 100644 --- a/app/funkgeraete-service/page.tsx +++ b/app/funkgeraete-service/page.tsx @@ -1,6 +1,5 @@ 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"; @@ -8,7 +7,7 @@ export const dynamic = "force-dynamic"; export async function generateMetadata() { const content = await getContent("radio-service"); - return createContentMetadata(content.seo, defaultContent["radio-service"].seo); + return createContentMetadata(content.seo); } export default async function RadioServicePage() { diff --git a/app/globals.css b/app/globals.css index 174bfdc..8ffb79d 100644 --- a/app/globals.css +++ b/app/globals.css @@ -488,372 +488,6 @@ h3 { line-height: 1; } -.status-page { - min-height: calc(100vh - 78px); - padding: 72px 0; - background: - linear-gradient(135deg, rgba(244, 246, 248, 0.96), rgba(232, 238, 246, 0.94)), - url("/workbench-signal.svg"); - background-position: center; - background-size: cover; -} - -.status-page-inner { - display: grid; - align-items: start; -} - -.status-card { - border: 1px solid rgba(8, 42, 96, 0.12); - border-radius: var(--radius); - background: rgba(255, 255, 255, 0.97); - padding: 34px; - box-shadow: var(--shadow); -} - -.status-card-narrow { - width: min(760px, 100%); - margin: 0 auto; -} - -.status-card h1 { - margin-bottom: 10px; - color: var(--ink); - font-size: clamp(36px, 5vw, 58px); - line-height: 1; -} - -.status-title-row { - display: flex; - align-items: flex-start; - justify-content: space-between; - gap: 24px; - margin-bottom: 26px; -} - -.status-badge, -.status-pill { - display: inline-flex; - min-height: 32px; - align-items: center; - border-radius: 999px; - border: 1px solid rgba(30, 159, 184, 0.24); - background: rgba(30, 159, 184, 0.1); - color: var(--navy); - font-size: 13px; - font-weight: 800; - padding: 0 12px; - white-space: nowrap; -} - -.status-summary { - display: grid; - grid-template-columns: repeat(4, minmax(0, 1fr)); - gap: 14px; - margin: 0 0 34px; -} - -.status-summary div { - border: 1px solid var(--line); - border-radius: var(--radius); - background: #f8fafc; - padding: 16px; -} - -.status-summary dt { - margin-bottom: 8px; - color: var(--muted); - font-size: 12px; - font-weight: 800; - text-transform: uppercase; -} - -.status-summary dd { - margin: 0; - color: var(--ink); - font-size: 17px; - font-weight: 800; -} - -.status-timeline-section h2 { - margin-bottom: 20px; - font-size: clamp(26px, 3vw, 34px); -} - -.public-status-timeline { - display: grid; - gap: 0; - margin: 0; - padding: 0; - list-style: none; -} - -.public-status-timeline li { - position: relative; - border-left: 2px solid #d7e0ea; - padding: 0 0 18px 26px; -} - -.public-status-timeline li:last-child { - padding-bottom: 0; -} - -.timeline-dot { - position: absolute; - left: -8px; - top: 15px; - width: 14px; - height: 14px; - border: 3px solid #fff; - border-radius: 999px; - background: var(--steel); - box-shadow: 0 0 0 1px rgba(8, 42, 96, 0.12); -} - -.public-status-timeline li.is-current .timeline-dot { - background: var(--orange); -} - -.timeline-content { - display: flex; - align-items: flex-start; - justify-content: space-between; - gap: 18px; - border: 1px solid var(--line); - border-radius: var(--radius); - background: #fff; - padding: 16px; -} - -.timeline-title { - margin-bottom: 8px; - color: var(--ink); - font-size: 17px; - font-weight: 800; -} - -.timeline-content time, -.status-muted { - color: var(--muted); - font-size: 14px; -} - -.estimate-card { - display: grid; - gap: 22px; - border: 1px solid rgba(8, 42, 96, 0.12); - border-radius: var(--radius); - background: #f8fafc; - margin: 0 0 34px; - padding: 24px; -} - -.estimate-header, -.estimate-item, -.estimate-totals div, -.estimate-form-actions { - display: flex; - align-items: flex-start; - justify-content: space-between; - gap: 18px; -} - -.estimate-header h2 { - margin: 0 0 8px; - color: var(--ink); - font-size: clamp(26px, 3vw, 34px); -} - -.estimate-status { - display: inline-flex; - min-height: 32px; - align-items: center; - border: 1px solid rgba(232, 124, 46, 0.28); - border-radius: 999px; - background: rgba(232, 124, 46, 0.12); - color: #8b420d; - font-size: 13px; - font-weight: 800; - padding: 0 12px; - white-space: nowrap; -} - -.estimate-message, -.estimate-status-text { - border-left: 4px solid var(--steel); - background: #fff; - color: var(--ink); - margin: 0; - padding: 14px 16px; -} - -.estimate-meta { - display: grid; - grid-template-columns: repeat(2, minmax(0, 1fr)); - gap: 12px; - margin: 0; -} - -.estimate-meta div { - border: 1px solid var(--line); - border-radius: 8px; - background: #fff; - padding: 14px; -} - -.estimate-meta dt, -.estimate-totals dt { - color: var(--muted); - font-size: 12px; - font-weight: 800; - text-transform: uppercase; -} - -.estimate-meta dd, -.estimate-totals dd { - margin: 6px 0 0; - color: var(--ink); - font-weight: 800; -} - -.estimate-items { - display: grid; - gap: 12px; -} - -.estimate-item { - border: 1px solid var(--line); - border-radius: 8px; - background: #fff; - padding: 16px; -} - -.estimate-item h3 { - margin: 6px 0; - color: var(--ink); - font-size: 18px; -} - -.estimate-item p { - margin: 0; - color: var(--muted); -} - -.estimate-item-type { - color: var(--steel); - font-size: 12px; - font-weight: 800; - text-transform: uppercase; -} - -.estimate-item-price { - display: grid; - gap: 6px; - min-width: 150px; - text-align: right; -} - -.estimate-item-price span { - color: var(--muted); - font-size: 14px; -} - -.estimate-item-price strong { - color: var(--ink); - font-size: 18px; -} - -.estimate-totals { - display: grid; - gap: 8px; - width: min(380px, 100%); - justify-self: end; - margin: 0; -} - -.estimate-totals div { - border-bottom: 1px solid var(--line); - padding: 8px 0; -} - -.estimate-totals .estimate-total-highlight { - border-bottom: 0; - color: var(--ink); - font-size: 20px; -} - -.estimate-actions-panel { - display: grid; - gap: 16px; - border-top: 1px solid var(--line); - padding-top: 18px; -} - -.estimate-action-buttons { - display: flex; - flex-wrap: wrap; - gap: 12px; -} - -.button.danger { - border-color: #a33a2d; - background: #a33a2d; - color: #fff; -} - -.button.danger:hover { - background: #842f25; -} - -.button:disabled { - cursor: not-allowed; - opacity: 0.64; -} - -.estimate-action-form { - display: grid; - gap: 10px; - border: 1px solid var(--line); - border-radius: 8px; - background: #fff; - padding: 16px; -} - -.estimate-action-form label { - color: var(--ink); - font-weight: 800; -} - -.estimate-action-form textarea { - width: 100%; - border: 1px solid #cfd8e5; - border-radius: 8px; - color: var(--ink); - padding: 12px; - resize: vertical; -} - -.estimate-form-actions { - justify-content: flex-start; - flex-wrap: wrap; -} - -.estimate-feedback { - border: 1px solid rgba(46, 125, 50, 0.24); - border-radius: 8px; - background: rgba(46, 125, 50, 0.08); - color: #1b5e20; - font-weight: 800; - margin: 0; - padding: 12px 14px; -} - -.estimate-feedback.error { - border-color: rgba(163, 58, 45, 0.26); - background: rgba(163, 58, 45, 0.08); - color: #842f25; -} - .admin-login-page { min-height: 100vh; display: grid; @@ -996,7 +630,7 @@ h3 { .admin-kpis { display: grid; - grid-template-columns: repeat(auto-fit, minmax(180px, 1fr)); + grid-template-columns: repeat(4, minmax(0, 1fr)); gap: 14px; margin-bottom: 18px; } @@ -1405,11 +1039,6 @@ 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); @@ -1465,27 +1094,6 @@ 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; } @@ -1502,10 +1110,6 @@ h3 { margin-bottom: 0; } -.content-preview-toolbar .eyebrow { - margin-bottom: 4px; -} - .content-preview-toolbar div { display: flex; flex-wrap: wrap; @@ -1629,44 +1233,10 @@ h3 { .admin-shell, .admin-kpis, .admin-detail-grid, - .media-grid, - .status-summary { + .media-grid { grid-template-columns: 1fr; } - .status-page { - padding: 38px 0; - } - - .status-card { - padding: 22px; - } - - .status-title-row, - .timeline-content, - .estimate-header, - .estimate-item { - display: grid; - } - - .status-badge, - .estimate-status { - width: fit-content; - } - - .estimate-meta { - grid-template-columns: 1fr; - } - - .estimate-item-price { - min-width: 0; - text-align: left; - } - - .estimate-totals { - justify-self: stretch; - } - .admin-sidebar { position: static; height: auto; @@ -1703,16 +1273,4 @@ 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 da1c722..4dd02b2 100644 --- a/app/kontakt/page.tsx +++ b/app/kontakt/page.tsx @@ -1,6 +1,5 @@ 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"; @@ -8,7 +7,7 @@ export const dynamic = "force-dynamic"; export async function generateMetadata() { const content = await getContent("contact"); - return createContentMetadata(content.seo, defaultContent.contact.seo); + return createContentMetadata(content.seo); } export default async function ContactPage() { diff --git a/app/layout.tsx b/app/layout.tsx index 7d808f5..565bf30 100644 --- a/app/layout.tsx +++ b/app/layout.tsx @@ -1,13 +1,12 @@ import type { Metadata } from "next"; import SiteFrame from "@/components/SiteFrame"; import "./globals.css"; -import { defaultContent } from "@/lib/content/defaults"; import { getContent } from "@/lib/content/service"; import { createContentMetadata } from "./seo"; export async function generateMetadata(): Promise { const settings = await getContent("settings"); - return createContentMetadata(settings.seo, defaultContent.settings.seo); + return createContentMetadata(settings.seo); } export default function RootLayout({ children }: Readonly<{ children: React.ReactNode }>) { diff --git a/app/leistungen/page.tsx b/app/leistungen/page.tsx index 092e4e2..20afa89 100644 --- a/app/leistungen/page.tsx +++ b/app/leistungen/page.tsx @@ -1,6 +1,5 @@ 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"; @@ -8,7 +7,7 @@ export const dynamic = "force-dynamic"; export async function generateMetadata() { const content = await getContent("services"); - return createContentMetadata(content.seo, defaultContent.services.seo); + return createContentMetadata(content.seo); } export default async function ServicesPage() { diff --git a/app/page.tsx b/app/page.tsx index 0095e36..4d43644 100644 --- a/app/page.tsx +++ b/app/page.tsx @@ -1,6 +1,5 @@ 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"; @@ -8,7 +7,7 @@ export const dynamic = "force-dynamic"; export async function generateMetadata() { const content = await getContent("home"); - return createContentMetadata(content.seo, defaultContent.home.seo); + return createContentMetadata(content.seo); } export default async function HomePage() { @@ -16,10 +15,7 @@ export default async function HomePage() { return ( <> -
    +

    {content.eyebrow}

    {content.title}

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

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

    -

    {paragraph?.text || content.intro}

    +

    Vor der Einsendung

    +

    Bitte senden Sie Geräte erst nach Rückmeldung ein. Vollständige Gerätedaten und eine präzise Fehlerbeschreibung helfen, den Aufwand besser einzuschätzen.

      - {content.list.map((item) =>
    • {item}
    • )} +
    • Hersteller, Modell, Geräteart und Seriennummer bereithalten, soweit vorhanden
    • +
    • Fehler möglichst konkret beschreiben: Empfang, Sendung, Modulation, Frequenz, Anzeige oder Versorgung
    • +
    • Zubehör wie Netzteil, Mikrofon, Antennenadapter oder Kabel angeben
    • +
    • Bereits geöffnete Geräte und durchgeführte Arbeiten ehrlich nennen
    • +
    • Fotos können später nach Rückmeldung ergänzt werden
    diff --git a/app/seo.ts b/app/seo.ts index 8fb7d2d..2baa7ef 100644 --- a/app/seo.ts +++ b/app/seo.ts @@ -60,32 +60,28 @@ 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 || "/"; +export function createContentMetadata(seo: SeoContent): Metadata { + const path = seo.canonicalUrl || "/"; const url = new URL(path, siteUrl).toString(); - const title = resolvedSeo.metaTitle || siteName; - const description = resolvedSeo.metaDescription || defaultTitle; + const title = seo.metaTitle || siteName; + const description = seo.metaDescription || defaultTitle; return { ...createMetadata(title, description, path), - keywords: resolvedSeo.keywords ? resolvedSeo.keywords.split(",").map((keyword) => keyword.trim()).filter(Boolean) : keywords, + keywords: seo.keywords ? seo.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, + title: seo.openGraphTitle || title, + description: seo.openGraphDescription || description, images: [ { - url: resolvedSeo.socialImage || "/funktechnik_schubert_logo.jpg", + url: seo.socialImage || "/funktechnik_schubert_logo.jpg", width: 1200, height: 630, - alt: resolvedSeo.openGraphTitle || title, + alt: seo.openGraphTitle || title, }, ], }, diff --git a/app/status/[token]/page.tsx b/app/status/[token]/page.tsx deleted file mode 100644 index 00fdf7a..0000000 --- a/app/status/[token]/page.tsx +++ /dev/null @@ -1,268 +0,0 @@ -import type { Metadata } from "next"; -import Link from "next/link"; -import { EstimateActions } from "@/components/status/EstimateActions"; -import { - fetchOlympusRepairStatus, - type OlympusEstimate, - type OlympusRepairStatus, -} from "@/lib/olympus/status"; - -type PageProps = { - params: Promise<{ - token: string; - }>; -}; - -export const dynamic = "force-dynamic"; - -export const metadata: Metadata = { - title: "Reparaturstatus | Funktechnik Schubert", - description: "Öffentlicher Reparaturstatus für Kunden von Funktechnik Schubert.", - robots: { - index: false, - follow: false, - }, -}; - -function formatDate(value: string) { - return new Intl.DateTimeFormat("de-DE", { - dateStyle: "medium", - timeStyle: "short", - }).format(new Date(value)); -} - -function formatDateOnly(value: string | null) { - if (!value) { - return "Nicht angegeben"; - } - - return new Intl.DateTimeFormat("de-DE", { - dateStyle: "medium", - }).format(new Date(value)); -} - -function formatMoney(cents: number, currency: string) { - return new Intl.NumberFormat("de-DE", { - style: "currency", - currency: currency || "EUR", - }).format(cents / 100); -} - -function estimateStatusLabel(status: string) { - const labels: Record = { - draft: "In Vorbereitung", - sent: "Zur Freigabe", - approved: "Freigegeben", - declined: "Abgelehnt", - expired: "Abgelaufen", - cancelled: "Storniert", - }; - - return labels[status] ?? status; -} - -function estimateStatusText(status: string) { - const texts: Record = { - sent: "Bitte prüfen Sie den Kostenvoranschlag. Sie können ihn freigeben, ablehnen oder eine Rückfrage senden.", - approved: "Dieser Kostenvoranschlag wurde freigegeben.", - declined: "Dieser Kostenvoranschlag wurde abgelehnt.", - expired: "Dieser Kostenvoranschlag ist nicht mehr gültig.", - cancelled: "Dieser Kostenvoranschlag wurde storniert.", - }; - - return texts[status] ?? "Der Kostenvoranschlag ist aktuell nicht zur Bearbeitung freigegeben."; -} - -function estimateItemTypeLabel(type: string) { - const labels: Record = { - labor: "Arbeitszeit", - part: "Ersatzteil", - material: "Material", - service: "Service", - shipping: "Versand", - other: "Sonstiges", - }; - - return labels[type] ?? "Position"; -} - -function EstimateCard({ estimate, token }: { estimate: OlympusEstimate | null | undefined; token: string }) { - if (!estimate) { - return null; - } - - return ( -
    -
    -
    -

    Kostenvoranschlag

    -

    {estimate.title || "Kostenvoranschlag"}

    -

    Nummer {estimate.estimate_number}

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

    {estimate.customer_message}

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

    {item.title}

    - {item.description &&

    {item.description}

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

    {estimateStatusText(estimate.status)}

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

    Reparaturstatus

    -

    {title}

    -

    {text}

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

    Noch keine Statushistorie vorhanden.

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

    Reparaturstatus

    -
    -
    -

    Reparaturstatus

    -

    Hier sehen Sie den aktuellen Stand Ihrer Reparatur.

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

    Statusverlauf

    - -
    -
    -
    -
    - ); -} diff --git a/app/ueber-uns/page.tsx b/app/ueber-uns/page.tsx index 51b630b..74a8c31 100644 --- a/app/ueber-uns/page.tsx +++ b/app/ueber-uns/page.tsx @@ -1,5 +1,4 @@ import PageHero from "@/components/PageHero"; -import { defaultContent } from "@/lib/content/defaults"; import { getContent } from "@/lib/content/service"; import { createContentMetadata } from "../seo"; @@ -7,7 +6,7 @@ export const dynamic = "force-dynamic"; export async function generateMetadata() { const content = await getContent("about"); - return createContentMetadata(content.seo, defaultContent.about.seo); + return createContentMetadata(content.seo); } export default async function AboutPage() { diff --git a/components/Footer.tsx b/components/Footer.tsx index 00932ee..926df5b 100644 --- a/components/Footer.tsx +++ b/components/Footer.tsx @@ -61,7 +61,7 @@ export default function Footer() {
    {settings.siteName}; +type ContentTabId = ContentKey | "footer" | "global-seo" | "general-settings"; -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" }, +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" }, ]; type Props = { @@ -39,12 +28,6 @@ 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"); } @@ -75,43 +58,25 @@ 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 }) { + const images = mediaFiles.filter((file) => file.isImage); + + return ( + + ); +} + 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 SaveActions({ pending, onSave }: { pending: boolean; onSave: () => void }) { - return ( -
    - -
    - ); -} - function BlocksEditor({ blocks, onChange }: { blocks: TextBlockContent[]; onChange: (blocks: TextBlockContent[]) => void }) { function update(index: number, field: keyof TextBlockContent, value: string) { onChange(blocks.map((block, current) => current === index ? { ...block, [field]: value } : block)); @@ -131,7 +96,7 @@ function BlocksEditor({ blocks, onChange }: { blocks: TextBlockContent[]; onChan ); } -function PageFields({ page, onChange }: { page: PageContent; onChange: (page: PageContent) => void }) { +function PageFields({ page, mediaFiles, onChange }: { page: PageContent; mediaFiles: MediaFile[]; onChange: (page: PageContent) => void }) { return ( <> Hero @@ -153,24 +118,20 @@ function PageFields({ page, onChange }: { page: PageContent; onChange: (page: Pa 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)); @@ -204,16 +165,14 @@ 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 = contentKeyForTab(activeTab, seoPage); + const active = tabs.find((tab) => tab.id === activeTab)?.key ?? "home"; const current = content[active]; - const activeLabel = useMemo(() => tabs.find((tab) => tab.id === activeTab)?.label ?? "Startseite", [activeTab]); - const pagePreview = "title" in current ? current : content.home; + const activeLabel = useMemo(() => tabs.find((tab) => tab.id === activeTab)?.label ?? active, [active, activeTab]); function update(key: K, value: ContentDocuments[K]) { setContent((previous) => ({ ...previous, [key]: value })); @@ -231,17 +190,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("Inhalt konnte nicht gespeichert werden."); + if (!response.ok || !result.content) throw new Error(result.message ?? "Inhalt konnte nicht gespeichert werden."); update(active, result.content); - setMessage("Inhalt wurde gespeichert."); - } catch { - setError("Der Inhalt konnte nicht gespeichert werden. Bitte prüfen Sie die Eingaben und versuchen Sie es erneut."); + setMessage(result.message ?? "Inhalt gespeichert."); + } catch (err) { + setError(err instanceof Error ? err.message : "Inhalt konnte nicht gespeichert werden."); } finally { setPending(false); } } - const heroBackground = content.home.heroImage || "/workbench-signal.svg"; + const pagePreview = "title" in current ? current : content.home; return (
    @@ -249,8 +208,8 @@ export default function ContentManager({ initialContent, mediaFiles }: Props) { {error &&

    {error}

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