mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-10 13:22:14 +00:00
Icons inside buttons drop their manual margins in favour of the button's built-in gap (two hundred call sites), form field stacks settle on one spacing step, card titles on exactly two sizes, inline field errors on one text size, and a dozen hand-rolled loading pulses move onto the shared skeleton primitive while keeping their silhouettes. The intake dialog wording joins the rest of the app on 'übersprungen', and the UI guidelines now document the two deliberate footer and touch-target systems.
122 lines
3.4 KiB
TypeScript
122 lines
3.4 KiB
TypeScript
"use client";
|
|
|
|
import { useState } from "react";
|
|
import { CheckCircle2, Loader2, Plug, XCircle } from "lucide-react";
|
|
|
|
import { Button } from "@/components/ui/button";
|
|
import { useTranslations } from "@/lib/i18n/context";
|
|
|
|
/**
|
|
* Shared "Test connection" UX for the settings integrations + notifications
|
|
* sections (A8-UI). POSTs to the given endpoint, expects a `{ data, error,
|
|
* meta }` envelope, and surfaces success (latency) or a translated
|
|
* `meta.errorCode` callout.
|
|
*
|
|
* Endpoints are user-scoped, so the button intentionally does NOT send an
|
|
* Idempotency-Key — each click probes the upstream live.
|
|
*/
|
|
export interface TestConnectionButtonProps {
|
|
endpoint: string;
|
|
/** Optional disabled flag from the parent — typically "no credentials yet". */
|
|
disabled?: boolean;
|
|
/** Label override for the button (defaults to settings.testConnection.test). */
|
|
label?: string;
|
|
}
|
|
|
|
interface TestResponse {
|
|
data?: { latencyMs?: number; ok?: boolean; sent?: number };
|
|
error?: string;
|
|
meta?: { errorCode?: string };
|
|
}
|
|
|
|
export function TestConnectionButton({
|
|
endpoint,
|
|
disabled = false,
|
|
label,
|
|
}: TestConnectionButtonProps) {
|
|
const { t } = useTranslations();
|
|
const [testing, setTesting] = useState(false);
|
|
const [result, setResult] = useState<
|
|
| { kind: "ok"; latency: number }
|
|
| { kind: "error"; errorCode: string }
|
|
| null
|
|
>(null);
|
|
|
|
async function handleClick() {
|
|
setTesting(true);
|
|
setResult(null);
|
|
try {
|
|
const res = await fetch(endpoint, { method: "POST" });
|
|
const json = (await res.json().catch(() => ({}))) as TestResponse;
|
|
|
|
if (res.ok && json.data?.ok !== false) {
|
|
setResult({
|
|
kind: "ok",
|
|
latency: json.data?.latencyMs ?? 0,
|
|
});
|
|
} else {
|
|
setResult({
|
|
kind: "error",
|
|
errorCode: json.meta?.errorCode ?? "generic",
|
|
});
|
|
}
|
|
} catch {
|
|
setResult({ kind: "error", errorCode: "connection_failed" });
|
|
} finally {
|
|
setTesting(false);
|
|
}
|
|
}
|
|
|
|
function describeError(errorCode: string): string {
|
|
const key = `settings.testConnection.errors.${errorCode}`;
|
|
const translated = t(key);
|
|
// `t()` returns the raw key when missing — fall back to generic.
|
|
if (translated === key) {
|
|
return t("settings.testConnection.errors.generic");
|
|
}
|
|
return translated;
|
|
}
|
|
|
|
return (
|
|
<div className="space-y-2">
|
|
<Button
|
|
type="button"
|
|
variant="outline"
|
|
size="sm"
|
|
className="min-h-11"
|
|
onClick={handleClick}
|
|
disabled={disabled || testing}
|
|
>
|
|
{testing ? (
|
|
<Loader2 className="h-3.5 w-3.5 animate-spin motion-reduce:animate-none" />
|
|
) : (
|
|
<Plug className="h-3.5 w-3.5" />
|
|
)}
|
|
{testing
|
|
? t("settings.testConnection.testing")
|
|
: (label ?? t("settings.testConnection.test"))}
|
|
</Button>
|
|
|
|
{result?.kind === "ok" && (
|
|
<p
|
|
role="status"
|
|
className="text-success flex items-center gap-1.5 text-xs"
|
|
>
|
|
<CheckCircle2 className="h-3.5 w-3.5" />
|
|
{t("settings.testConnection.ok", { latency: result.latency })}
|
|
</p>
|
|
)}
|
|
|
|
{result?.kind === "error" && (
|
|
<p
|
|
role="alert"
|
|
className="text-destructive flex items-center gap-1.5 text-sm"
|
|
>
|
|
<XCircle className="h-3.5 w-3.5" />
|
|
{describeError(result.errorCode)}
|
|
</p>
|
|
)}
|
|
</div>
|
|
);
|
|
}
|