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/README.md b/README.md index 8ccb7db..bdea9cb 100644 --- a/README.md +++ b/README.md @@ -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 @@ -260,9 +204,9 @@ Bilder für Logo und Social Image werden aus dem Medienbestand ausgewählt. Neue ## 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: +Ein Kundenportal ist vorbereitet, aber noch nicht öffentlich implementiert. Für spätere Ausbaustufen sind folgende Routen vorgesehen: -- `/status/[token]`: öffentliche Statuslink-Seite für Olympus +- `/status`: öffentliche Status-Einstiegsseite - `/reparatur/status`: Statusabfrage für Reparaturanfragen - `/portal/login`: geschützter Kundenlogin 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/globals.css b/app/globals.css index 174bfdc..1405840 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; @@ -1629,44 +1263,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; 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/components/status/EstimateActions.tsx b/components/status/EstimateActions.tsx deleted file mode 100644 index feebf37..0000000 --- a/components/status/EstimateActions.tsx +++ /dev/null @@ -1,153 +0,0 @@ -"use client"; - -import { useRouter } from "next/navigation"; -import { useState } from "react"; - -type EstimateAction = "approve" | "decline" | "question"; - -type EstimateActionsProps = { - token: string; -}; - -type ActionResponse = { - message?: string; -}; - -const actionLabels: Record = { - approve: "Freigeben", - decline: "Ablehnen", - question: "Rückfrage senden", -}; - -async function readActionMessage(response: Response) { - try { - const body = await response.json() as ActionResponse; - return body.message || "Die Aktion konnte nicht abgeschlossen werden."; - } catch { - return "Die Aktion konnte nicht abgeschlossen werden."; - } -} - -export function EstimateActions({ token }: EstimateActionsProps) { - const router = useRouter(); - const [activeForm, setActiveForm] = useState | null>(null); - const [message, setMessage] = useState(""); - const [pendingAction, setPendingAction] = useState(null); - const [feedback, setFeedback] = useState<{ type: "success" | "error"; text: string } | null>(null); - - async function submitAction(action: EstimateAction, actionMessage = "") { - setPendingAction(action); - setFeedback(null); - - try { - const response = await fetch(`/api/status/${encodeURIComponent(token)}/estimate/${action}`, { - method: "POST", - headers: { - "Content-Type": "application/json", - }, - body: JSON.stringify({ message: actionMessage.trim() || null }), - }); - const responseMessage = await readActionMessage(response); - - if (!response.ok) { - setFeedback({ type: "error", text: responseMessage }); - return; - } - - setMessage(""); - setActiveForm(null); - setFeedback({ type: "success", text: responseMessage }); - router.refresh(); - } catch { - setFeedback({ - type: "error", - text: "Die Aktion konnte momentan nicht gesendet werden. Bitte versuchen Sie es erneut.", - }); - } finally { - setPendingAction(null); - } - } - - const isPending = pendingAction !== null; - - return ( -
-
- - - -
- - {activeForm && ( -
{ - event.preventDefault(); - void submitAction(activeForm, message); - }} - > - -