mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-11 13:52:20 +00:00
feat(web): show the error id on server failures and add the support info and report surfaces
This commit is contained in:
1 parent
800bebd45c
commit
f7386742b4
138 files changed
+1982
-318
No files matched your search
+24
-4
@@ -51,7 +51,10 @@
|
||||
"durationHoursMinutes": "{hours} Std. {minutes} Min.",
|
||||
"durationMinutes": "{minutes} Min.",
|
||||
"durationClockMinutes": "{time} Min.",
|
||||
"durationClockHours": "{time} Std."
|
||||
"durationClockHours": "{time} Std.",
|
||||
"errorIdLabel": "Fehler-ID",
|
||||
"errorIdCopy": "Fehler-ID kopieren",
|
||||
"errorIdCopied": "Fehler-ID kopiert"
|
||||
},
|
||||
"apiErrors": {
|
||||
"measurement": {
|
||||
@@ -6646,7 +6649,14 @@
|
||||
"docs": "Dokumentation",
|
||||
"credits": "Credits & Datenquellen",
|
||||
"linksHeading": "Quellen & Dokumentation",
|
||||
"newerAvailable": "Neue Version: {tag}"
|
||||
"newerAvailable": "Neue Version: {tag}",
|
||||
"support": {
|
||||
"title": "Support",
|
||||
"description": "Eine Zusammenfassung für eine Fehlermeldung, ohne Namen, E-Mail oder Gesundheitswerte.",
|
||||
"copy": "Support-Info kopieren",
|
||||
"copied": "Kopiert",
|
||||
"copyFailed": "Die Support-Info konnte nicht zusammengestellt werden."
|
||||
}
|
||||
},
|
||||
"testConnection": {
|
||||
"test": "Verbindung testen",
|
||||
@@ -7753,7 +7763,8 @@
|
||||
"eventDetails": "Event-Details",
|
||||
"closeDetails": "Details schließen",
|
||||
"processDetail": "Der Puffer ist flüchtig und auf 500 Einträge begrenzt.",
|
||||
"cardTitle": "Letzte Ereignisse"
|
||||
"cardTitle": "Letzte Ereignisse",
|
||||
"filterErrorId": "Fehler-ID"
|
||||
},
|
||||
"backups": {
|
||||
"title": "Backups",
|
||||
@@ -8120,7 +8131,16 @@
|
||||
"codes": "Für den Betrieb: API-Anfragen antworten mit 503 und dem Fehlercode {code}, /api/health meldet den Grund {reason}."
|
||||
},
|
||||
"loginOverviewCardTitle": "Anmeldeereignisse",
|
||||
"userColumn": "Nutzer"
|
||||
"userColumn": "Nutzer",
|
||||
"supportReport": {
|
||||
"title": "Support-Bericht",
|
||||
"description": "Eine Zusammenfassung dieser Instanz für eine Fehlermeldung, ohne E-Mails, IDs oder Werte.",
|
||||
"body": "Konten erscheinen als „Account 1“, „Account 2“ und so weiter. Lies den Bericht, bevor du ihn teilst.",
|
||||
"copy": "Kopieren",
|
||||
"copied": "Kopiert",
|
||||
"download": "Herunterladen",
|
||||
"loadError": "Der Support-Bericht konnte nicht erstellt werden."
|
||||
}
|
||||
},
|
||||
"achievements": {
|
||||
"title": "Erfolge",
|
||||
|
||||
+24
-4
@@ -51,7 +51,10 @@
|
||||
"durationHoursMinutes": "{hours}h {minutes}m",
|
||||
"durationMinutes": "{minutes}m",
|
||||
"durationClockMinutes": "{time} min",
|
||||
"durationClockHours": "{time} h"
|
||||
"durationClockHours": "{time} h",
|
||||
"errorIdLabel": "Error ID",
|
||||
"errorIdCopy": "Copy error ID",
|
||||
"errorIdCopied": "Error ID copied"
|
||||
},
|
||||
"apiErrors": {
|
||||
"measurement": {
|
||||
@@ -6646,7 +6649,14 @@
|
||||
"docs": "Documentation",
|
||||
"credits": "Credits & data attributions",
|
||||
"linksHeading": "Sources & docs",
|
||||
"newerAvailable": "Newer version: {tag}"
|
||||
"newerAvailable": "Newer version: {tag}",
|
||||
"support": {
|
||||
"title": "Support",
|
||||
"description": "A summary for a bug report, without your name, email or health values.",
|
||||
"copy": "Copy support info",
|
||||
"copied": "Copied",
|
||||
"copyFailed": "The support info could not be put together."
|
||||
}
|
||||
},
|
||||
"testConnection": {
|
||||
"test": "Test connection",
|
||||
@@ -7753,7 +7763,8 @@
|
||||
"eventDetails": "Event details",
|
||||
"closeDetails": "Close details",
|
||||
"processDetail": "Buffer is volatile and capped at 500 entries.",
|
||||
"cardTitle": "Recent events"
|
||||
"cardTitle": "Recent events",
|
||||
"filterErrorId": "Error ID"
|
||||
},
|
||||
"backups": {
|
||||
"title": "Backups",
|
||||
@@ -8120,7 +8131,16 @@
|
||||
"codes": "For the operator: API requests answer 503 with error code {code}, and /api/health reports reason {reason}."
|
||||
},
|
||||
"loginOverviewCardTitle": "Sign-in events",
|
||||
"userColumn": "User"
|
||||
"userColumn": "User",
|
||||
"supportReport": {
|
||||
"title": "Support report",
|
||||
"description": "A summary of this instance for a bug report, without emails, IDs or values.",
|
||||
"body": "Accounts appear as “Account 1”, “Account 2” and so on. Read the report before you share it.",
|
||||
"copy": "Copy",
|
||||
"copied": "Copied",
|
||||
"download": "Download",
|
||||
"loadError": "The support report could not be created."
|
||||
}
|
||||
},
|
||||
"achievements": {
|
||||
"title": "Achievements",
|
||||
|
||||
+24
-4
@@ -51,7 +51,10 @@
|
||||
"durationHoursMinutes": "{hours} h {minutes} min",
|
||||
"durationMinutes": "{minutes} min",
|
||||
"durationClockMinutes": "{time} min",
|
||||
"durationClockHours": "{time} h"
|
||||
"durationClockHours": "{time} h",
|
||||
"errorIdLabel": "ID de error",
|
||||
"errorIdCopy": "Copiar ID de error",
|
||||
"errorIdCopied": "ID de error copiado"
|
||||
},
|
||||
"apiErrors": {
|
||||
"measurement": {
|
||||
@@ -6646,7 +6649,14 @@
|
||||
"docs": "Documentación",
|
||||
"credits": "Créditos y atribuciones de datos",
|
||||
"linksHeading": "Fuentes y documentación",
|
||||
"newerAvailable": "Nueva versión: {tag}"
|
||||
"newerAvailable": "Nueva versión: {tag}",
|
||||
"support": {
|
||||
"title": "Soporte",
|
||||
"description": "Un resumen para un informe de error, sin tu nombre, correo ni valores de salud.",
|
||||
"copy": "Copiar información de soporte",
|
||||
"copied": "Copiado",
|
||||
"copyFailed": "No se pudo reunir la información de soporte."
|
||||
}
|
||||
},
|
||||
"testConnection": {
|
||||
"test": "Probar conexión",
|
||||
@@ -7753,7 +7763,8 @@
|
||||
"eventDetails": "Detalles del evento",
|
||||
"closeDetails": "Cerrar detalles",
|
||||
"processDetail": "El búfer es volátil y está limitado a 500 entradas.",
|
||||
"cardTitle": "Eventos recientes"
|
||||
"cardTitle": "Eventos recientes",
|
||||
"filterErrorId": "ID de error"
|
||||
},
|
||||
"backups": {
|
||||
"title": "Copias de seguridad",
|
||||
@@ -8120,7 +8131,16 @@
|
||||
"codes": "Para el administrador: las peticiones a la API responden 503 con el código de error {code}, y /api/health indica el motivo {reason}."
|
||||
},
|
||||
"loginOverviewCardTitle": "Eventos de inicio de sesión",
|
||||
"userColumn": "Usuario"
|
||||
"userColumn": "Usuario",
|
||||
"supportReport": {
|
||||
"title": "Informe de soporte",
|
||||
"description": "Un resumen de esta instancia para un informe de error, sin correos, ID ni valores.",
|
||||
"body": "Las cuentas aparecen como «Account 1», «Account 2», etc. Lee el informe antes de compartirlo.",
|
||||
"copy": "Copiar",
|
||||
"copied": "Copiado",
|
||||
"download": "Descargar",
|
||||
"loadError": "No se pudo crear el informe de soporte."
|
||||
}
|
||||
},
|
||||
"achievements": {
|
||||
"title": "Logros",
|
||||
|
||||
+24
-4
@@ -51,7 +51,10 @@
|
||||
"durationHoursMinutes": "{hours} h {minutes} min",
|
||||
"durationMinutes": "{minutes} min",
|
||||
"durationClockMinutes": "{time} min",
|
||||
"durationClockHours": "{time} h"
|
||||
"durationClockHours": "{time} h",
|
||||
"errorIdLabel": "ID d'erreur",
|
||||
"errorIdCopy": "Copier l'ID d'erreur",
|
||||
"errorIdCopied": "ID d'erreur copié"
|
||||
},
|
||||
"apiErrors": {
|
||||
"measurement": {
|
||||
@@ -6646,7 +6649,14 @@
|
||||
"docs": "Documentation",
|
||||
"credits": "Crédits et attributions de données",
|
||||
"linksHeading": "Sources et documentation",
|
||||
"newerAvailable": "Nouvelle version : {tag}"
|
||||
"newerAvailable": "Nouvelle version : {tag}",
|
||||
"support": {
|
||||
"title": "Assistance",
|
||||
"description": "Un résumé pour un signalement de bug, sans votre nom, votre e-mail ni vos valeurs de santé.",
|
||||
"copy": "Copier les infos d'assistance",
|
||||
"copied": "Copié",
|
||||
"copyFailed": "Les infos d'assistance n'ont pas pu être rassemblées."
|
||||
}
|
||||
},
|
||||
"testConnection": {
|
||||
"test": "Tester la connexion",
|
||||
@@ -7753,7 +7763,8 @@
|
||||
"eventDetails": "Détails de l'événement",
|
||||
"closeDetails": "Fermer les détails",
|
||||
"processDetail": "Le tampon est volatil et limité à 500 entrées.",
|
||||
"cardTitle": "Événements récents"
|
||||
"cardTitle": "Événements récents",
|
||||
"filterErrorId": "ID d'erreur"
|
||||
},
|
||||
"backups": {
|
||||
"title": "Sauvegardes",
|
||||
@@ -8120,7 +8131,16 @@
|
||||
"codes": "Pour l'administrateur : les requêtes API répondent 503 avec le code d'erreur {code}, et /api/health indique la raison {reason}."
|
||||
},
|
||||
"loginOverviewCardTitle": "Événements de connexion",
|
||||
"userColumn": "Utilisateur"
|
||||
"userColumn": "Utilisateur",
|
||||
"supportReport": {
|
||||
"title": "Rapport d'assistance",
|
||||
"description": "Un résumé de cette instance pour un signalement de bug, sans e-mails, identifiants ni valeurs.",
|
||||
"body": "Les comptes apparaissent comme « Account 1 », « Account 2 », etc. Lisez le rapport avant de le partager.",
|
||||
"copy": "Copier",
|
||||
"copied": "Copié",
|
||||
"download": "Télécharger",
|
||||
"loadError": "Le rapport d'assistance n'a pas pu être créé."
|
||||
}
|
||||
},
|
||||
"achievements": {
|
||||
"title": "Réussites",
|
||||
|
||||
+24
-4
@@ -51,7 +51,10 @@
|
||||
"durationHoursMinutes": "{hours} h {minutes} min",
|
||||
"durationMinutes": "{minutes} min",
|
||||
"durationClockMinutes": "{time} min",
|
||||
"durationClockHours": "{time} h"
|
||||
"durationClockHours": "{time} h",
|
||||
"errorIdLabel": "ID errore",
|
||||
"errorIdCopy": "Copia ID errore",
|
||||
"errorIdCopied": "ID errore copiato"
|
||||
},
|
||||
"apiErrors": {
|
||||
"measurement": {
|
||||
@@ -6646,7 +6649,14 @@
|
||||
"docs": "Documentazione",
|
||||
"credits": "Crediti e attribuzioni dei dati",
|
||||
"linksHeading": "Fonti e documentazione",
|
||||
"newerAvailable": "Nuova versione: {tag}"
|
||||
"newerAvailable": "Nuova versione: {tag}",
|
||||
"support": {
|
||||
"title": "Assistenza",
|
||||
"description": "Un riepilogo per una segnalazione di errore, senza nome, email o valori di salute.",
|
||||
"copy": "Copia le info di assistenza",
|
||||
"copied": "Copiato",
|
||||
"copyFailed": "Non è stato possibile raccogliere le info di assistenza."
|
||||
}
|
||||
},
|
||||
"testConnection": {
|
||||
"test": "Prova connessione",
|
||||
@@ -7753,7 +7763,8 @@
|
||||
"eventDetails": "Dettagli dell'evento",
|
||||
"closeDetails": "Chiudi dettagli",
|
||||
"processDetail": "Il buffer è volatile e limitato a 500 voci.",
|
||||
"cardTitle": "Eventi recenti"
|
||||
"cardTitle": "Eventi recenti",
|
||||
"filterErrorId": "ID errore"
|
||||
},
|
||||
"backups": {
|
||||
"title": "Backup",
|
||||
@@ -8120,7 +8131,16 @@
|
||||
"codes": "Per chi gestisce il server: le richieste API rispondono 503 con il codice di errore {code}, e /api/health indica il motivo {reason}."
|
||||
},
|
||||
"loginOverviewCardTitle": "Eventi di accesso",
|
||||
"userColumn": "Utente"
|
||||
"userColumn": "Utente",
|
||||
"supportReport": {
|
||||
"title": "Rapporto di assistenza",
|
||||
"description": "Un riepilogo di questa istanza per una segnalazione di errore, senza email, ID o valori.",
|
||||
"body": "Gli account compaiono come “Account 1”, “Account 2” e così via. Leggi il rapporto prima di condividerlo.",
|
||||
"copy": "Copia",
|
||||
"copied": "Copiato",
|
||||
"download": "Scarica",
|
||||
"loadError": "Non è stato possibile creare il rapporto di assistenza."
|
||||
}
|
||||
},
|
||||
"achievements": {
|
||||
"title": "Obiettivi",
|
||||
|
||||
+24
-4
@@ -51,7 +51,10 @@
|
||||
"durationHoursMinutes": "{hours}시간 {minutes}분",
|
||||
"durationMinutes": "{minutes}분",
|
||||
"durationClockMinutes": "{time}분",
|
||||
"durationClockHours": "{time}시간"
|
||||
"durationClockHours": "{time}시간",
|
||||
"errorIdLabel": "오류 ID",
|
||||
"errorIdCopy": "오류 ID 복사",
|
||||
"errorIdCopied": "오류 ID 복사됨"
|
||||
},
|
||||
"apiErrors": {
|
||||
"measurement": {
|
||||
@@ -6646,7 +6649,14 @@
|
||||
"docs": "문서",
|
||||
"credits": "크레딧 및 데이터 출처",
|
||||
"linksHeading": "출처 및 문서",
|
||||
"newerAvailable": "새 버전: {tag}"
|
||||
"newerAvailable": "새 버전: {tag}",
|
||||
"support": {
|
||||
"title": "지원",
|
||||
"description": "버그 신고용 요약이며, 이름, 이메일, 건강 수치는 포함되지 않습니다.",
|
||||
"copy": "지원 정보 복사",
|
||||
"copied": "복사됨",
|
||||
"copyFailed": "지원 정보를 만들 수 없습니다."
|
||||
}
|
||||
},
|
||||
"testConnection": {
|
||||
"test": "연결 테스트",
|
||||
@@ -7729,7 +7739,8 @@
|
||||
"eventDetails": "이벤트 상세",
|
||||
"closeDetails": "상세 닫기",
|
||||
"processDetail": "버퍼는 휘발성이고 최대 500개까지만 담아요.",
|
||||
"cardTitle": "최근 이벤트"
|
||||
"cardTitle": "최근 이벤트",
|
||||
"filterErrorId": "오류 ID"
|
||||
},
|
||||
"backups": {
|
||||
"title": "백업",
|
||||
@@ -8120,7 +8131,16 @@
|
||||
"codes": "운영자용: API 요청은 오류 코드 {code}와 함께 503으로 응답하고, /api/health는 원인 {reason}을 알려 줍니다."
|
||||
},
|
||||
"loginOverviewCardTitle": "로그인 이벤트",
|
||||
"userColumn": "사용자"
|
||||
"userColumn": "사용자",
|
||||
"supportReport": {
|
||||
"title": "지원 보고서",
|
||||
"description": "버그 신고용 이 인스턴스의 요약이며, 이메일, ID, 수치는 포함되지 않습니다.",
|
||||
"body": "계정은 “Account 1”, “Account 2”처럼 표시됩니다. 공유하기 전에 보고서를 확인하세요.",
|
||||
"copy": "복사",
|
||||
"copied": "복사됨",
|
||||
"download": "다운로드",
|
||||
"loadError": "지원 보고서를 만들 수 없습니다."
|
||||
}
|
||||
},
|
||||
"achievements": {
|
||||
"title": "업적",
|
||||
|
||||
+24
-4
@@ -51,7 +51,10 @@
|
||||
"durationHoursMinutes": "{hours} godz. {minutes} min",
|
||||
"durationMinutes": "{minutes} min",
|
||||
"durationClockMinutes": "{time} min",
|
||||
"durationClockHours": "{time} godz."
|
||||
"durationClockHours": "{time} godz.",
|
||||
"errorIdLabel": "ID błędu",
|
||||
"errorIdCopy": "Kopiuj ID błędu",
|
||||
"errorIdCopied": "Skopiowano ID błędu"
|
||||
},
|
||||
"apiErrors": {
|
||||
"measurement": {
|
||||
@@ -6646,7 +6649,14 @@
|
||||
"docs": "Dokumentacja",
|
||||
"credits": "Autorzy i źródła danych",
|
||||
"linksHeading": "Źródła i dokumentacja",
|
||||
"newerAvailable": "Nowa wersja: {tag}"
|
||||
"newerAvailable": "Nowa wersja: {tag}",
|
||||
"support": {
|
||||
"title": "Pomoc",
|
||||
"description": "Podsumowanie do zgłoszenia błędu, bez imienia, adresu e-mail i wartości zdrowotnych.",
|
||||
"copy": "Kopiuj informacje dla pomocy",
|
||||
"copied": "Skopiowano",
|
||||
"copyFailed": "Nie udało się zebrać informacji dla pomocy."
|
||||
}
|
||||
},
|
||||
"testConnection": {
|
||||
"test": "Testuj połączenie",
|
||||
@@ -7753,7 +7763,8 @@
|
||||
"eventDetails": "Szczegóły zdarzenia",
|
||||
"closeDetails": "Zamknij szczegóły",
|
||||
"processDetail": "Bufor jest ulotny i ograniczony do 500 wpisów.",
|
||||
"cardTitle": "Ostatnie zdarzenia"
|
||||
"cardTitle": "Ostatnie zdarzenia",
|
||||
"filterErrorId": "ID błędu"
|
||||
},
|
||||
"backups": {
|
||||
"title": "Kopie zapasowe",
|
||||
@@ -8120,7 +8131,16 @@
|
||||
"codes": "Dla administratora: żądania API odpowiadają 503 z kodem błędu {code}, a /api/health podaje powód {reason}."
|
||||
},
|
||||
"loginOverviewCardTitle": "Zdarzenia logowania",
|
||||
"userColumn": "Użytkownik"
|
||||
"userColumn": "Użytkownik",
|
||||
"supportReport": {
|
||||
"title": "Raport dla pomocy",
|
||||
"description": "Podsumowanie tej instancji do zgłoszenia błędu, bez adresów e-mail, identyfikatorów i wartości.",
|
||||
"body": "Konta pojawiają się jako „Account 1”, „Account 2” itd. Przeczytaj raport, zanim go udostępnisz.",
|
||||
"copy": "Kopiuj",
|
||||
"copied": "Skopiowano",
|
||||
"download": "Pobierz",
|
||||
"loadError": "Nie udało się utworzyć raportu dla pomocy."
|
||||
}
|
||||
},
|
||||
"achievements": {
|
||||
"title": "Osiągnięcia",
|
||||
|
||||
@@ -0,0 +1,103 @@
|
||||
/**
|
||||
* Every error display goes through the one helper that shows an error id.
|
||||
*
|
||||
* A self-hoster reporting a failure needs the id the operator finds it by. The
|
||||
* web shows it on a server failure with a copy button, through two doors only:
|
||||
*
|
||||
* - toasts: `showErrorToast` (`src/lib/errors/error-toast.tsx`), which adds
|
||||
* the id when its `cause` is a 5xx and is otherwise exactly `toast.error`;
|
||||
* - error boundaries: `ErrorDetails` / `ErrorIdLine`, or `errorIdOf` for the
|
||||
* root boundary that renders without any provider.
|
||||
*
|
||||
* A raw `toast.error(` anywhere else is a toast that can never carry the id,
|
||||
* and a route boundary that skips the helper is a crash screen without it.
|
||||
*/
|
||||
import { readFileSync } from "node:fs";
|
||||
import { join } from "node:path";
|
||||
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import { walkSourceFiles } from "./helpers/source-files";
|
||||
|
||||
const SRC = join(process.cwd(), "src");
|
||||
const HELPER = "lib/errors/error-toast.tsx";
|
||||
|
||||
/** Whitespace-tolerant: `toast.error(`, `toast .error (`, `toast\n.error(`. */
|
||||
const RAW_TOAST_ERROR = /\btoast\s*\.\s*error\s*\(/g;
|
||||
const HELPER_CALL = /\bshowErrorToast\s*\(/g;
|
||||
|
||||
/**
|
||||
* Route boundaries that deliberately show no error id, with the reason. Each
|
||||
* entry is a decision, not a convenience.
|
||||
*/
|
||||
const BOUNDARY_EXEMPT: Readonly<Record<string, string>> = {
|
||||
// The clinician share view is opened by someone outside the instance, with
|
||||
// no account and no way to reach its operator; it shows a retry and nothing
|
||||
// a probe could learn from, by design (see the file's header).
|
||||
"app/c/error.tsx": "public share view, no operator relationship",
|
||||
};
|
||||
|
||||
const files = walkSourceFiles(SRC, { floor: 2500 }).filter(
|
||||
(rel) =>
|
||||
!rel.includes("__tests__/") &&
|
||||
!/\.test\.tsx?$/.test(rel) &&
|
||||
!rel.startsWith("generated/"),
|
||||
);
|
||||
|
||||
function read(rel: string): string {
|
||||
return readFileSync(join(SRC, rel), "utf8");
|
||||
}
|
||||
|
||||
describe("error display helper guard", () => {
|
||||
it("finds the helper's own toast.error, so the matcher works", () => {
|
||||
const own = read(HELPER).match(RAW_TOAST_ERROR) ?? [];
|
||||
expect(own.length).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it("allows no toast.error outside the helper", () => {
|
||||
const offenders = files
|
||||
.filter((rel) => rel !== HELPER)
|
||||
.filter((rel) => (read(rel).match(RAW_TOAST_ERROR) ?? []).length > 0)
|
||||
.sort();
|
||||
expect(offenders).toEqual([]);
|
||||
});
|
||||
|
||||
it("routes the app's error toasts through showErrorToast", () => {
|
||||
const calls = files.reduce(
|
||||
(n, rel) => n + (read(rel).match(HELPER_CALL) ?? []).length,
|
||||
0,
|
||||
);
|
||||
// 200 call sites when the helper landed; a count far below it means the
|
||||
// matcher or the walk stopped seeing them.
|
||||
expect(calls).toBeGreaterThan(180);
|
||||
});
|
||||
|
||||
it("renders every route error boundary through the error id helper", () => {
|
||||
const boundaries = files.filter((rel) =>
|
||||
/^app\/(.*\/)?(error|global-error)\.tsx$/.test(rel),
|
||||
);
|
||||
expect(boundaries.length).toBeGreaterThanOrEqual(3);
|
||||
const missing = boundaries
|
||||
.filter((rel) => !(rel in BOUNDARY_EXEMPT))
|
||||
.filter((rel) => {
|
||||
const source = read(rel);
|
||||
return !(
|
||||
/<ErrorDetails\b/.test(source) ||
|
||||
/<ErrorIdLine\b/.test(source) ||
|
||||
/\berrorIdOf\s*\(/.test(source)
|
||||
);
|
||||
});
|
||||
expect(missing).toEqual([]);
|
||||
for (const rel of Object.keys(BOUNDARY_EXEMPT)) {
|
||||
expect(boundaries, `${rel} is exempt but no longer exists`).toContain(
|
||||
rel,
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
it("shows the id in the shared boundary panel", () => {
|
||||
const source = read("components/error-details.tsx");
|
||||
expect(source).toMatch(/<ErrorIdLine\b/);
|
||||
expect(source).toMatch(/\berrorIdOf\s*\(/);
|
||||
});
|
||||
});
|
||||
@@ -36,6 +36,7 @@ import { ModuleAvailabilitySection } from "@/components/admin/module-availabilit
|
||||
import { RemindersSection } from "@/components/admin/reminders-section";
|
||||
import { ServicesSection } from "@/components/admin/services-section";
|
||||
import { SystemStatusSection } from "@/components/admin/system-status-section";
|
||||
import { SupportReportCard } from "@/components/admin/support-report-card";
|
||||
import { RevealWhenSettled } from "@/components/ui/reveal-when-settled";
|
||||
import { Skeleton } from "@/components/ui/skeleton";
|
||||
import { MeasurementMaintenanceCard } from "@/components/admin/measurement-maintenance-card";
|
||||
@@ -69,6 +70,8 @@ export function AdminSectionRenderer({
|
||||
>
|
||||
<SectionFrame>
|
||||
<SystemStatusSection />
|
||||
{/* v1.43 — the redacted report for a bug report. */}
|
||||
<SupportReportCard />
|
||||
{/* v1.42 — the table maintenance after the tombstone purge. */}
|
||||
<MeasurementMaintenanceCard />
|
||||
</SectionFrame>
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
|
||||
import { useEffect } from "react";
|
||||
|
||||
import { errorIdOf, recordErrorId, shortErrorId } from "@/lib/errors/error-id";
|
||||
import { reloadOnceForChunkError } from "@/lib/pwa/chunk-reload";
|
||||
|
||||
/**
|
||||
@@ -22,12 +23,29 @@ export default function GlobalError({
|
||||
reloadOnceForChunkError(error);
|
||||
}, [error]);
|
||||
|
||||
// The id the operator finds this failure by. No i18n provider exists at
|
||||
// this level, so the line is the same static bilingual pair as the heading.
|
||||
const errorId = errorIdOf(error, { unexpected: true });
|
||||
useEffect(() => {
|
||||
if (errorId) recordErrorId(errorId);
|
||||
}, [errorId]);
|
||||
|
||||
async function handleCopyId() {
|
||||
if (!errorId) return;
|
||||
try {
|
||||
await navigator.clipboard.writeText(errorId);
|
||||
} catch {
|
||||
window.prompt("Error ID", errorId);
|
||||
}
|
||||
}
|
||||
|
||||
async function handleCopy() {
|
||||
// Strip query string — OAuth callback routes carry sensitive tokens.
|
||||
const loc = typeof window !== "undefined" ? window.location : null;
|
||||
const payload = {
|
||||
message: error.message,
|
||||
digest: error.digest,
|
||||
errorId,
|
||||
name: error.name,
|
||||
stack: error.stack?.split("\n").slice(0, 10).join("\n"),
|
||||
urlPath: loc?.pathname ?? null,
|
||||
@@ -91,6 +109,39 @@ export default function GlobalError({
|
||||
>
|
||||
{error.message || "Unknown error"}
|
||||
</pre>
|
||||
{errorId && (
|
||||
<p
|
||||
data-slot="error-id"
|
||||
style={{
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
gap: 8,
|
||||
fontSize: 13,
|
||||
color: "#d0d0d0",
|
||||
marginBottom: 16,
|
||||
}}
|
||||
>
|
||||
<span>Fehler-ID / Error ID</span>
|
||||
<code style={{ color: "#f8f8f2" }}>{shortErrorId(errorId)}</code>
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleCopyId}
|
||||
aria-label="Copy error ID"
|
||||
style={{
|
||||
background: "transparent",
|
||||
color: "#f8f8f2",
|
||||
border: "1px solid #44475a",
|
||||
borderRadius: 6,
|
||||
minHeight: 44,
|
||||
padding: "6px 12px",
|
||||
cursor: "pointer",
|
||||
fontSize: 13,
|
||||
}}
|
||||
>
|
||||
Copy
|
||||
</button>
|
||||
</p>
|
||||
)}
|
||||
<div style={{ display: "flex", gap: 8, flexWrap: "wrap" }}>
|
||||
<button
|
||||
onClick={reset}
|
||||
|
||||
@@ -4,6 +4,7 @@ import { useMemo } from "react";
|
||||
import { StatTile } from "@/components/admin/_stat-tile";
|
||||
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
|
||||
import { toast } from "sonner";
|
||||
import { showErrorToast } from "@/lib/errors/error-toast";
|
||||
import { Cpu, Fingerprint, Globe, KeyRound } from "lucide-react";
|
||||
import { Switch } from "@/components/ui/switch";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
@@ -297,10 +298,11 @@ export function useUpdateSettings() {
|
||||
toast.success(t("common.saved"));
|
||||
},
|
||||
onError: (err) => {
|
||||
toast.error(
|
||||
showErrorToast(
|
||||
err instanceof Error && err.message
|
||||
? err.message
|
||||
: t("admin.settingsSaveError"),
|
||||
{ cause: err },
|
||||
);
|
||||
},
|
||||
});
|
||||
|
||||
@@ -4,6 +4,7 @@ import { useState } from "react";
|
||||
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import { AlertTriangle, KeyRound, Loader2 } from "lucide-react";
|
||||
import { toast } from "sonner";
|
||||
import { showErrorToast } from "@/lib/errors/error-toast";
|
||||
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { SettingsCardActions } from "@/components/settings/_card-actions";
|
||||
@@ -89,8 +90,9 @@ export function AiServerKeySection() {
|
||||
toast.success(t("admin.aiServerKey.saved"));
|
||||
},
|
||||
onError: (error: unknown) => {
|
||||
toast.error(
|
||||
showErrorToast(
|
||||
error instanceof Error ? error.message : t("admin.aiServerKey.error"),
|
||||
{ cause: error },
|
||||
);
|
||||
},
|
||||
});
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
|
||||
import { Sparkles } from "lucide-react";
|
||||
import { toast } from "sonner";
|
||||
import { showErrorToast } from "@/lib/errors/error-toast";
|
||||
|
||||
import { SettingsCard } from "@/components/settings/settings-card";
|
||||
import { SettingsCardHeader } from "@/components/settings/_card-header";
|
||||
@@ -104,10 +105,11 @@ function useUpdateAssistantFlags() {
|
||||
toast.success(t("common.saved"));
|
||||
},
|
||||
onError: (err) => {
|
||||
toast.error(
|
||||
showErrorToast(
|
||||
err instanceof Error && err.message
|
||||
? err.message
|
||||
: t("admin.settingsSaveError"),
|
||||
{ cause: err },
|
||||
);
|
||||
client.invalidateQueries({
|
||||
queryKey: queryKeys.adminAssistantFlags(),
|
||||
|
||||
@@ -24,6 +24,7 @@ import {
|
||||
} from "lucide-react";
|
||||
import { useRef, useState, type ChangeEvent } from "react";
|
||||
import { toast } from "sonner";
|
||||
import { showErrorToast } from "@/lib/errors/error-toast";
|
||||
import {
|
||||
AlertDialog,
|
||||
AlertDialogAction,
|
||||
@@ -864,10 +865,11 @@ export function BackupsSection() {
|
||||
}, 2000);
|
||||
},
|
||||
onError: (err) => {
|
||||
toast.error(
|
||||
showErrorToast(
|
||||
err instanceof Error && err.message
|
||||
? err.message
|
||||
: t("admin.section.backups.runFailed"),
|
||||
{ cause: err },
|
||||
);
|
||||
},
|
||||
});
|
||||
@@ -923,10 +925,11 @@ export function BackupsSection() {
|
||||
},
|
||||
onError: (err) => {
|
||||
if (err instanceof ReproofCancelledError) return;
|
||||
toast.error(
|
||||
showErrorToast(
|
||||
err instanceof Error && err.message
|
||||
? err.message
|
||||
: t("admin.section.backups.uploadFailed"),
|
||||
{ cause: err },
|
||||
);
|
||||
},
|
||||
onSettled: () => {
|
||||
@@ -983,7 +986,7 @@ export function BackupsSection() {
|
||||
void queryClient.invalidateQueries();
|
||||
} else if (job.status === "failed") {
|
||||
setRefusedSections(missingSectionsOf(job.failure));
|
||||
toast.error(t("admin.section.backups.restoreFailed"));
|
||||
showErrorToast(t("admin.section.backups.restoreFailed"));
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1061,17 +1064,20 @@ export function BackupsSection() {
|
||||
err instanceof ApiError &&
|
||||
err.meta?.errorCode === "backup.restore.active"
|
||||
) {
|
||||
toast.error(t("admin.section.backups.restoreActive"));
|
||||
showErrorToast(t("admin.section.backups.restoreActive"), {
|
||||
cause: err,
|
||||
});
|
||||
void queryClient.invalidateQueries({
|
||||
queryKey: queryKeys.adminBackupRestores(),
|
||||
});
|
||||
return;
|
||||
}
|
||||
setRefusedSections([]);
|
||||
toast.error(
|
||||
showErrorToast(
|
||||
err instanceof Error && err.message
|
||||
? err.message
|
||||
: t("admin.section.backups.restoreFailed"),
|
||||
{ cause: err },
|
||||
);
|
||||
},
|
||||
});
|
||||
@@ -1109,10 +1115,11 @@ export function BackupsSection() {
|
||||
toast.success(t("admin.section.backups.downloadStarted"));
|
||||
} catch (err) {
|
||||
if (err instanceof ReproofCancelledError) return;
|
||||
toast.error(
|
||||
showErrorToast(
|
||||
err instanceof Error && err.message
|
||||
? err.message
|
||||
: t("admin.section.backups.downloadFailed"),
|
||||
{ cause: err },
|
||||
);
|
||||
} finally {
|
||||
setDownloadingId(null);
|
||||
|
||||
@@ -19,6 +19,7 @@ import { useEffect, useState } from "react";
|
||||
import { useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import { Loader2, ShieldAlert, Sparkles, Trash2 } from "lucide-react";
|
||||
import { toast } from "sonner";
|
||||
import { showErrorToast } from "@/lib/errors/error-toast";
|
||||
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { SettingsInfoTile } from "@/components/settings/_info-tile";
|
||||
@@ -75,8 +76,9 @@ export function CentralCodexSection() {
|
||||
intervalSeconds: json.data.intervalSeconds,
|
||||
});
|
||||
} catch (err) {
|
||||
toast.error(
|
||||
showErrorToast(
|
||||
err instanceof Error ? err.message : t("admin.centralCodex.error"),
|
||||
{ cause: err },
|
||||
);
|
||||
setDevicePolling(false);
|
||||
}
|
||||
@@ -110,8 +112,9 @@ export function CentralCodexSection() {
|
||||
if (!cancelled) setTimeout(tick, intervalMs);
|
||||
} catch (err) {
|
||||
if (cancelled) return;
|
||||
toast.error(
|
||||
showErrorToast(
|
||||
err instanceof Error ? err.message : t("admin.centralCodex.error"),
|
||||
{ cause: err },
|
||||
);
|
||||
setDeviceCode(null);
|
||||
setDevicePolling(false);
|
||||
@@ -142,8 +145,9 @@ export function CentralCodexSection() {
|
||||
});
|
||||
void invalidateKeys(queryClient, aiInputDependentKeys);
|
||||
} catch (err) {
|
||||
toast.error(
|
||||
showErrorToast(
|
||||
err instanceof Error ? err.message : t("admin.centralCodex.error"),
|
||||
{ cause: err },
|
||||
);
|
||||
} finally {
|
||||
setDisconnecting(false);
|
||||
|
||||
@@ -23,6 +23,7 @@ import {
|
||||
ShieldCheck,
|
||||
} from "lucide-react";
|
||||
import { toast } from "sonner";
|
||||
import { showErrorToast } from "@/lib/errors/error-toast";
|
||||
import {
|
||||
AlertDialog,
|
||||
AlertDialogAction,
|
||||
@@ -125,7 +126,10 @@ export function EncryptionSection() {
|
||||
});
|
||||
},
|
||||
onError: (err: Error) => {
|
||||
toast.error(err.message || t("admin.section.encryption.rotateFailed"));
|
||||
showErrorToast(
|
||||
err.message || t("admin.section.encryption.rotateFailed"),
|
||||
{ cause: err },
|
||||
);
|
||||
},
|
||||
});
|
||||
|
||||
|
||||
@@ -4,6 +4,7 @@ import { useState } from "react";
|
||||
import { useMutation, useQueryClient } from "@tanstack/react-query";
|
||||
import { AlertTriangle, Loader2 } from "lucide-react";
|
||||
import { toast } from "sonner";
|
||||
import { showErrorToast } from "@/lib/errors/error-toast";
|
||||
import { SettingsCard } from "@/components/settings/settings-card";
|
||||
import { SettingsCardActions } from "@/components/settings/_card-actions";
|
||||
import { SettingsCardHeader } from "@/components/settings/_card-header";
|
||||
@@ -111,10 +112,11 @@ export function GlitchtipSection() {
|
||||
});
|
||||
},
|
||||
onError: (error) => {
|
||||
toast.error(
|
||||
showErrorToast(
|
||||
error instanceof Error
|
||||
? error.message
|
||||
: t("admin.monitoringTestFailed"),
|
||||
{ cause: error },
|
||||
);
|
||||
void queryClient.invalidateQueries({
|
||||
queryKey: queryKeys.adminSettings(),
|
||||
|
||||
@@ -30,6 +30,7 @@ import { useState } from "react";
|
||||
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import { Ban, Check, Copy, Loader2, Plus, Ticket, Users } from "lucide-react";
|
||||
import { toast } from "sonner";
|
||||
import { showErrorToast } from "@/lib/errors/error-toast";
|
||||
|
||||
import {
|
||||
AlertDialog,
|
||||
@@ -221,8 +222,8 @@ export function InviteTokensSection() {
|
||||
toast.success(t("admin.invites.createdToast"));
|
||||
queryClient.invalidateQueries({ queryKey: queryKeys.adminInvites() });
|
||||
},
|
||||
onError: () => {
|
||||
toast.error(t("admin.invites.createError"));
|
||||
onError: (failure) => {
|
||||
showErrorToast(t("admin.invites.createError"), { cause: failure });
|
||||
},
|
||||
});
|
||||
|
||||
@@ -234,8 +235,8 @@ export function InviteTokensSection() {
|
||||
toast.success(t("admin.invites.revokedToast"));
|
||||
queryClient.invalidateQueries({ queryKey: queryKeys.adminInvites() });
|
||||
},
|
||||
onError: () => {
|
||||
toast.error(t("admin.invites.revokeError"));
|
||||
onError: (failure) => {
|
||||
showErrorToast(t("admin.invites.revokeError"), { cause: failure });
|
||||
},
|
||||
});
|
||||
|
||||
@@ -245,8 +246,8 @@ export function InviteTokensSection() {
|
||||
setCopied(true);
|
||||
toast.success(t("admin.invites.copiedToast"));
|
||||
window.setTimeout(() => setCopied(false), 2_000);
|
||||
} catch {
|
||||
toast.error(t("admin.invites.copyError"));
|
||||
} catch (failure) {
|
||||
showErrorToast(t("admin.invites.copyError"), { cause: failure });
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -15,6 +15,7 @@ import { useState } from "react";
|
||||
import { useMutation, useQueryClient } from "@tanstack/react-query";
|
||||
import { KeyRound, Loader2 } from "lucide-react";
|
||||
import { toast } from "sonner";
|
||||
import { showErrorToast } from "@/lib/errors/error-toast";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
@@ -106,11 +107,13 @@ export function KeyBackupCard() {
|
||||
},
|
||||
onError: async (err: Error) => {
|
||||
if (err instanceof ApiError && err.status === 409) {
|
||||
toast.error(t("admin.keyBackup.stale"));
|
||||
showErrorToast(t("admin.keyBackup.stale"), { cause: err });
|
||||
await invalidate();
|
||||
return;
|
||||
}
|
||||
toast.error(err.message || t("admin.keyBackup.confirmFailed"));
|
||||
showErrorToast(err.message || t("admin.keyBackup.confirmFailed"), {
|
||||
cause: err,
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
@@ -133,7 +136,9 @@ export function KeyBackupCard() {
|
||||
},
|
||||
onError: (err: Error) => {
|
||||
setCheckResult(null);
|
||||
toast.error(err.message || t("admin.keyBackup.checkFailed"));
|
||||
showErrorToast(err.message || t("admin.keyBackup.checkFailed"), {
|
||||
cause: err,
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
import { useMemo, useState } from "react";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { toast } from "sonner";
|
||||
import { showErrorToast } from "@/lib/errors/error-toast";
|
||||
import {
|
||||
Check,
|
||||
CheckCircle2,
|
||||
@@ -112,8 +113,8 @@ export function LoginOverviewSection() {
|
||||
window.setTimeout(() => {
|
||||
setCopiedIp((current) => (current === id ? null : current));
|
||||
}, 2_000);
|
||||
} catch {
|
||||
toast.error(t("admin.copyFailed"));
|
||||
} catch (failure) {
|
||||
showErrorToast(t("admin.copyFailed"), { cause: failure });
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -14,6 +14,7 @@
|
||||
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import { Database, Loader2, Wrench } from "lucide-react";
|
||||
import { toast } from "sonner";
|
||||
import { showErrorToast } from "@/lib/errors/error-toast";
|
||||
|
||||
import { StatTile } from "@/components/admin/_stat-tile";
|
||||
import { SettingsCardActions } from "@/components/settings/_card-actions";
|
||||
@@ -92,7 +93,10 @@ export function MeasurementMaintenanceCard() {
|
||||
});
|
||||
},
|
||||
onError: (err: Error) => {
|
||||
toast.error(err.message || t("admin.section.maintenance.startFailed"));
|
||||
showErrorToast(
|
||||
err.message || t("admin.section.maintenance.startFailed"),
|
||||
{ cause: err },
|
||||
);
|
||||
},
|
||||
});
|
||||
|
||||
|
||||
@@ -4,6 +4,7 @@ import Link from "next/link";
|
||||
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
|
||||
import { ToggleLeft } from "lucide-react";
|
||||
import { toast } from "sonner";
|
||||
import { showErrorToast } from "@/lib/errors/error-toast";
|
||||
|
||||
import { SettingsCard } from "@/components/settings/settings-card";
|
||||
import { SettingsCardHeader } from "@/components/settings/_card-header";
|
||||
@@ -74,10 +75,11 @@ function useUpdateModuleAvailability() {
|
||||
toast.success(t("common.saved"));
|
||||
},
|
||||
onError: (err) => {
|
||||
toast.error(
|
||||
showErrorToast(
|
||||
err instanceof Error && err.message
|
||||
? err.message
|
||||
: t("admin.modules.saveError"),
|
||||
{ cause: err },
|
||||
);
|
||||
client.invalidateQueries({
|
||||
queryKey: queryKeys.adminModuleAvailability(),
|
||||
|
||||
@@ -11,6 +11,7 @@ import {
|
||||
XCircle,
|
||||
} from "lucide-react";
|
||||
import { toast } from "sonner";
|
||||
import { showErrorToast } from "@/lib/errors/error-toast";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { SettingsCardActions } from "@/components/settings/_card-actions";
|
||||
import { Button } from "@/components/ui/button";
|
||||
@@ -144,7 +145,7 @@ export function RemindersSection() {
|
||||
onSuccess: (data) => {
|
||||
const failures = describeChannelTestFailures(data?.results ?? [], t);
|
||||
if (failures.length > 0) {
|
||||
toast.error(data?.message ?? t("admin.notificationTestFailed"), {
|
||||
showErrorToast(data?.message ?? t("admin.notificationTestFailed"), {
|
||||
description: (
|
||||
<ul className="space-y-0.5">
|
||||
{failures.map((line) => (
|
||||
@@ -158,10 +159,11 @@ export function RemindersSection() {
|
||||
}
|
||||
},
|
||||
onError: (error) => {
|
||||
toast.error(
|
||||
showErrorToast(
|
||||
error instanceof Error
|
||||
? error.message
|
||||
: t("admin.notificationTestFailed"),
|
||||
{ cause: error },
|
||||
);
|
||||
},
|
||||
});
|
||||
@@ -196,8 +198,9 @@ export function RemindersSection() {
|
||||
toast.success(data?.message ?? t("admin.reminderCheckSuccess"));
|
||||
},
|
||||
onError: (error) => {
|
||||
toast.error(
|
||||
showErrorToast(
|
||||
error instanceof Error ? error.message : t("admin.reminderCheckFailed"),
|
||||
{ cause: error },
|
||||
);
|
||||
},
|
||||
});
|
||||
|
||||
@@ -0,0 +1,128 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { Copy, Download, LifeBuoy, Loader2 } from "lucide-react";
|
||||
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { SettingsCard } from "@/components/settings/settings-card";
|
||||
import { SettingsCardHeader } from "@/components/settings/_card-header";
|
||||
import { SettingsCardActions } from "@/components/settings/_card-actions";
|
||||
import { apiGet } from "@/lib/api/api-fetch";
|
||||
import { showErrorToast } from "@/lib/errors/error-toast";
|
||||
import { useTranslations } from "@/lib/i18n/context";
|
||||
import { queryKeys } from "@/lib/query-keys";
|
||||
|
||||
interface SupportReport {
|
||||
generatedAt: string;
|
||||
text: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Admin, System status: the redacted support report, with copy and download.
|
||||
*
|
||||
* The report is made on request, not on page load: it reads every account's
|
||||
* connection health, which is work an operator only wants when filing a bug.
|
||||
* Once made it is shown in full above the actions, so the operator reads what
|
||||
* they are about to share.
|
||||
*/
|
||||
export function SupportReportCard() {
|
||||
const { t } = useTranslations();
|
||||
const [copied, setCopied] = useState(false);
|
||||
const report = useQuery({
|
||||
queryKey: queryKeys.adminSupportReport(),
|
||||
queryFn: async () => apiGet<SupportReport>("/api/admin/support-report"),
|
||||
enabled: false,
|
||||
staleTime: 0,
|
||||
gcTime: 5 * 60_000,
|
||||
});
|
||||
|
||||
async function load(): Promise<SupportReport | null> {
|
||||
const result = await report.refetch();
|
||||
if (result.error || !result.data) {
|
||||
showErrorToast(t("admin.supportReport.loadError"), {
|
||||
cause: result.error,
|
||||
});
|
||||
return null;
|
||||
}
|
||||
return result.data;
|
||||
}
|
||||
|
||||
async function copy() {
|
||||
const data = await load();
|
||||
if (!data) return;
|
||||
try {
|
||||
await navigator.clipboard.writeText(data.text);
|
||||
setCopied(true);
|
||||
setTimeout(() => setCopied(false), 2000);
|
||||
} catch {
|
||||
window.prompt(t("admin.supportReport.copy"), data.text);
|
||||
}
|
||||
}
|
||||
|
||||
async function download() {
|
||||
const data = await load();
|
||||
if (!data) return;
|
||||
const blob = new Blob([data.text], { type: "text/plain;charset=utf-8" });
|
||||
const url = URL.createObjectURL(blob);
|
||||
const link = document.createElement("a");
|
||||
link.href = url;
|
||||
link.download = `healthlog-support-report-${data.generatedAt.slice(0, 10)}.txt`;
|
||||
document.body.appendChild(link);
|
||||
link.click();
|
||||
link.remove();
|
||||
setTimeout(() => URL.revokeObjectURL(url), 1000);
|
||||
}
|
||||
|
||||
const busy = report.isFetching;
|
||||
|
||||
return (
|
||||
<SettingsCard>
|
||||
<SettingsCardHeader
|
||||
icon={LifeBuoy}
|
||||
title={t("admin.supportReport.title")}
|
||||
description={t("admin.supportReport.description")}
|
||||
/>
|
||||
<p className="text-sm">{t("admin.supportReport.body")}</p>
|
||||
{report.data && (
|
||||
<pre
|
||||
data-slot="support-report-text"
|
||||
className="bg-muted/50 max-h-80 overflow-auto rounded-lg border p-3 font-mono text-xs whitespace-pre-wrap"
|
||||
>
|
||||
{report.data.text}
|
||||
</pre>
|
||||
)}
|
||||
<SettingsCardActions>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="min-h-11 sm:min-h-9"
|
||||
onClick={() => void copy()}
|
||||
disabled={busy}
|
||||
aria-live="polite"
|
||||
>
|
||||
{busy ? (
|
||||
<Loader2
|
||||
className="size-4 animate-spin motion-reduce:animate-none"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
) : (
|
||||
<Copy className="size-4" aria-hidden="true" />
|
||||
)}
|
||||
{copied
|
||||
? t("admin.supportReport.copied")
|
||||
: t("admin.supportReport.copy")}
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
className="min-h-11 sm:min-h-9"
|
||||
onClick={() => void download()}
|
||||
disabled={busy}
|
||||
>
|
||||
<Download className="size-4" aria-hidden="true" />
|
||||
{t("admin.supportReport.download")}
|
||||
</Button>
|
||||
</SettingsCardActions>
|
||||
</SettingsCard>
|
||||
);
|
||||
}
|
||||
@@ -4,6 +4,7 @@ import { useState } from "react";
|
||||
import { useMutation } from "@tanstack/react-query";
|
||||
import { Activity, Loader2 } from "lucide-react";
|
||||
import { toast } from "sonner";
|
||||
import { showErrorToast } from "@/lib/errors/error-toast";
|
||||
import { SettingsCard } from "@/components/settings/settings-card";
|
||||
import { SettingsCardActions } from "@/components/settings/_card-actions";
|
||||
import { SettingsCardHeader } from "@/components/settings/_card-header";
|
||||
@@ -50,10 +51,11 @@ export function UmamiSection() {
|
||||
toast.success(message);
|
||||
},
|
||||
onError: (error) => {
|
||||
toast.error(
|
||||
showErrorToast(
|
||||
error instanceof Error
|
||||
? error.message
|
||||
: t("admin.monitoringTestFailed"),
|
||||
{ cause: error },
|
||||
);
|
||||
},
|
||||
});
|
||||
|
||||
@@ -12,6 +12,7 @@ import {
|
||||
Users,
|
||||
} from "lucide-react";
|
||||
import { toast } from "sonner";
|
||||
import { showErrorToast } from "@/lib/errors/error-toast";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { SettingsCardActions } from "@/components/settings/_card-actions";
|
||||
import { Button } from "@/components/ui/button";
|
||||
@@ -99,10 +100,11 @@ export function UserManagementSection() {
|
||||
toast.success(t("common.saved"));
|
||||
},
|
||||
onError: (err) => {
|
||||
toast.error(
|
||||
showErrorToast(
|
||||
err instanceof Error && err.message
|
||||
? err.message
|
||||
: t("admin.settingsSaveError"),
|
||||
{ cause: err },
|
||||
);
|
||||
},
|
||||
});
|
||||
@@ -144,10 +146,11 @@ export function UserManagementSection() {
|
||||
setLogoutTarget(null);
|
||||
},
|
||||
onError: (err) => {
|
||||
toast.error(
|
||||
showErrorToast(
|
||||
err instanceof Error && err.message
|
||||
? err.message
|
||||
: t("admin.section.users.forceLogoutFailed"),
|
||||
{ cause: err },
|
||||
);
|
||||
},
|
||||
});
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
import { useState } from "react";
|
||||
import { useQueryClient } from "@tanstack/react-query";
|
||||
import { toast } from "sonner";
|
||||
import { showErrorToast } from "@/lib/errors/error-toast";
|
||||
import { BellRing, KeyRound, Loader2 } from "lucide-react";
|
||||
import { SettingsCard } from "@/components/settings/settings-card";
|
||||
import { SettingsCardActions } from "@/components/settings/_card-actions";
|
||||
@@ -96,7 +97,7 @@ export function WebPushVapidSection() {
|
||||
}
|
||||
|
||||
if (!res.ok) {
|
||||
toast.error(t("admin.webPushVapidGenerateFailed"));
|
||||
showErrorToast(t("admin.webPushVapidGenerateFailed"), { cause: res });
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -113,8 +114,8 @@ export function WebPushVapidSection() {
|
||||
queryKey: queryKeys.adminSettings(),
|
||||
});
|
||||
toast.success(t("admin.webPushVapidGenerated"));
|
||||
} catch {
|
||||
toast.error(t("admin.webPushVapidGenerateFailed"));
|
||||
} catch (failure) {
|
||||
showErrorToast(t("admin.webPushVapidGenerateFailed"), { cause: failure });
|
||||
} finally {
|
||||
setGenerating(false);
|
||||
}
|
||||
|
||||
@@ -11,6 +11,7 @@ import {
|
||||
} from "@tanstack/react-query";
|
||||
import { Gauge, ListOrdered, Pencil, Plus } from "lucide-react";
|
||||
import { toast } from "sonner";
|
||||
import { showErrorToast } from "@/lib/errors/error-toast";
|
||||
|
||||
import { DeleteButton } from "@/components/data-list";
|
||||
import { Button } from "@/components/ui/button";
|
||||
@@ -162,7 +163,8 @@ export function CustomMetricDetail({
|
||||
queryClient.invalidateQueries({ queryKey: queryKeys.customMetrics() });
|
||||
router.push("/measurements");
|
||||
},
|
||||
onError: () => toast.error(t("customMetrics.deleteError")),
|
||||
onError: (failure) =>
|
||||
showErrorToast(t("customMetrics.deleteError"), { cause: failure }),
|
||||
});
|
||||
|
||||
const description = metric?.description?.trim()
|
||||
|
||||
@@ -4,6 +4,7 @@ import { useMemo, useState } from "react";
|
||||
import { useMutation, useQueryClient } from "@tanstack/react-query";
|
||||
import { Loader2, Pencil } from "lucide-react";
|
||||
import { toast } from "sonner";
|
||||
import { showErrorToast } from "@/lib/errors/error-toast";
|
||||
|
||||
import { DeleteButton, SortableHead } from "@/components/data-list";
|
||||
import { Button } from "@/components/ui/button";
|
||||
@@ -96,8 +97,8 @@ export function CustomMetricHistoryList({
|
||||
});
|
||||
invalidate();
|
||||
toast.success(t("customMetrics.entry.restoredToast"));
|
||||
} catch {
|
||||
toast.error(t("customMetrics.entry.restoreError"));
|
||||
} catch (failure) {
|
||||
showErrorToast(t("customMetrics.entry.restoreError"), { cause: failure });
|
||||
}
|
||||
}
|
||||
|
||||
@@ -113,7 +114,8 @@ export function CustomMetricHistoryList({
|
||||
},
|
||||
});
|
||||
},
|
||||
onError: () => toast.error(t("customMetrics.entry.deleteError")),
|
||||
onError: (failure) =>
|
||||
showErrorToast(t("customMetrics.entry.deleteError"), { cause: failure }),
|
||||
});
|
||||
|
||||
const updateMutation = useMutation({
|
||||
|
||||
@@ -12,6 +12,7 @@
|
||||
import { useMemo } from "react";
|
||||
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import { toast } from "sonner";
|
||||
import { showErrorToast } from "@/lib/errors/error-toast";
|
||||
|
||||
import {
|
||||
ApiError,
|
||||
@@ -233,7 +234,8 @@ export function useDeleteCustomSymptom() {
|
||||
onSuccess: () => invalidateKeys(qc, cycleDependentKeys),
|
||||
// v1.16.4 — a failed delete used to fail silently (the sheet clears the
|
||||
// chip optimistically), so surface the rejection.
|
||||
onError: () => toast.error(t("cycle.deleteError")),
|
||||
onError: (failure) =>
|
||||
showErrorToast(t("cycle.deleteError"), { cause: failure }),
|
||||
});
|
||||
}
|
||||
|
||||
@@ -355,7 +357,8 @@ export function useStartPeriod() {
|
||||
},
|
||||
// v1.16.4 — period boundaries had no failure signal at all (the sheet
|
||||
// simply stayed open); a toast names the rejection.
|
||||
onError: () => toast.error(t("cycle.saveError")),
|
||||
onError: (failure) =>
|
||||
showErrorToast(t("cycle.saveError"), { cause: failure }),
|
||||
});
|
||||
}
|
||||
|
||||
@@ -375,7 +378,8 @@ export function useEndPeriod() {
|
||||
void invalidateKeys(qc, cycleDependentKeys);
|
||||
toastWrittenOutcome("success", t("cycle.periodEndSaved"));
|
||||
},
|
||||
onError: () => toast.error(t("cycle.saveError")),
|
||||
onError: (failure) =>
|
||||
showErrorToast(t("cycle.saveError"), { cause: failure }),
|
||||
});
|
||||
}
|
||||
|
||||
@@ -391,7 +395,8 @@ export function useDeleteDayLog() {
|
||||
void invalidateKeys(qc, cycleDependentKeys);
|
||||
toastWrittenOutcome("success", t("cycle.deleteSuccess"));
|
||||
},
|
||||
onError: () => toast.error(t("cycle.deleteError")),
|
||||
onError: (failure) =>
|
||||
showErrorToast(t("cycle.deleteError"), { cause: failure }),
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -35,6 +35,7 @@ import {
|
||||
import Link from "next/link";
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import { toast } from "sonner";
|
||||
import { showErrorToast } from "@/lib/errors/error-toast";
|
||||
|
||||
import { useIllnessEpisodes } from "@/components/illness/use-illness";
|
||||
import { useAuth } from "@/hooks/use-auth";
|
||||
@@ -567,11 +568,11 @@ export function DocumentDetailSheet({
|
||||
? error.meta.reason
|
||||
: "";
|
||||
if (reason === "purged") {
|
||||
toast.error(t("documents.error.purged"));
|
||||
showErrorToast(t("documents.error.purged"), { cause: error });
|
||||
} else if (reason === "duplicateExists") {
|
||||
toast.error(t("documents.error.duplicateExists"));
|
||||
showErrorToast(t("documents.error.duplicateExists"), { cause: error });
|
||||
} else {
|
||||
toast.error(t("documents.toast.restoreFailed"));
|
||||
showErrorToast(t("documents.toast.restoreFailed"), { cause: error });
|
||||
}
|
||||
},
|
||||
});
|
||||
@@ -681,16 +682,17 @@ export function DocumentDetailSheet({
|
||||
onSuccess: (result) => {
|
||||
if ("summary" in result) {
|
||||
if (result.persistence === "withheld") {
|
||||
toast.error(result.summary);
|
||||
showErrorToast(result.summary);
|
||||
} else if (result.persistence === "failed") {
|
||||
toast.error(t("documents.detail.saveError"));
|
||||
showErrorToast(t("documents.detail.saveError"));
|
||||
}
|
||||
}
|
||||
void queryClient.invalidateQueries({
|
||||
queryKey: queryKeys.inboundDocument(documentId),
|
||||
});
|
||||
},
|
||||
onError: (error) => toast.error(aiErrorText(error)),
|
||||
onError: (error) =>
|
||||
showErrorToast(aiErrorText(error), { cause: error }),
|
||||
},
|
||||
);
|
||||
};
|
||||
@@ -709,7 +711,8 @@ export function DocumentDetailSheet({
|
||||
? tCount("documents.ai.readStaged", result.labFactsStaged)
|
||||
: t("documents.ai.readDone"),
|
||||
),
|
||||
onError: (error) => toast.error(aiErrorText(error)),
|
||||
onError: (error) =>
|
||||
showErrorToast(aiErrorText(error), { cause: error }),
|
||||
},
|
||||
);
|
||||
};
|
||||
|
||||
@@ -28,6 +28,7 @@ import { useCalendarDate } from "@/hooks/use-calendar-date";
|
||||
import { useState } from "react";
|
||||
import { FlaskConical, Loader2, ScanSearch } from "lucide-react";
|
||||
import { toast } from "sonner";
|
||||
import { showErrorToast } from "@/lib/errors/error-toast";
|
||||
|
||||
import { toastWrittenOutcome } from "@/components/outcome/outcome-toast";
|
||||
import { Button } from "@/components/ui/button";
|
||||
@@ -635,7 +636,8 @@ export function DocumentFactsSection({
|
||||
toast.info(t("documents.review.extractEmpty"));
|
||||
}
|
||||
},
|
||||
onError: (error) => toast.error(aiErrorText(error)),
|
||||
onError: (error) =>
|
||||
showErrorToast(aiErrorText(error), { cause: error }),
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
@@ -17,6 +17,7 @@ import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import { Check, Search } from "lucide-react";
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import { toast } from "sonner";
|
||||
import { showErrorToast } from "@/lib/errors/error-toast";
|
||||
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { ResponsiveSheet } from "@/components/ui/responsive-sheet";
|
||||
@@ -92,10 +93,11 @@ export function DocumentLinkPicker({
|
||||
),
|
||||
onSuccess: (data) => {
|
||||
if (data.results.some((r) => !r.ok)) {
|
||||
toast.error(t("documents.bulk.failed"));
|
||||
showErrorToast(t("documents.bulk.failed"));
|
||||
}
|
||||
},
|
||||
onError: () => toast.error(t("documents.bulk.failed")),
|
||||
onError: (failure) =>
|
||||
showErrorToast(t("documents.bulk.failed"), { cause: failure }),
|
||||
onSettled: () => {
|
||||
void invalidateKeys(queryClient, [queryKeys.documents()]);
|
||||
},
|
||||
|
||||
@@ -33,6 +33,7 @@ import Link from "next/link";
|
||||
import { usePathname, useRouter, useSearchParams } from "next/navigation";
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||
import { toast } from "sonner";
|
||||
import { showErrorToast } from "@/lib/errors/error-toast";
|
||||
|
||||
import { useIllnessEpisodes } from "@/components/illness/use-illness";
|
||||
import { Button } from "@/components/ui/button";
|
||||
@@ -488,7 +489,10 @@ export function DocumentsView() {
|
||||
})
|
||||
: t("documents.contentIndex.indexAllNothing"),
|
||||
),
|
||||
onError: () => toast.error(t("documents.contentIndex.indexAllError")),
|
||||
onError: (failure) =>
|
||||
showErrorToast(t("documents.contentIndex.indexAllError"), {
|
||||
cause: failure,
|
||||
}),
|
||||
});
|
||||
}, [reindexAll, t]);
|
||||
|
||||
@@ -665,7 +669,7 @@ export function DocumentsView() {
|
||||
(results: DocumentBulkResultDto[], successMessage: string) => {
|
||||
const failed = results.filter((r) => !r.ok).length;
|
||||
if (failed > 0) {
|
||||
toast.error(
|
||||
showErrorToast(
|
||||
t("documents.bulk.partialFailure", {
|
||||
failed,
|
||||
total: results.length,
|
||||
@@ -700,7 +704,8 @@ export function DocumentsView() {
|
||||
);
|
||||
clearSelection();
|
||||
},
|
||||
onError: () => toast.error(t("documents.bulk.failed")),
|
||||
onError: (failure) =>
|
||||
showErrorToast(t("documents.bulk.failed"), { cause: failure }),
|
||||
},
|
||||
);
|
||||
},
|
||||
@@ -719,7 +724,10 @@ export function DocumentsView() {
|
||||
count: results.filter((r) => r.ok).length,
|
||||
}),
|
||||
),
|
||||
onError: () => toast.error(t("documents.toast.restoreFailed")),
|
||||
onError: (failure) =>
|
||||
showErrorToast(t("documents.toast.restoreFailed"), {
|
||||
cause: failure,
|
||||
}),
|
||||
},
|
||||
);
|
||||
},
|
||||
@@ -735,7 +743,7 @@ export function DocumentsView() {
|
||||
const okIds = results.filter((r) => r.ok).map((r) => r.id);
|
||||
const failed = results.length - okIds.length;
|
||||
if (failed > 0) {
|
||||
toast.error(
|
||||
showErrorToast(
|
||||
t("documents.bulk.partialFailure", {
|
||||
failed,
|
||||
total: results.length,
|
||||
@@ -756,7 +764,8 @@ export function DocumentsView() {
|
||||
}
|
||||
clearSelection();
|
||||
},
|
||||
onError: () => toast.error(t("documents.bulk.failed")),
|
||||
onError: (failure) =>
|
||||
showErrorToast(t("documents.bulk.failed"), { cause: failure }),
|
||||
},
|
||||
);
|
||||
},
|
||||
@@ -794,7 +803,7 @@ export function DocumentsView() {
|
||||
t("documents.card.untitled"),
|
||||
);
|
||||
if (resolved.overCap) {
|
||||
toast.error(
|
||||
showErrorToast(
|
||||
t("documents.bulk.shareTooMany", { max: SHARE_LINK_MAX_DOCUMENTS }),
|
||||
);
|
||||
return;
|
||||
|
||||
@@ -34,6 +34,7 @@
|
||||
import { useState } from "react";
|
||||
import { Trash2 } from "lucide-react";
|
||||
import { toast } from "sonner";
|
||||
import { showErrorToast } from "@/lib/errors/error-toast";
|
||||
|
||||
import {
|
||||
AlertDialog,
|
||||
@@ -130,7 +131,7 @@ export function EncounterSheet({
|
||||
const message =
|
||||
err instanceof Error ? err.message : t("encounters.saveFailed");
|
||||
setError(message);
|
||||
toast.error(message);
|
||||
showErrorToast(message, { cause: err });
|
||||
}
|
||||
};
|
||||
|
||||
@@ -205,7 +206,10 @@ export function EncounterSheet({
|
||||
onClick={() => {
|
||||
remove.mutate(encounter.id, {
|
||||
onSuccess: () => onOpenChange(false),
|
||||
onError: () => toast.error(t("encounters.deleteFailed")),
|
||||
onError: (failure) =>
|
||||
showErrorToast(t("encounters.deleteFailed"), {
|
||||
cause: failure,
|
||||
}),
|
||||
});
|
||||
}}
|
||||
>
|
||||
|
||||
@@ -3,6 +3,8 @@
|
||||
import { useState } from "react";
|
||||
import { Copy, CheckCircle2, RotateCcw } from "lucide-react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { ErrorIdLine } from "@/components/errors/error-id-line";
|
||||
import { errorIdOf } from "@/lib/errors/error-id";
|
||||
import { useTranslations } from "@/lib/i18n/context";
|
||||
|
||||
export interface ErrorDetailsProps {
|
||||
@@ -29,6 +31,9 @@ export function ErrorDetails({
|
||||
}: ErrorDetailsProps) {
|
||||
const { t, locale } = useTranslations();
|
||||
const [copied, setCopied] = useState(false);
|
||||
// The id the operator finds this failure by: the request id of an API
|
||||
// failure that reached the boundary, else the server render's digest.
|
||||
const errorId = requestId ?? errorIdOf(error, { unexpected: true });
|
||||
|
||||
async function handleCopy() {
|
||||
// Only capture the pathname — some flows carry OAuth codes / state tokens
|
||||
@@ -47,7 +52,7 @@ export function ErrorDetails({
|
||||
digest: error.digest,
|
||||
name: error.name,
|
||||
stack: error.stack?.split("\n").slice(0, 10).join("\n"),
|
||||
requestId: requestId ?? null,
|
||||
requestId: errorId,
|
||||
urlPath,
|
||||
searchParamCount,
|
||||
userAgent: typeof navigator !== "undefined" ? navigator.userAgent : null,
|
||||
@@ -81,6 +86,7 @@ export function ErrorDetails({
|
||||
<div className="bg-muted/50 text-muted-foreground rounded-md border p-3 font-mono text-xs break-all">
|
||||
{error.message || t("common.unknownError")}
|
||||
</div>
|
||||
{errorId && <ErrorIdLine id={errorId} />}
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
{reset && (
|
||||
<Button onClick={reset} variant="outline">
|
||||
|
||||
@@ -0,0 +1,73 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useState } from "react";
|
||||
import { Check, Copy } from "lucide-react";
|
||||
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { recordErrorId, shortErrorId } from "@/lib/errors/error-id";
|
||||
import { useTranslations } from "@/lib/i18n/context";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
/**
|
||||
* "Error ID 3f9a2c1b" with a copy button: the one way the web shows the id of
|
||||
* a failure, in toasts (`showErrorToast`) and in error boundaries. The first 8
|
||||
* characters are shown; the button copies the whole id, which the operator's
|
||||
* log search matches as well as the short one.
|
||||
*
|
||||
* Showing an id also records it for the session's support info.
|
||||
*/
|
||||
export function ErrorIdLine({
|
||||
id,
|
||||
className,
|
||||
}: {
|
||||
id: string;
|
||||
className?: string;
|
||||
}) {
|
||||
const { t } = useTranslations();
|
||||
const [copied, setCopied] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
recordErrorId(id);
|
||||
}, [id]);
|
||||
|
||||
async function copy() {
|
||||
try {
|
||||
await navigator.clipboard.writeText(id);
|
||||
setCopied(true);
|
||||
setTimeout(() => setCopied(false), 2000);
|
||||
} catch {
|
||||
window.prompt(t("common.errorIdCopy"), id);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<span
|
||||
data-slot="error-id"
|
||||
className={cn("inline-flex items-center gap-1.5 text-xs", className)}
|
||||
>
|
||||
<span className="text-muted-foreground">{t("common.errorIdLabel")}</span>
|
||||
<code className="text-foreground font-mono">{shortErrorId(id)}</code>
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="size-11 sm:size-8"
|
||||
onClick={(event) => {
|
||||
// Inside a toast, a click must not count as a dismiss swipe.
|
||||
event.stopPropagation();
|
||||
void copy();
|
||||
}}
|
||||
aria-label={
|
||||
copied ? t("common.errorIdCopied") : t("common.errorIdCopy")
|
||||
}
|
||||
title={copied ? t("common.errorIdCopied") : t("common.errorIdCopy")}
|
||||
>
|
||||
{copied ? (
|
||||
<Check className="size-3.5" aria-hidden="true" />
|
||||
) : (
|
||||
<Copy className="size-3.5" aria-hidden="true" />
|
||||
)}
|
||||
</Button>
|
||||
</span>
|
||||
);
|
||||
}
|
||||
@@ -10,6 +10,7 @@
|
||||
import { useState } from "react";
|
||||
import { MoreVertical, Pencil, CheckCircle2, Trash2 } from "lucide-react";
|
||||
import { toast } from "sonner";
|
||||
import { showErrorToast } from "@/lib/errors/error-toast";
|
||||
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
@@ -69,7 +70,8 @@ export function EpisodeMenu({
|
||||
label: t("common.undo"),
|
||||
onClick: () => {
|
||||
restore.mutate(episode.id, {
|
||||
onError: () => toast.error(t("illness.restoreError")),
|
||||
onError: (failure) =>
|
||||
showErrorToast(t("illness.restoreError"), { cause: failure }),
|
||||
});
|
||||
},
|
||||
},
|
||||
|
||||
@@ -9,6 +9,7 @@ import {
|
||||
} from "@tanstack/react-query";
|
||||
import { Paperclip, Sparkles } from "lucide-react";
|
||||
import { toast } from "sonner";
|
||||
import { showErrorToast } from "@/lib/errors/error-toast";
|
||||
|
||||
import { cn } from "@/lib/utils";
|
||||
import { useTranslations } from "@/lib/i18n/context";
|
||||
@@ -335,8 +336,10 @@ export function CoachConversation({
|
||||
onSuccess: (next) => {
|
||||
queryClient.setQueryData(queryKeys.coachAboutMeQuestions(), next);
|
||||
},
|
||||
onError: () => {
|
||||
toast.error(t("insights.coach.guided.dismissError"));
|
||||
onError: (failure) => {
|
||||
showErrorToast(t("insights.coach.guided.dismissError"), {
|
||||
cause: failure,
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
@@ -451,7 +454,7 @@ export function CoachConversation({
|
||||
}
|
||||
useEffect(() => {
|
||||
if (!invalidToastKey) return;
|
||||
toast.error(t("insights.coach.attach.notFoundError"));
|
||||
showErrorToast(t("insights.coach.attach.notFoundError"));
|
||||
}, [invalidToastKey, t]);
|
||||
|
||||
// The pills row above the composer. On a fresh chat every pill is a staged id;
|
||||
@@ -559,8 +562,10 @@ export function CoachConversation({
|
||||
headers: { "Idempotency-Key": randomId() },
|
||||
body: form,
|
||||
});
|
||||
} catch {
|
||||
toast.error(t("insights.coach.attach.uploadError"));
|
||||
} catch (failure) {
|
||||
showErrorToast(t("insights.coach.attach.uploadError"), {
|
||||
cause: failure,
|
||||
});
|
||||
continue;
|
||||
}
|
||||
const parsed = parseUploadResponse(
|
||||
@@ -568,7 +573,9 @@ export function CoachConversation({
|
||||
await response.text(),
|
||||
);
|
||||
if (!parsed.ok) {
|
||||
toast.error(t("insights.coach.attach.uploadError"));
|
||||
showErrorToast(t("insights.coach.attach.uploadError"), {
|
||||
cause: response,
|
||||
});
|
||||
continue;
|
||||
}
|
||||
const doc = parsed.document;
|
||||
|
||||
@@ -12,6 +12,7 @@
|
||||
import { useCallback, useEffect, useRef, useState } from "react";
|
||||
import { Check, Copy } from "lucide-react";
|
||||
import { toast } from "sonner";
|
||||
import { showErrorToast } from "@/lib/errors/error-toast";
|
||||
|
||||
import {
|
||||
DropdownMenu,
|
||||
@@ -61,8 +62,8 @@ export function CopyTableButton({ grid, caption }: CopyTableButtonProps) {
|
||||
setCopied(false);
|
||||
timer.current = null;
|
||||
}, COPY_CONFIRM_MS);
|
||||
} catch {
|
||||
toast.error(t(COACH_RESULT_UI_KEYS.copyFailed));
|
||||
} catch (failure) {
|
||||
showErrorToast(t(COACH_RESULT_UI_KEYS.copyFailed), { cause: failure });
|
||||
}
|
||||
},
|
||||
[grid, caption, t],
|
||||
|
||||
@@ -17,6 +17,7 @@
|
||||
*/
|
||||
import { useState } from "react";
|
||||
import { toast } from "sonner";
|
||||
import { showErrorToast } from "@/lib/errors/error-toast";
|
||||
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { cn } from "@/lib/utils";
|
||||
@@ -83,7 +84,7 @@ export function CoachMemoryNote({
|
||||
const facts = useCoachFacts({ enabled: saved && !liveFact && !undone });
|
||||
const forget = useForgetCoachFact({
|
||||
onSuccess: () => setUndone(true),
|
||||
onError: () => toast.error(t("settings.ai.coachMemory.forgotError")),
|
||||
onError: () => showErrorToast(t("settings.ai.coachMemory.forgotError")),
|
||||
});
|
||||
if (!saved || undone || !note.factId) return null;
|
||||
const fact =
|
||||
|
||||
@@ -18,6 +18,7 @@
|
||||
import { useCallback, useRef, useState } from "react";
|
||||
import { useMutation, useQueryClient } from "@tanstack/react-query";
|
||||
import { toast } from "sonner";
|
||||
import { showErrorToast } from "@/lib/errors/error-toast";
|
||||
import {
|
||||
BookmarkCheck,
|
||||
BookmarkPlus,
|
||||
@@ -139,8 +140,8 @@ function CopyMessageButton({
|
||||
await navigator.clipboard.writeText(text);
|
||||
setCopied(true);
|
||||
window.setTimeout(() => setCopied(false), 1500);
|
||||
} catch {
|
||||
toast.error(t("insights.coach.copyMessageError"));
|
||||
} catch (failure) {
|
||||
showErrorToast(t("insights.coach.copyMessageError"), { cause: failure });
|
||||
}
|
||||
}, [content, strip, t]);
|
||||
// Absent where the Clipboard API is (plain-HTTP self-hosts) rather than
|
||||
@@ -309,8 +310,10 @@ function RememberMessageButton({ messageId }: { messageId: string }) {
|
||||
),
|
||||
);
|
||||
},
|
||||
onError: () => {
|
||||
toast.error(t("insights.coach.rememberMessage.failed"));
|
||||
onError: (failure) => {
|
||||
showErrorToast(t("insights.coach.rememberMessage.failed"), {
|
||||
cause: failure,
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
import { useState } from "react";
|
||||
import { useMutation, useQueryClient } from "@tanstack/react-query";
|
||||
import { toast } from "sonner";
|
||||
import { showErrorToast } from "@/lib/errors/error-toast";
|
||||
import { BellPlus, Check, Loader2 } from "lucide-react";
|
||||
|
||||
import { cn } from "@/lib/utils";
|
||||
@@ -60,8 +61,8 @@ export function ReminderSuggestionCard({
|
||||
: "dismissed",
|
||||
);
|
||||
},
|
||||
onError: () => {
|
||||
toast.error(t("coach.reminderSuggestion.failed"));
|
||||
onError: (failure) => {
|
||||
showErrorToast(t("coach.reminderSuggestion.failed"), { cause: failure });
|
||||
},
|
||||
});
|
||||
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
import { useState } from "react";
|
||||
import { Eye, Loader2, Target } from "lucide-react";
|
||||
import { toast } from "sonner";
|
||||
import { showErrorToast } from "@/lib/errors/error-toast";
|
||||
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { Switch } from "@/components/ui/switch";
|
||||
@@ -98,7 +99,8 @@ export function SourcesRail({ className, activeScopeLabel }: SourcesRailProps) {
|
||||
onSettled: () => setPending(null),
|
||||
// v1.16.4 — a failed save used to revert the switch silently once the
|
||||
// spinner cleared; a toast names the rejection.
|
||||
onError: () => toast.error(t("insights.coach.prefsSaveError")),
|
||||
onError: (failure) =>
|
||||
showErrorToast(t("insights.coach.prefsSaveError"), { cause: failure }),
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
import { useState } from "react";
|
||||
import { useMutation, useQueryClient } from "@tanstack/react-query";
|
||||
import { toast } from "sonner";
|
||||
import { showErrorToast } from "@/lib/errors/error-toast";
|
||||
import { Check, Loader2, Sparkles } from "lucide-react";
|
||||
|
||||
import { cn } from "@/lib/utils";
|
||||
@@ -53,7 +54,8 @@ export function SuggestedActionCard({
|
||||
}
|
||||
setSettled("applied");
|
||||
},
|
||||
onError: () => toast.error(t("coach.suggestedAction.failed")),
|
||||
onError: (failure) =>
|
||||
showErrorToast(t("coach.suggestedAction.failed"), { cause: failure }),
|
||||
});
|
||||
|
||||
if (settled) {
|
||||
|
||||
@@ -16,6 +16,7 @@ import {
|
||||
} from "@tanstack/react-query";
|
||||
import type { QueryClient, QueryKey } from "@tanstack/react-query";
|
||||
import { toast } from "sonner";
|
||||
import { showErrorToast } from "@/lib/errors/error-toast";
|
||||
|
||||
import type {
|
||||
CoachActivity,
|
||||
@@ -521,7 +522,7 @@ export function useDeleteCoachConversationWithUndo() {
|
||||
const failedMessage = t("insights.coach.historyDeleteFailed");
|
||||
commitRef.current = (id) =>
|
||||
commitCoachConversationDelete(queryClient, id, owner, () =>
|
||||
toast.error(failedMessage),
|
||||
showErrorToast(failedMessage),
|
||||
);
|
||||
});
|
||||
|
||||
@@ -596,7 +597,7 @@ export function useRenameCoachConversation() {
|
||||
}),
|
||||
onError: (_error, _input, snapshot) => {
|
||||
if (snapshot) restoreCoachConversationRename(queryClient, snapshot);
|
||||
toast.error(t("insights.coach.rename.error"));
|
||||
showErrorToast(t("insights.coach.rename.error"), { cause: _error });
|
||||
},
|
||||
onSettled: (_data, _error, input) => {
|
||||
void invalidateCoachConversationRename(queryClient, input.id);
|
||||
|
||||
@@ -4,6 +4,7 @@ import { useEffect, useRef, useState } from "react";
|
||||
import Link from "next/link";
|
||||
import { useMutation, useQueryClient } from "@tanstack/react-query";
|
||||
import { toast } from "sonner";
|
||||
import { showErrorToast } from "@/lib/errors/error-toast";
|
||||
import {
|
||||
Eye,
|
||||
EyeOff,
|
||||
@@ -195,7 +196,7 @@ export function InsightsEditMode({
|
||||
toast.message(t("common.conflictReloaded"));
|
||||
return;
|
||||
}
|
||||
toast.error(t("insights.editMode.saveError"));
|
||||
showErrorToast(t("insights.editMode.saveError"), { cause: err });
|
||||
},
|
||||
});
|
||||
|
||||
@@ -212,7 +213,8 @@ export function InsightsEditMode({
|
||||
setDraftState(seedArrangeDraft(saved.sections));
|
||||
toast.success(t("insights.editMode.resetSuccess"));
|
||||
},
|
||||
onError: () => toast.error(t("insights.editMode.saveError")),
|
||||
onError: (failure) =>
|
||||
showErrorToast(t("insights.editMode.saveError"), { cause: failure }),
|
||||
});
|
||||
|
||||
const busy = saveMutation.isPending || resetMutation.isPending;
|
||||
|
||||
@@ -12,6 +12,7 @@ import {
|
||||
Wrench,
|
||||
} from "lucide-react";
|
||||
import { toast } from "sonner";
|
||||
import { showErrorToast } from "@/lib/errors/error-toast";
|
||||
|
||||
import {
|
||||
Popover,
|
||||
@@ -724,7 +725,7 @@ function InsightsTabStripImpl({
|
||||
const decision = regenerateToastKind(regenerateOutcome);
|
||||
if (decision) {
|
||||
if (decision.kind === "error") {
|
||||
toast.error(t(decision.messageKey));
|
||||
showErrorToast(t(decision.messageKey));
|
||||
} else {
|
||||
toast.success(t(decision.messageKey));
|
||||
}
|
||||
|
||||
@@ -4,6 +4,7 @@ import { QueryClientContext, type QueryClient } from "@tanstack/react-query";
|
||||
import { EyeOff, RotateCcw, X } from "lucide-react";
|
||||
import { useContext, useReducer, useState } from "react";
|
||||
import { toast } from "sonner";
|
||||
import { showErrorToast } from "@/lib/errors/error-toast";
|
||||
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { apiPatch } from "@/lib/api/api-fetch";
|
||||
@@ -138,8 +139,8 @@ export function PatternDismissButton({
|
||||
{ dismissed: true },
|
||||
);
|
||||
await applyPatternDecisionSuccess(queryClient, onDismissed, onSettled);
|
||||
} catch {
|
||||
toast.error(t("insights.pattern.dismissError"));
|
||||
} catch (failure) {
|
||||
showErrorToast(t("insights.pattern.dismissError"), { cause: failure });
|
||||
} finally {
|
||||
setPending(false);
|
||||
}
|
||||
@@ -184,8 +185,8 @@ export function PatternDismissedNotice({
|
||||
{ dismissed: false },
|
||||
);
|
||||
await applyPatternDecisionSuccess(queryClient, onRestored, onSettled);
|
||||
} catch {
|
||||
toast.error(t("insights.pattern.restoreError"));
|
||||
} catch (failure) {
|
||||
showErrorToast(t("insights.pattern.restoreError"), { cause: failure });
|
||||
} finally {
|
||||
setPending(false);
|
||||
}
|
||||
|
||||
@@ -4,6 +4,7 @@ import { useState } from "react";
|
||||
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import { Eye, EyeOff, FlaskConical, Pencil, Plus } from "lucide-react";
|
||||
import { toast } from "sonner";
|
||||
import { showErrorToast } from "@/lib/errors/error-toast";
|
||||
|
||||
import { DeleteButton } from "@/components/data-list";
|
||||
import { Button } from "@/components/ui/button";
|
||||
@@ -61,7 +62,8 @@ export function BiomarkerManager() {
|
||||
queryClient.invalidateQueries({ queryKey: queryKeys.biomarkers() });
|
||||
queryClient.invalidateQueries({ queryKey: queryKeys.labResults() });
|
||||
},
|
||||
onError: () => toast.error(t("labs.biomarker.deleteError")),
|
||||
onError: (failure) =>
|
||||
showErrorToast(t("labs.biomarker.deleteError"), { cause: failure }),
|
||||
});
|
||||
|
||||
const hiddenMutation = useMutation({
|
||||
@@ -75,7 +77,8 @@ export function BiomarkerManager() {
|
||||
);
|
||||
queryClient.invalidateQueries({ queryKey: queryKeys.biomarkers() });
|
||||
},
|
||||
onError: () => toast.error(t("labs.biomarker.hideError")),
|
||||
onError: (failure) =>
|
||||
showErrorToast(t("labs.biomarker.hideError"), { cause: failure }),
|
||||
});
|
||||
|
||||
function openNew() {
|
||||
|
||||
@@ -19,6 +19,7 @@ import {
|
||||
Plus,
|
||||
} from "lucide-react";
|
||||
import { toast } from "sonner";
|
||||
import { showErrorToast } from "@/lib/errors/error-toast";
|
||||
|
||||
import { useAuth } from "@/hooks/use-auth";
|
||||
import { DeleteButton } from "@/components/data-list";
|
||||
@@ -283,7 +284,8 @@ export function LabBiomarkerDetail({ biomarkerId }: { biomarkerId: string }) {
|
||||
queryClient.invalidateQueries({ queryKey: queryKeys.labResults() });
|
||||
router.push("/labs");
|
||||
},
|
||||
onError: () => toast.error(t("labs.biomarker.deleteError")),
|
||||
onError: (failure) =>
|
||||
showErrorToast(t("labs.biomarker.deleteError"), { cause: failure }),
|
||||
});
|
||||
|
||||
// v1.25.1 — the per-marker description leads the page, mirroring the metric
|
||||
|
||||
@@ -6,6 +6,7 @@ import { useMemo, useState } from "react";
|
||||
import { useMutation, useQueryClient } from "@tanstack/react-query";
|
||||
import { Loader2, Pencil } from "lucide-react";
|
||||
import { toast } from "sonner";
|
||||
import { showErrorToast } from "@/lib/errors/error-toast";
|
||||
|
||||
import { DeleteButton, SortableHead } from "@/components/data-list";
|
||||
import { Button } from "@/components/ui/button";
|
||||
@@ -156,8 +157,8 @@ export function LabHistoryList({ readings }: { readings: LabResultDto[] }) {
|
||||
await apiPost("/api/labs/restore", { ids: [id] });
|
||||
invalidate();
|
||||
toast.success(t("labs.restoredToast"));
|
||||
} catch {
|
||||
toast.error(t("labs.restoreError"));
|
||||
} catch (failure) {
|
||||
showErrorToast(t("labs.restoreError"), { cause: failure });
|
||||
}
|
||||
}
|
||||
|
||||
@@ -172,7 +173,8 @@ export function LabHistoryList({ readings }: { readings: LabResultDto[] }) {
|
||||
},
|
||||
});
|
||||
},
|
||||
onError: () => toast.error(t("labs.deleteError")),
|
||||
onError: (failure) =>
|
||||
showErrorToast(t("labs.deleteError"), { cause: failure }),
|
||||
});
|
||||
|
||||
const updateMutation = useMutation({
|
||||
|
||||
@@ -14,6 +14,7 @@ import { useEffect, useId, useMemo, useRef, useState } from "react";
|
||||
|
||||
import { AlertCircle, Loader2, ScanLine, Upload } from "lucide-react";
|
||||
import { toast } from "sonner";
|
||||
import { showErrorToast } from "@/lib/errors/error-toast";
|
||||
|
||||
import { toastWrittenOutcome } from "@/components/outcome/outcome-toast";
|
||||
import { Button } from "@/components/ui/button";
|
||||
@@ -233,21 +234,22 @@ export function OcrReviewDialog({
|
||||
onSuccess: (data) => {
|
||||
const seeded = seedReviewRows(data.rows, data.reportDate);
|
||||
if (seeded.length === 0) {
|
||||
toast.error(t("labs.ocr.noRows"));
|
||||
showErrorToast(t("labs.ocr.noRows"));
|
||||
return;
|
||||
}
|
||||
setRows(seeded);
|
||||
setPageCoverage(data.pageCoverage ?? null);
|
||||
setStage("review");
|
||||
},
|
||||
onError: (err) => toast.error(extractErrorMessage(err, t)),
|
||||
onError: (err) =>
|
||||
showErrorToast(extractErrorMessage(err, t), { cause: err }),
|
||||
});
|
||||
}
|
||||
|
||||
function onSave() {
|
||||
const plan = planSave(rows);
|
||||
if (plan.kind === "nothing-selected") {
|
||||
toast.error(t("labs.ocr.nothingToSave"));
|
||||
showErrorToast(t("labs.ocr.nothingToSave"));
|
||||
return;
|
||||
}
|
||||
if (plan.kind === "blocked") {
|
||||
@@ -296,7 +298,8 @@ export function OcrReviewDialog({
|
||||
onCommitted();
|
||||
handleClose(false);
|
||||
},
|
||||
onError: () => toast.error(t("labs.ocr.saveFailed")),
|
||||
onError: (failure) =>
|
||||
showErrorToast(t("labs.ocr.saveFailed"), { cause: failure }),
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
@@ -65,6 +65,7 @@ import { useTableSort } from "@/hooks/use-table-sort";
|
||||
import { useUrlFilterSync } from "@/hooks/use-url-filter-sync";
|
||||
import { createPortal } from "react-dom";
|
||||
import { toast } from "sonner";
|
||||
import { showErrorToast } from "@/lib/errors/error-toast";
|
||||
import { formatDateOrRelative, formatDateTime } from "@/lib/format";
|
||||
import { DayLink, DayLinkAt } from "@/components/day/day-link";
|
||||
import { useTranslations, useFormatters } from "@/lib/i18n/context";
|
||||
@@ -687,8 +688,8 @@ export function MeasurementList({
|
||||
await invalidateKeys(queryClient, measurementDependentKeys);
|
||||
await refetchInactiveDailyReads(queryClient);
|
||||
toast.success(t("measurements.restoredToast"));
|
||||
} catch {
|
||||
toast.error(t("measurements.restoreError"));
|
||||
} catch (failure) {
|
||||
showErrorToast(t("measurements.restoreError"), { cause: failure });
|
||||
}
|
||||
},
|
||||
[queryClient, t],
|
||||
@@ -711,8 +712,8 @@ export function MeasurementList({
|
||||
// v1.16.4 — a failed row delete used to fail silently (no onError
|
||||
// handler); the edit-dialog path surfaced its own inline error but
|
||||
// the row-level confirm had no feedback at all.
|
||||
onError: () => {
|
||||
toast.error(t("measurements.deleteError"));
|
||||
onError: (failure) => {
|
||||
showErrorToast(t("measurements.deleteError"), { cause: failure });
|
||||
},
|
||||
});
|
||||
|
||||
@@ -744,8 +745,8 @@ export function MeasurementList({
|
||||
},
|
||||
);
|
||||
},
|
||||
onError: () => {
|
||||
toast.error(t("measurements.bulkDeleteError"));
|
||||
onError: (failure) => {
|
||||
showErrorToast(t("measurements.bulkDeleteError"), { cause: failure });
|
||||
},
|
||||
});
|
||||
|
||||
|
||||
@@ -14,6 +14,7 @@ import dynamic from "next/dynamic";
|
||||
import { Activity, TrendingUp } from "lucide-react";
|
||||
import { useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import { toast } from "sonner";
|
||||
import { showErrorToast } from "@/lib/errors/error-toast";
|
||||
|
||||
import { MedicationDetailSection } from "@/components/medications/medication-detail-section";
|
||||
import { SegmentedToggle } from "@/components/medications/detail/segmented-toggle";
|
||||
@@ -434,12 +435,14 @@ function RetargetControl({
|
||||
await apiPut(`/api/medications/${medicationId}/efficacy/target`, body);
|
||||
onChanged();
|
||||
if (clear) setValue("");
|
||||
} catch {
|
||||
} catch (failure) {
|
||||
// v1.30 UI audit (M9) — the save previously failed silently: the
|
||||
// buttons re-enabled with no toast, no inline alert, and the
|
||||
// rejection escaped as an unhandled promise. The user could not
|
||||
// tell the override was not saved.
|
||||
toast.error(t("medications.efficacy.retarget.error"));
|
||||
showErrorToast(t("medications.efficacy.retarget.error"), {
|
||||
cause: failure,
|
||||
});
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
|
||||
@@ -21,6 +21,7 @@ import { useId, useMemo, useState } from "react";
|
||||
import { useQueryClient } from "@tanstack/react-query";
|
||||
import { Loader2 } from "lucide-react";
|
||||
import { toast } from "sonner";
|
||||
import { showErrorToast } from "@/lib/errors/error-toast";
|
||||
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { ResponsiveSheet } from "@/components/ui/responsive-sheet";
|
||||
@@ -202,8 +203,11 @@ export function LedgerAddDialog({
|
||||
await refetchInactiveDailyReads(queryClient);
|
||||
setNudgeOpen(false);
|
||||
onOpenChange(false);
|
||||
} catch {
|
||||
toast.error(t("medications.intakeToastFailed", { name: medicationName }));
|
||||
} catch (failure) {
|
||||
showErrorToast(
|
||||
t("medications.intakeToastFailed", { name: medicationName }),
|
||||
{ cause: failure },
|
||||
);
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
|
||||
@@ -61,6 +61,7 @@ import {
|
||||
Unlink,
|
||||
} from "lucide-react";
|
||||
import { toast } from "sonner";
|
||||
import { showErrorToast } from "@/lib/errors/error-toast";
|
||||
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
@@ -307,10 +308,11 @@ export function DoseHistoryLedger({
|
||||
// reads (Today hero + dashboard) are unmounted behind the detail
|
||||
// page, so force their inactive refetch too (v1.32.19).
|
||||
await refetchInactiveDailyReads(queryClient);
|
||||
} catch {
|
||||
} catch (failure) {
|
||||
if (prev) queryClient.setQueryData(queryKey, prev);
|
||||
toast.error(
|
||||
showErrorToast(
|
||||
t("medications.intakeToastFailed", { name: medicationName }),
|
||||
{ cause: failure },
|
||||
);
|
||||
} finally {
|
||||
setMarking(null);
|
||||
@@ -359,8 +361,10 @@ export function DoseHistoryLedger({
|
||||
// page, so force their inactive refetch too (v1.32.19).
|
||||
await refetchInactiveDailyReads(queryClient);
|
||||
toast.success(t("medications.detail.intake.edit.savedToast"));
|
||||
} catch {
|
||||
toast.error(t("medications.detail.intake.edit.failed"));
|
||||
} catch (failure) {
|
||||
showErrorToast(t("medications.detail.intake.edit.failed"), {
|
||||
cause: failure,
|
||||
});
|
||||
}
|
||||
},
|
||||
[medicationId, queryClient, queryKey, t],
|
||||
@@ -393,8 +397,10 @@ export function DoseHistoryLedger({
|
||||
// reads (Today hero + dashboard) are unmounted behind the detail
|
||||
// page, so force their inactive refetch too (v1.32.19).
|
||||
await refetchInactiveDailyReads(queryClient);
|
||||
} catch {
|
||||
toast.error(t("medications.detail.intake.edit.failed"));
|
||||
} catch (failure) {
|
||||
showErrorToast(t("medications.detail.intake.edit.failed"), {
|
||||
cause: failure,
|
||||
});
|
||||
}
|
||||
},
|
||||
[medicationId, queryClient, queryKey, t],
|
||||
@@ -412,8 +418,10 @@ export function DoseHistoryLedger({
|
||||
// reads (Today hero + dashboard) are unmounted behind the detail
|
||||
// page, so force their inactive refetch too (v1.32.19).
|
||||
await refetchInactiveDailyReads(queryClient);
|
||||
} catch {
|
||||
toast.error(t("medications.detail.intake.deleteRow.failed"));
|
||||
} catch (failure) {
|
||||
showErrorToast(t("medications.detail.intake.deleteRow.failed"), {
|
||||
cause: failure,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -7,6 +7,7 @@ import {
|
||||
import { useEffect, useReducer, useState } from "react";
|
||||
import { useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import { toast } from "sonner";
|
||||
import { showErrorToast } from "@/lib/errors/error-toast";
|
||||
|
||||
import { MedicationCardMenu } from "@/components/medications/medication-card-menu";
|
||||
import { MedicationStateBadges } from "@/components/medications/card-parts/medication-state-badges";
|
||||
@@ -321,7 +322,9 @@ export function Glp1MedicationCard({
|
||||
injectionSite: site,
|
||||
});
|
||||
} catch (err) {
|
||||
toast.error(t("medications.logInjectionSiteSaveFailed"));
|
||||
showErrorToast(t("medications.logInjectionSiteSaveFailed"), {
|
||||
cause: err,
|
||||
});
|
||||
throw err;
|
||||
}
|
||||
await invalidateKeys(queryClient, medicationDependentKeys);
|
||||
|
||||
@@ -24,6 +24,7 @@
|
||||
import { useId, useState } from "react";
|
||||
import { useQueryClient } from "@tanstack/react-query";
|
||||
import { toast } from "sonner";
|
||||
import { showErrorToast } from "@/lib/errors/error-toast";
|
||||
import { Loader2 } from "lucide-react";
|
||||
|
||||
import { Button } from "@/components/ui/button";
|
||||
@@ -135,8 +136,10 @@ function IntakeEditDialogBody({
|
||||
await invalidateMedicationReads(queryClient);
|
||||
toast.success(t("medications.detail.intake.edit.savedToast"));
|
||||
onClose();
|
||||
} catch {
|
||||
toast.error(t("medications.detail.intake.edit.failed"));
|
||||
} catch (failure) {
|
||||
showErrorToast(t("medications.detail.intake.edit.failed"), {
|
||||
cause: failure,
|
||||
});
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
|
||||
@@ -22,6 +22,7 @@ import { useState } from "react";
|
||||
import { useQueryClient } from "@tanstack/react-query";
|
||||
import { Trash2 } from "lucide-react";
|
||||
import { toast } from "sonner";
|
||||
import { showErrorToast } from "@/lib/errors/error-toast";
|
||||
|
||||
import {
|
||||
AlertDialog,
|
||||
@@ -102,8 +103,10 @@ export function IntakeHistoryEditable({
|
||||
toast.success(t("medications.detail.intake.bulkDelete.toast"));
|
||||
setBulkConfirmOpen(false);
|
||||
clearSelection();
|
||||
} catch {
|
||||
toast.error(t("medications.detail.intake.bulkDelete.failed"));
|
||||
} catch (failure) {
|
||||
showErrorToast(t("medications.detail.intake.bulkDelete.failed"), {
|
||||
cause: failure,
|
||||
});
|
||||
} finally {
|
||||
setBulkBusy(false);
|
||||
}
|
||||
@@ -126,8 +129,10 @@ export function IntakeHistoryEditable({
|
||||
next.delete(id);
|
||||
return next;
|
||||
});
|
||||
} catch {
|
||||
toast.error(t("medications.detail.intake.deleteRow.failed"));
|
||||
} catch (failure) {
|
||||
showErrorToast(t("medications.detail.intake.deleteRow.failed"), {
|
||||
cause: failure,
|
||||
});
|
||||
} finally {
|
||||
setRowDeleteBusy(false);
|
||||
}
|
||||
|
||||
@@ -24,6 +24,7 @@ import { useId, useMemo, useState } from "react";
|
||||
import { useQueryClient } from "@tanstack/react-query";
|
||||
import { Loader2 } from "lucide-react";
|
||||
import { toast } from "sonner";
|
||||
import { showErrorToast } from "@/lib/errors/error-toast";
|
||||
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { ResponsiveSheet } from "@/components/ui/responsive-sheet";
|
||||
@@ -195,7 +196,7 @@ export function LogIntakeDialog({
|
||||
if (slot !== NO_SLOT) {
|
||||
const iso = slotInstantIso(takenAt, slot);
|
||||
if (!iso) {
|
||||
toast.error(t("medications.logIntake.slotError"));
|
||||
showErrorToast(t("medications.logIntake.slotError"));
|
||||
return;
|
||||
}
|
||||
scheduledFor = iso;
|
||||
|
||||
@@ -7,6 +7,7 @@ import {
|
||||
import { useState, useEffect, useReducer } from "react";
|
||||
import { useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import { toast } from "sonner";
|
||||
import { showErrorToast } from "@/lib/errors/error-toast";
|
||||
import { useMedicationIntake } from "@/components/medications/use-medication-intake";
|
||||
import { MedicationCardMenu } from "@/components/medications/medication-card-menu";
|
||||
import { MedicationStateBadges } from "@/components/medications/card-parts/medication-state-badges";
|
||||
@@ -243,7 +244,9 @@ export function MedicationCard({
|
||||
injectionSite: site,
|
||||
});
|
||||
} catch (err) {
|
||||
toast.error(t("medications.logInjectionSiteSaveFailed"));
|
||||
showErrorToast(t("medications.logInjectionSiteSaveFailed"), {
|
||||
cause: err,
|
||||
});
|
||||
throw err;
|
||||
}
|
||||
await invalidateKeys(queryClient, medicationDependentKeys);
|
||||
|
||||
@@ -9,6 +9,7 @@
|
||||
import { Eye, EyeOff, Loader2, Pencil, Tag, Trash2 } from "lucide-react";
|
||||
import { useState } from "react";
|
||||
import { toast } from "sonner";
|
||||
import { showErrorToast } from "@/lib/errors/error-toast";
|
||||
|
||||
import {
|
||||
AlertDialog,
|
||||
@@ -55,8 +56,10 @@ function CategoryRow({
|
||||
try {
|
||||
await update.mutateAsync({ key: category.key, label: trimmed });
|
||||
setEditing(false);
|
||||
} catch {
|
||||
toast.error(t("medications.category.custom.saveFailed"));
|
||||
} catch (failure) {
|
||||
showErrorToast(t("medications.category.custom.saveFailed"), {
|
||||
cause: failure,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -66,8 +69,10 @@ function CategoryRow({
|
||||
key: category.key,
|
||||
isActive: !category.isActive,
|
||||
});
|
||||
} catch {
|
||||
toast.error(t("medications.category.custom.saveFailed"));
|
||||
} catch (failure) {
|
||||
showErrorToast(t("medications.category.custom.saveFailed"), {
|
||||
cause: failure,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -221,8 +226,10 @@ export function MedicationCategoriesSheet({
|
||||
count: String(result.movedCount),
|
||||
}),
|
||||
);
|
||||
} catch {
|
||||
toast.error(t("medications.category.custom.saveFailed"));
|
||||
} catch (failure) {
|
||||
showErrorToast(t("medications.category.custom.saveFailed"), {
|
||||
cause: failure,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -39,6 +39,7 @@ import {
|
||||
Trash2,
|
||||
} from "lucide-react";
|
||||
import { toast } from "sonner";
|
||||
import { showErrorToast } from "@/lib/errors/error-toast";
|
||||
|
||||
import {
|
||||
AlertDialog,
|
||||
@@ -143,8 +144,10 @@ export function ScheduleHistoryTimeline({
|
||||
queryKey: queryKeys.medicationDetail(medicationId),
|
||||
});
|
||||
},
|
||||
onError: () => {
|
||||
toast.error(t("medications.detail.zeitplan.history.deleteError"));
|
||||
onError: (failure) => {
|
||||
showErrorToast(t("medications.detail.zeitplan.history.deleteError"), {
|
||||
cause: failure,
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
@@ -468,11 +471,12 @@ function EraDialog({
|
||||
reset();
|
||||
onSaved();
|
||||
},
|
||||
onError: () => {
|
||||
toast.error(
|
||||
onError: (failure) => {
|
||||
showErrorToast(
|
||||
target
|
||||
? t("medications.detail.zeitplan.history.updateError")
|
||||
: t("medications.detail.zeitplan.history.createError"),
|
||||
{ cause: failure },
|
||||
);
|
||||
},
|
||||
});
|
||||
|
||||
@@ -20,6 +20,7 @@
|
||||
import { useMemo, useState } from "react";
|
||||
import { useQueryClient } from "@tanstack/react-query";
|
||||
import { toast } from "sonner";
|
||||
import { showErrorToast } from "@/lib/errors/error-toast";
|
||||
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Label } from "@/components/ui/label";
|
||||
@@ -170,8 +171,10 @@ export function ScheduleTimesEditor({
|
||||
await apiPut(`/api/medications/${medicationId}`, body);
|
||||
await invalidateKeys(queryClient, medicationDependentKeys);
|
||||
toast.success(t("medications.detail.zeitplan.saved"));
|
||||
} catch {
|
||||
toast.error(t("medications.detail.zeitplan.failed"));
|
||||
} catch (failure) {
|
||||
showErrorToast(t("medications.detail.zeitplan.failed"), {
|
||||
cause: failure,
|
||||
});
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
|
||||
@@ -19,6 +19,7 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import { useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import { toast } from "sonner";
|
||||
import { showErrorToast } from "@/lib/errors/error-toast";
|
||||
import { ChevronDown, ChevronUp, Copy, KeyRound } from "lucide-react";
|
||||
|
||||
import { Button } from "@/components/ui/button";
|
||||
@@ -142,8 +143,10 @@ export function ApiTokensRow({
|
||||
try {
|
||||
await navigator.clipboard.writeText(text);
|
||||
toast.success(label);
|
||||
} catch {
|
||||
toast.error(t("medications.detail.api.copyFailed"));
|
||||
} catch (failure) {
|
||||
showErrorToast(t("medications.detail.api.copyFailed"), {
|
||||
cause: failure,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -178,10 +181,11 @@ export function ApiTokensRow({
|
||||
: (status?.activeTokenCount ?? 0) + 1,
|
||||
});
|
||||
} catch (err) {
|
||||
toast.error(
|
||||
showErrorToast(
|
||||
err instanceof ApiError && err.message
|
||||
? err.message
|
||||
: t("medications.detail.api.mintFailed"),
|
||||
{ cause: err },
|
||||
);
|
||||
} finally {
|
||||
setBusy(false);
|
||||
|
||||
@@ -13,6 +13,7 @@
|
||||
import { useState } from "react";
|
||||
import { useQueryClient } from "@tanstack/react-query";
|
||||
import { toast } from "sonner";
|
||||
import { showErrorToast } from "@/lib/errors/error-toast";
|
||||
|
||||
import { Switch } from "@/components/ui/switch";
|
||||
import { useTranslations } from "@/lib/i18n/context";
|
||||
@@ -55,9 +56,11 @@ export function AutoMarkTakenBody({
|
||||
? t("medications.autoMarkTaken.enabledToast")
|
||||
: t("medications.autoMarkTaken.disabledToast"),
|
||||
);
|
||||
} catch {
|
||||
} catch (failure) {
|
||||
setLocalOn(previous);
|
||||
toast.error(t("medications.autoMarkTaken.toggleFailed"));
|
||||
showErrorToast(t("medications.autoMarkTaken.toggleFailed"), {
|
||||
cause: failure,
|
||||
});
|
||||
} finally {
|
||||
setSubmitting(false);
|
||||
}
|
||||
|
||||
@@ -15,6 +15,7 @@ import { useState } from "react";
|
||||
import { useQueryClient } from "@tanstack/react-query";
|
||||
import { CalendarPlus, Loader2, Pencil, Trash2 } from "lucide-react";
|
||||
import { toast } from "sonner";
|
||||
import { showErrorToast } from "@/lib/errors/error-toast";
|
||||
|
||||
import {
|
||||
AlertDialog,
|
||||
@@ -222,8 +223,8 @@ export function CoursesRow({
|
||||
await apiDelete(`/api/medications/${medicationId}/courses/${course.id}`);
|
||||
await invalidateMedicationReads(queryClient);
|
||||
setDeleting(null);
|
||||
} catch {
|
||||
toast.error(t("medications.course.error.generic"));
|
||||
} catch (failure) {
|
||||
showErrorToast(t("medications.course.error.generic"), { cause: failure });
|
||||
} finally {
|
||||
setDeleteBusy(false);
|
||||
}
|
||||
|
||||
@@ -30,6 +30,7 @@ import { useState } from "react";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { useQueryClient } from "@tanstack/react-query";
|
||||
import { toast } from "sonner";
|
||||
import { showErrorToast } from "@/lib/errors/error-toast";
|
||||
import { Archive, Loader2, Trash2 } from "lucide-react";
|
||||
|
||||
import {
|
||||
@@ -92,9 +93,11 @@ export function LifecycleManageBody({
|
||||
: t("medications.detail.zone.pause.resumedToast"),
|
||||
);
|
||||
onAfterAction?.();
|
||||
} catch {
|
||||
} catch (failure) {
|
||||
setPaused(previous);
|
||||
toast.error(t("medications.detail.zone.pause.failed"));
|
||||
showErrorToast(t("medications.detail.zone.pause.failed"), {
|
||||
cause: failure,
|
||||
});
|
||||
} finally {
|
||||
setTier1Busy(false);
|
||||
}
|
||||
@@ -110,8 +113,10 @@ export function LifecycleManageBody({
|
||||
toast.success(t("medications.detail.zone.end.toast"));
|
||||
setEndDialogOpen(false);
|
||||
onAfterAction?.();
|
||||
} catch {
|
||||
toast.error(t("medications.detail.zone.end.failed"));
|
||||
} catch (failure) {
|
||||
showErrorToast(t("medications.detail.zone.end.failed"), {
|
||||
cause: failure,
|
||||
});
|
||||
} finally {
|
||||
setTier2Busy(false);
|
||||
}
|
||||
@@ -246,8 +251,10 @@ export function DangerZoneBody({
|
||||
toast.success(t("medications.detail.zone.purge.toast"));
|
||||
setPurgeDialogOpen(false);
|
||||
onAfterAction?.();
|
||||
} catch {
|
||||
toast.error(t("medications.detail.zone.purge.failed"));
|
||||
} catch (failure) {
|
||||
showErrorToast(t("medications.detail.zone.purge.failed"), {
|
||||
cause: failure,
|
||||
});
|
||||
} finally {
|
||||
setTier3aBusy(false);
|
||||
}
|
||||
@@ -278,8 +285,10 @@ export function DangerZoneBody({
|
||||
// analytics / compliance / inline chart). Not awaited: navigation
|
||||
// already happened and a transient refetch must not block it.
|
||||
void invalidateMedicationReads(queryClient);
|
||||
} catch {
|
||||
toast.error(t("medications.detail.zone.delete.failed"));
|
||||
} catch (failure) {
|
||||
showErrorToast(t("medications.detail.zone.delete.failed"), {
|
||||
cause: failure,
|
||||
});
|
||||
setTier3bBusy(false);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -13,6 +13,7 @@
|
||||
import { useState } from "react";
|
||||
import { useQueryClient } from "@tanstack/react-query";
|
||||
import { toast } from "sonner";
|
||||
import { showErrorToast } from "@/lib/errors/error-toast";
|
||||
|
||||
import { Switch } from "@/components/ui/switch";
|
||||
import { useTranslations } from "@/lib/i18n/context";
|
||||
@@ -55,9 +56,11 @@ export function IntakeTrackingBody({
|
||||
? t("medications.trackIntake.enabledToast")
|
||||
: t("medications.trackIntake.disabledToast"),
|
||||
);
|
||||
} catch {
|
||||
} catch (failure) {
|
||||
setLocalTracked(previous);
|
||||
toast.error(t("medications.trackIntake.toggleFailed"));
|
||||
showErrorToast(t("medications.trackIntake.toggleFailed"), {
|
||||
cause: failure,
|
||||
});
|
||||
} finally {
|
||||
setSubmitting(false);
|
||||
}
|
||||
|
||||
@@ -18,6 +18,7 @@
|
||||
import { useId, useState, type FormEvent } from "react";
|
||||
import { useQueryClient } from "@tanstack/react-query";
|
||||
import { toast } from "sonner";
|
||||
import { showErrorToast } from "@/lib/errors/error-toast";
|
||||
import { Loader2 } from "lucide-react";
|
||||
|
||||
import { Button } from "@/components/ui/button";
|
||||
@@ -145,8 +146,10 @@ export function AddInventoryDialog({
|
||||
await invalidateSupplyQueries(queryClient, medicationId);
|
||||
toast.success(t("medications.detail.bestand.addSuccess"));
|
||||
onClose();
|
||||
} catch {
|
||||
toast.error(t("medications.detail.bestand.addFailed"));
|
||||
} catch (failure) {
|
||||
showErrorToast(t("medications.detail.bestand.addFailed"), {
|
||||
cause: failure,
|
||||
});
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
@@ -399,8 +402,10 @@ export function AdjustInventoryDialog({
|
||||
await invalidateSupplyQueries(queryClient, medicationId);
|
||||
toast.success(t("medications.detail.bestand.adjustSuccess"));
|
||||
onClose();
|
||||
} catch {
|
||||
toast.error(t("medications.detail.bestand.adjustFailed"));
|
||||
} catch (failure) {
|
||||
showErrorToast(t("medications.detail.bestand.adjustFailed"), {
|
||||
cause: failure,
|
||||
});
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
@@ -573,8 +578,10 @@ export function PackagingDialog({
|
||||
]);
|
||||
toast.success(t("medications.detail.bestand.packagingSuccess"));
|
||||
onClose();
|
||||
} catch {
|
||||
toast.error(t("medications.detail.bestand.packagingFailed"));
|
||||
} catch (failure) {
|
||||
showErrorToast(t("medications.detail.bestand.packagingFailed"), {
|
||||
cause: failure,
|
||||
});
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
|
||||
@@ -48,6 +48,7 @@ import { useState } from "react";
|
||||
import Link from "next/link";
|
||||
import { useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import { toast } from "sonner";
|
||||
import { showErrorToast } from "@/lib/errors/error-toast";
|
||||
import { Loader2, PackageOpen, Plus } from "lucide-react";
|
||||
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
@@ -129,8 +130,10 @@ export function InventorySection({
|
||||
await apiDelete(`/api/medications/${medicationId}/inventory/${item.id}`);
|
||||
await invalidateSupplyQueries(queryClient, medicationId);
|
||||
toast.success(t("medications.detail.bestand.deleteSuccess"));
|
||||
} catch {
|
||||
toast.error(t("medications.detail.bestand.deleteFailed"));
|
||||
} catch (failure) {
|
||||
showErrorToast(t("medications.detail.bestand.deleteFailed"), {
|
||||
cause: failure,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -23,6 +23,7 @@
|
||||
import { useState } from "react";
|
||||
import { useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import { toast } from "sonner";
|
||||
import { showErrorToast } from "@/lib/errors/error-toast";
|
||||
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Switch } from "@/components/ui/switch";
|
||||
@@ -137,9 +138,11 @@ export function NotificationsBody({
|
||||
? t("medications.detail.notifications.enabledToast")
|
||||
: t("medications.detail.notifications.disabledToast"),
|
||||
);
|
||||
} catch {
|
||||
} catch (failure) {
|
||||
setLocalEnabled(previous);
|
||||
toast.error(t("medications.detail.notifications.toggleFailed"));
|
||||
showErrorToast(t("medications.detail.notifications.toggleFailed"), {
|
||||
cause: failure,
|
||||
});
|
||||
} finally {
|
||||
setSubmitting(false);
|
||||
}
|
||||
|
||||
@@ -28,6 +28,7 @@
|
||||
import { useMemo, useState } from "react";
|
||||
import { useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import { toast } from "sonner";
|
||||
import { showErrorToast } from "@/lib/errors/error-toast";
|
||||
import { Loader2 } from "lucide-react";
|
||||
|
||||
import { Button } from "@/components/ui/button";
|
||||
@@ -227,10 +228,11 @@ function PhaseConfigForm({
|
||||
toast.success(t("medications.phaseSaved"));
|
||||
onClose();
|
||||
} catch (err) {
|
||||
toast.error(
|
||||
showErrorToast(
|
||||
err instanceof ApiError && err.message
|
||||
? err.message
|
||||
: t("medications.detail.phases.saveFailed"),
|
||||
{ cause: err },
|
||||
);
|
||||
} finally {
|
||||
setSaving(false);
|
||||
|
||||
@@ -21,6 +21,7 @@
|
||||
import { useState } from "react";
|
||||
import { useQueryClient } from "@tanstack/react-query";
|
||||
import { toast } from "sonner";
|
||||
import { showErrorToast } from "@/lib/errors/error-toast";
|
||||
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
@@ -124,8 +125,10 @@ export function GraceRow({
|
||||
await apiPut(`/api/medications/${medicationId}`, body);
|
||||
await invalidateKeys(queryClient, medicationDependentKeys);
|
||||
toast.success(t("medications.detail.settings.grace.saved"));
|
||||
} catch {
|
||||
toast.error(t("medications.detail.settings.grace.failed"));
|
||||
} catch (failure) {
|
||||
showErrorToast(t("medications.detail.settings.grace.failed"), {
|
||||
cause: failure,
|
||||
});
|
||||
} finally {
|
||||
setGraceBusy(false);
|
||||
}
|
||||
@@ -207,8 +210,10 @@ export function DrugCodingRow({
|
||||
});
|
||||
await invalidateKeys(queryClient, medicationDependentKeys);
|
||||
toast.success(t("medications.detail.settings.codes.saved"));
|
||||
} catch {
|
||||
toast.error(t("medications.detail.settings.codes.failed"));
|
||||
} catch (failure) {
|
||||
showErrorToast(t("medications.detail.settings.codes.failed"), {
|
||||
cause: failure,
|
||||
});
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
|
||||
@@ -9,6 +9,7 @@ import {
|
||||
useQueryClient,
|
||||
} from "@tanstack/react-query";
|
||||
import { toast } from "sonner";
|
||||
import { showErrorToast } from "@/lib/errors/error-toast";
|
||||
import { Loader2, Plus, Trash2 } from "lucide-react";
|
||||
|
||||
import { Button } from "@/components/ui/button";
|
||||
@@ -215,8 +216,10 @@ export function SideEffectsSection({ medicationId }: SideEffectsSectionProps) {
|
||||
},
|
||||
// v1.16.4 — a failed delete used to fail silently, leaving the row
|
||||
// in place with no signal that the request was rejected.
|
||||
onError: () => {
|
||||
toast.error(t("medications.sideEffects.deleteError"));
|
||||
onError: (failure) => {
|
||||
showErrorToast(t("medications.sideEffects.deleteError"), {
|
||||
cause: failure,
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
|
||||
@@ -47,6 +47,7 @@
|
||||
|
||||
import type { QueryClient } from "@tanstack/react-query";
|
||||
import { toast } from "sonner";
|
||||
import { showErrorToast } from "@/lib/errors/error-toast";
|
||||
|
||||
import { apiPost } from "@/lib/api/api-fetch";
|
||||
import { intakeToastOptions } from "@/components/medications/use-medication-intake";
|
||||
@@ -286,9 +287,9 @@ export async function runTakeAllDue(deps: {
|
||||
toast.success(message);
|
||||
}
|
||||
} else if (taken > 0) {
|
||||
toast.error(t("medications.takeAllDue.partialToast", { taken, failed }));
|
||||
showErrorToast(t("medications.takeAllDue.partialToast", { taken, failed }));
|
||||
} else {
|
||||
toast.error(t("medications.takeAllDue.failedToast"));
|
||||
showErrorToast(t("medications.takeAllDue.failedToast"));
|
||||
}
|
||||
|
||||
return { taken, failed };
|
||||
|
||||
@@ -4,6 +4,7 @@ import { useActiveRecordName } from "@/hooks/use-record-capabilities";
|
||||
import { useState } from "react";
|
||||
import { useQueryClient, type QueryClient } from "@tanstack/react-query";
|
||||
import { toast } from "sonner";
|
||||
import { showErrorToast } from "@/lib/errors/error-toast";
|
||||
|
||||
import { useTranslations } from "@/lib/i18n/context";
|
||||
import { invalidateMedicationReads } from "@/lib/query-keys";
|
||||
@@ -203,8 +204,11 @@ export async function runRecordIntake(deps: {
|
||||
);
|
||||
await invalidateMedicationReads(queryClient);
|
||||
onRecorded?.(eventId, skipped);
|
||||
} catch {
|
||||
toast.error(t("medications.intakeToastFailed", { name: medication.name }));
|
||||
} catch (failure) {
|
||||
showErrorToast(
|
||||
t("medications.intakeToastFailed", { name: medication.name }),
|
||||
{ cause: failure },
|
||||
);
|
||||
} finally {
|
||||
setIntakeLoading(null);
|
||||
}
|
||||
@@ -300,8 +304,11 @@ export async function runLogIntake(deps: {
|
||||
toast.success(message);
|
||||
}
|
||||
return true;
|
||||
} catch {
|
||||
toast.error(t("medications.intakeToastFailed", { name: medication.name }));
|
||||
} catch (failure) {
|
||||
showErrorToast(
|
||||
t("medications.intakeToastFailed", { name: medication.name }),
|
||||
{ cause: failure },
|
||||
);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
@@ -321,8 +328,8 @@ export async function runUndoIntake(deps: {
|
||||
await apiDelete(`/api/medications/${medication.id}/intake/${eventId}`);
|
||||
await invalidateMedicationReads(queryClient);
|
||||
toast.success(t("medications.intakeUndone"));
|
||||
} catch {
|
||||
toast.error(t("medications.intakeUndoFailed"));
|
||||
} catch (failure) {
|
||||
showErrorToast(t("medications.intakeUndoFailed"), { cause: failure });
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -193,9 +193,11 @@ describe("MoodList — level A on the edit path (v1.37)", () => {
|
||||
|
||||
describe("MoodList — delete failure toast (v1.11.5)", () => {
|
||||
it("surfaces a toast on a rejected delete instead of failing silently", () => {
|
||||
expect(listSrc).toContain('import { toast } from "sonner"');
|
||||
expect(listSrc).toContain(
|
||||
'import { showErrorToast } from "@/lib/errors/error-toast"',
|
||||
);
|
||||
expect(listSrc).toMatch(
|
||||
/deleteMutation = useMutation\(\{[\s\S]*onError: \(\) => \{[\s\S]*toast\.error\(t\("mood\.deleteError"\)\)/,
|
||||
/deleteMutation = useMutation\(\{[\s\S]*onError: \((\w+)\) => \{[\s\S]*showErrorToast\(t\("mood\.deleteError"\), \{ cause: \1 \}\)/,
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -4,6 +4,7 @@ import { useState } from "react";
|
||||
import { useQueryClient } from "@tanstack/react-query";
|
||||
import { ArchiveRestore, Trash2 } from "lucide-react";
|
||||
import { toast } from "sonner";
|
||||
import { showErrorToast } from "@/lib/errors/error-toast";
|
||||
|
||||
import {
|
||||
AlertDialog,
|
||||
@@ -70,9 +71,12 @@ export function ArchivedTagsCard({ catalog }: ArchivedTagsCardProps) {
|
||||
} catch (err) {
|
||||
rollback();
|
||||
if (err instanceof ApiError && err.status === 422) {
|
||||
toast.error(t("mood.manage.tagLimitReached"));
|
||||
showErrorToast(t("mood.manage.tagLimitReached"), { cause: err });
|
||||
} else {
|
||||
toast.error(err instanceof ApiError ? err.message : t("common.error"));
|
||||
showErrorToast(
|
||||
err instanceof ApiError ? err.message : t("common.error"),
|
||||
{ cause: err },
|
||||
);
|
||||
}
|
||||
} finally {
|
||||
void invalidateMoodTagCaches(queryClient);
|
||||
@@ -89,7 +93,10 @@ export function ArchivedTagsCard({ catalog }: ArchivedTagsCardProps) {
|
||||
toast.success(t("mood.manage.purged"));
|
||||
} catch (err) {
|
||||
rollback();
|
||||
toast.error(err instanceof ApiError ? err.message : t("common.error"));
|
||||
showErrorToast(
|
||||
err instanceof ApiError ? err.message : t("common.error"),
|
||||
{ cause: err },
|
||||
);
|
||||
} finally {
|
||||
void invalidateMoodTagCaches(queryClient);
|
||||
}
|
||||
|
||||
@@ -4,6 +4,7 @@ import { useId, useState } from "react";
|
||||
import { useQueryClient } from "@tanstack/react-query";
|
||||
import { Loader2 } from "lucide-react";
|
||||
import { toast } from "sonner";
|
||||
import { showErrorToast } from "@/lib/errors/error-toast";
|
||||
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
@@ -141,9 +142,12 @@ export function TagEditorSheet({
|
||||
void invalidateMoodTagCaches(queryClient);
|
||||
} catch (err) {
|
||||
if (err instanceof ApiError && err.status === 422) {
|
||||
toast.error(t("mood.manage.tagLimitReached"));
|
||||
showErrorToast(t("mood.manage.tagLimitReached"), { cause: err });
|
||||
} else {
|
||||
toast.error(err instanceof ApiError ? err.message : t("common.error"));
|
||||
showErrorToast(
|
||||
err instanceof ApiError ? err.message : t("common.error"),
|
||||
{ cause: err },
|
||||
);
|
||||
}
|
||||
} finally {
|
||||
setSaving(false);
|
||||
|
||||
@@ -30,6 +30,7 @@ import {
|
||||
} from "@dnd-kit/sortable";
|
||||
import { CSS } from "@dnd-kit/utilities";
|
||||
import { toast } from "sonner";
|
||||
import { showErrorToast } from "@/lib/errors/error-toast";
|
||||
|
||||
import {
|
||||
AlertDialog,
|
||||
@@ -173,7 +174,10 @@ export function TagGroupsCard({ catalog }: TagGroupsCardProps) {
|
||||
toast.message(t("common.conflictReloaded"));
|
||||
return false;
|
||||
}
|
||||
toast.error(err instanceof ApiError ? err.message : t("common.error"));
|
||||
showErrorToast(
|
||||
err instanceof ApiError ? err.message : t("common.error"),
|
||||
{ cause: err },
|
||||
);
|
||||
return false;
|
||||
} finally {
|
||||
void invalidateMoodTagCaches(queryClient);
|
||||
@@ -354,8 +358,9 @@ export function TagGroupsCard({ catalog }: TagGroupsCardProps) {
|
||||
);
|
||||
toast.success(t("mood.manage.groupDeleted"));
|
||||
} catch (err) {
|
||||
toast.error(
|
||||
showErrorToast(
|
||||
err instanceof ApiError ? err.message : t("common.error"),
|
||||
{ cause: err },
|
||||
);
|
||||
} finally {
|
||||
void invalidateMoodTagCaches(queryClient);
|
||||
@@ -554,9 +559,12 @@ function GroupEditorSheet({
|
||||
void invalidateMoodTagCaches(queryClient);
|
||||
} catch (err) {
|
||||
if (err instanceof ApiError && err.status === 422) {
|
||||
toast.error(t("mood.manage.groupLimitReached"));
|
||||
showErrorToast(t("mood.manage.groupLimitReached"), { cause: err });
|
||||
} else {
|
||||
toast.error(err instanceof ApiError ? err.message : t("common.error"));
|
||||
showErrorToast(
|
||||
err instanceof ApiError ? err.message : t("common.error"),
|
||||
{ cause: err },
|
||||
);
|
||||
}
|
||||
} finally {
|
||||
setSaving(false);
|
||||
|
||||
@@ -33,6 +33,7 @@ import {
|
||||
} from "@dnd-kit/sortable";
|
||||
import { CSS } from "@dnd-kit/utilities";
|
||||
import { toast } from "sonner";
|
||||
import { showErrorToast } from "@/lib/errors/error-toast";
|
||||
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Button } from "@/components/ui/button";
|
||||
@@ -166,7 +167,10 @@ export function TagManagerCard({ catalog }: TagManagerCardProps) {
|
||||
toast.message(t("common.conflictReloaded"));
|
||||
return false;
|
||||
}
|
||||
toast.error(err instanceof ApiError ? err.message : t("common.error"));
|
||||
showErrorToast(
|
||||
err instanceof ApiError ? err.message : t("common.error"),
|
||||
{ cause: err },
|
||||
);
|
||||
return false;
|
||||
} finally {
|
||||
void invalidateMoodTagCaches(queryClient);
|
||||
@@ -238,7 +242,10 @@ export function TagManagerCard({ catalog }: TagManagerCardProps) {
|
||||
}
|
||||
} catch (err) {
|
||||
rollback();
|
||||
toast.error(err instanceof ApiError ? err.message : t("common.error"));
|
||||
showErrorToast(
|
||||
err instanceof ApiError ? err.message : t("common.error"),
|
||||
{ cause: err },
|
||||
);
|
||||
} finally {
|
||||
void invalidateMoodTagCaches(queryClient);
|
||||
}
|
||||
@@ -275,7 +282,10 @@ export function TagManagerCard({ catalog }: TagManagerCardProps) {
|
||||
toast.message(t("common.conflictReloaded"));
|
||||
return;
|
||||
}
|
||||
toast.error(err instanceof ApiError ? err.message : t("common.error"));
|
||||
showErrorToast(
|
||||
err instanceof ApiError ? err.message : t("common.error"),
|
||||
{ cause: err },
|
||||
);
|
||||
} finally {
|
||||
void invalidateMoodTagCaches(queryClient);
|
||||
}
|
||||
|
||||
@@ -69,6 +69,7 @@ import {
|
||||
// reads newest-first by default; every other column opens ascending.
|
||||
const MOOD_DESC_COLUMNS: ReadonlySet<string> = new Set(["moodLoggedAt"]);
|
||||
import { toast } from "sonner";
|
||||
import { showErrorToast } from "@/lib/errors/error-toast";
|
||||
import { formatDateTime } from "@/lib/format";
|
||||
import { DayLink } from "@/components/day/day-link";
|
||||
import { useTranslations, useFormatters } from "@/lib/i18n/context";
|
||||
@@ -384,8 +385,8 @@ export function MoodList({ onAddFirst, onEmptyChange }: MoodListProps = {}) {
|
||||
await invalidateKeys(queryClient, moodDependentKeys);
|
||||
await refetchInactiveDailyReads(queryClient);
|
||||
toast.success(t("mood.restoredToast"));
|
||||
} catch {
|
||||
toast.error(t("mood.restoreError"));
|
||||
} catch (failure) {
|
||||
showErrorToast(t("mood.restoreError"), { cause: failure });
|
||||
}
|
||||
},
|
||||
[queryClient, t],
|
||||
@@ -409,8 +410,8 @@ export function MoodList({ onAddFirst, onEmptyChange }: MoodListProps = {}) {
|
||||
// leaving the row in place with no signal that the request was rejected.
|
||||
// Surface the failure so the user knows to retry. Mirrors the v1.11.3
|
||||
// medication-card silent-failure fix.
|
||||
onError: () => {
|
||||
toast.error(t("mood.deleteError"));
|
||||
onError: (failure) => {
|
||||
showErrorToast(t("mood.deleteError"), { cause: failure });
|
||||
},
|
||||
});
|
||||
|
||||
@@ -439,8 +440,8 @@ export function MoodList({ onAddFirst, onEmptyChange }: MoodListProps = {}) {
|
||||
},
|
||||
);
|
||||
},
|
||||
onError: () => {
|
||||
toast.error(t("mood.bulkDeleteError"));
|
||||
onError: (failure) => {
|
||||
showErrorToast(t("mood.bulkDeleteError"), { cause: failure });
|
||||
},
|
||||
});
|
||||
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
|
||||
import { useEffect, useState } from "react";
|
||||
import { toast } from "sonner";
|
||||
import { showErrorToast } from "@/lib/errors/error-toast";
|
||||
|
||||
import {
|
||||
BaselineFields,
|
||||
@@ -234,7 +235,9 @@ export function BaselineForm({
|
||||
await onConfirmed();
|
||||
},
|
||||
(err) =>
|
||||
toast.error(localizedApiError(err, t, "onboarding.errorGeneric")),
|
||||
showErrorToast(localizedApiError(err, t, "onboarding.errorGeneric"), {
|
||||
cause: err,
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -4,6 +4,7 @@ import { useRef, useState } from "react";
|
||||
import Link from "next/link";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { toast } from "sonner";
|
||||
import { showErrorToast } from "@/lib/errors/error-toast";
|
||||
|
||||
import { BaselineForm } from "@/components/onboarding/baseline-form";
|
||||
import { StepHeading } from "@/components/onboarding/step-heading";
|
||||
@@ -189,7 +190,9 @@ export function ConfirmScreen({
|
||||
/** The managed arm's two buttons: nothing above them reports a failure. */
|
||||
function finishReporting(managedRecordId?: string) {
|
||||
void finish(managedRecordId).catch((err) => {
|
||||
toast.error(localizedApiError(err, t, "onboarding.errorGeneric"));
|
||||
showErrorToast(localizedApiError(err, t, "onboarding.errorGeneric"), {
|
||||
cause: err,
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -6,6 +6,7 @@ import { useEffect, useRef, useState } from "react";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import { toast } from "sonner";
|
||||
import { showErrorToast } from "@/lib/errors/error-toast";
|
||||
import {
|
||||
CheckCircle2,
|
||||
Compass,
|
||||
@@ -171,10 +172,10 @@ export function DoneScreen({ state }: { state: OnboardingStateDto }) {
|
||||
queryKey: queryKeys.aiConsentReceipt("ai_full"),
|
||||
});
|
||||
},
|
||||
onError: () => {
|
||||
onError: (failure) => {
|
||||
// The button stays. A failed grant is a failed grant; the screen must
|
||||
// not read as though consent were on file.
|
||||
toast.error(t("onboarding.ai.offer.error"));
|
||||
showErrorToast(t("onboarding.ai.offer.error"), { cause: failure });
|
||||
},
|
||||
});
|
||||
|
||||
|
||||
@@ -5,6 +5,7 @@ import Link from "next/link";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { toast } from "sonner";
|
||||
import { showErrorToast } from "@/lib/errors/error-toast";
|
||||
import { Loader2 } from "lucide-react";
|
||||
|
||||
import { MeasurementForm } from "@/components/measurements/measurement-form";
|
||||
@@ -109,7 +110,9 @@ export function FirstResultScreen({ state }: { state: OnboardingStateDto }) {
|
||||
});
|
||||
setCompleted(true);
|
||||
} catch (err) {
|
||||
toast.error(localizedApiError(err, t, "onboarding.errorGeneric"));
|
||||
showErrorToast(localizedApiError(err, t, "onboarding.errorGeneric"), {
|
||||
cause: err,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -118,7 +121,9 @@ export function FirstResultScreen({ state }: { state: OnboardingStateDto }) {
|
||||
await answer.mutateAsync({ step: "first-result", status: "skipped" });
|
||||
router.push(screenHref("done"));
|
||||
} catch (err) {
|
||||
toast.error(localizedApiError(err, t, "onboarding.errorGeneric"));
|
||||
showErrorToast(localizedApiError(err, t, "onboarding.errorGeneric"), {
|
||||
cause: err,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
import { useState } from "react";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { toast } from "sonner";
|
||||
import { showErrorToast } from "@/lib/errors/error-toast";
|
||||
import {
|
||||
Activity,
|
||||
Brain,
|
||||
@@ -110,7 +111,9 @@ export function QuestionScreen({
|
||||
const written = await answer.mutateAsync(input);
|
||||
router.push(screenHref(nextScreen(written, step) ?? "confirm"));
|
||||
} catch (err) {
|
||||
toast.error(localizedApiError(err, t, "onboarding.errorGeneric"));
|
||||
showErrorToast(localizedApiError(err, t, "onboarding.errorGeneric"), {
|
||||
cause: err,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
import { useState } from "react";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { toast } from "sonner";
|
||||
import { showErrorToast } from "@/lib/errors/error-toast";
|
||||
|
||||
import { ChoiceChip } from "@/components/onboarding/choice-chip";
|
||||
import { StepActions } from "@/components/onboarding/step-actions";
|
||||
@@ -68,7 +69,9 @@ export function UnitsScreen({ state }: { state: OnboardingStateDto }) {
|
||||
const written = await answer.mutateAsync(input);
|
||||
router.push(screenHref(nextScreen(written, "units") ?? "confirm"));
|
||||
} catch (err) {
|
||||
toast.error(localizedApiError(err, t, "onboarding.errorGeneric"));
|
||||
showErrorToast(localizedApiError(err, t, "onboarding.errorGeneric"), {
|
||||
cause: err,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -5,6 +5,7 @@ import Link from "next/link";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { useMutation } from "@tanstack/react-query";
|
||||
import { toast } from "sonner";
|
||||
import { showErrorToast } from "@/lib/errors/error-toast";
|
||||
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Checkbox } from "@/components/ui/checkbox";
|
||||
@@ -70,7 +71,9 @@ export function WelcomeScreen({ variant }: { variant: "fresh" | "again" }) {
|
||||
acknowledge.isPending || complete.isPending || restart.isPending;
|
||||
|
||||
const fail = (err: unknown) =>
|
||||
toast.error(localizedApiError(err, t, "onboarding.errorGeneric"));
|
||||
showErrorToast(localizedApiError(err, t, "onboarding.errorGeneric"), {
|
||||
cause: err,
|
||||
});
|
||||
|
||||
async function setUp() {
|
||||
if (pending || !acknowledged) return;
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
"use client";
|
||||
|
||||
import { toast } from "sonner";
|
||||
import { showErrorToast } from "@/lib/errors/error-toast";
|
||||
|
||||
import type { WrittenOutcome } from "@/lib/outcome/written-outcome";
|
||||
|
||||
@@ -25,7 +26,7 @@ export function toastWrittenOutcome(
|
||||
toast.warning(message);
|
||||
return;
|
||||
case "failed":
|
||||
toast.error(message);
|
||||
showErrorToast(message);
|
||||
return;
|
||||
case "empty":
|
||||
toast.info(message);
|
||||
|
||||
@@ -14,6 +14,7 @@
|
||||
import { ContactRound, Pencil, Plus, Search, Trash2 } from "lucide-react";
|
||||
import { useMemo, useState } from "react";
|
||||
import { toast } from "sonner";
|
||||
import { showErrorToast } from "@/lib/errors/error-toast";
|
||||
|
||||
import {
|
||||
AlertDialog,
|
||||
@@ -256,7 +257,10 @@ export function PractitionerList({ enabled = true }: { enabled?: boolean }) {
|
||||
const id = deleting.id;
|
||||
setDeleting(null);
|
||||
remove.mutate(id, {
|
||||
onError: () => toast.error(t("practitioners.deleteFailed")),
|
||||
onError: (failure) =>
|
||||
showErrorToast(t("practitioners.deleteFailed"), {
|
||||
cause: failure,
|
||||
}),
|
||||
});
|
||||
}}
|
||||
>
|
||||
|
||||
@@ -14,6 +14,7 @@
|
||||
*/
|
||||
import { useState } from "react";
|
||||
import { toast } from "sonner";
|
||||
import { showErrorToast } from "@/lib/errors/error-toast";
|
||||
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { FieldGroup } from "@/components/ui/field-group";
|
||||
@@ -107,7 +108,7 @@ export function PractitionerSheet({
|
||||
const message =
|
||||
err instanceof Error ? err.message : t("practitioners.saveFailed");
|
||||
setError(message);
|
||||
toast.error(message);
|
||||
showErrorToast(message, { cause: err });
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
@@ -18,6 +18,7 @@ import {
|
||||
import { I18nProvider, useTranslations } from "@/lib/i18n/context";
|
||||
import type { Locale } from "@/lib/i18n/config";
|
||||
import { toast } from "sonner";
|
||||
import { showErrorToast } from "@/lib/errors/error-toast";
|
||||
import { Toaster } from "@/components/ui/sonner";
|
||||
import { applyThemeColor } from "@/lib/pwa/theme-color";
|
||||
import { KeyboardInsetBridge } from "@/components/keyboard-inset-bridge";
|
||||
@@ -370,7 +371,7 @@ function OfflineMutationToaster() {
|
||||
typeof navigator !== "undefined" &&
|
||||
navigator.onLine === false
|
||||
) {
|
||||
toast.error(t("offlineBanner.saveFailed"), {
|
||||
showErrorToast(t("offlineBanner.saveFailed"), {
|
||||
id: "offline-mutation-failed",
|
||||
});
|
||||
}
|
||||
|
||||
@@ -30,6 +30,7 @@ import Link from "next/link";
|
||||
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import { Loader2, MessageCircleQuestion, Trash2 } from "lucide-react";
|
||||
import { toast } from "sonner";
|
||||
import { showErrorToast } from "@/lib/errors/error-toast";
|
||||
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { SettingsCardActions } from "@/components/settings/_card-actions";
|
||||
@@ -116,7 +117,7 @@ export function AboutMeNoteManager() {
|
||||
toast.message(t("common.conflictReloaded"));
|
||||
return;
|
||||
}
|
||||
toast.error(t("settings.ai.aboutMe.saveError"));
|
||||
showErrorToast(t("settings.ai.aboutMe.saveError"), { cause: err });
|
||||
},
|
||||
});
|
||||
|
||||
|
||||
@@ -4,6 +4,7 @@ import { useState } from "react";
|
||||
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import { Loader2 } from "lucide-react";
|
||||
import { toast } from "sonner";
|
||||
import { showErrorToast } from "@/lib/errors/error-toast";
|
||||
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { SettingsCardActions } from "@/components/settings/_card-actions";
|
||||
@@ -88,7 +89,7 @@ export function AiProfileInclusionManager() {
|
||||
if (isConflict(error)) {
|
||||
queryClient.invalidateQueries({ queryKey: queryKeys.coachAboutMe() });
|
||||
}
|
||||
toast.error(t("records.aiInclusion.saveError"));
|
||||
showErrorToast(t("records.aiInclusion.saveError"), { cause: error });
|
||||
},
|
||||
});
|
||||
|
||||
|
||||
@@ -25,6 +25,7 @@ import { useState, type ReactNode } from "react";
|
||||
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import { Loader2 } from "lucide-react";
|
||||
import { toast } from "sonner";
|
||||
import { showErrorToast } from "@/lib/errors/error-toast";
|
||||
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { SettingsCardActions } from "@/components/settings/_card-actions";
|
||||
@@ -98,7 +99,7 @@ export function AllergyFreeTextNote({
|
||||
toast.message(t("common.conflictReloaded"));
|
||||
return;
|
||||
}
|
||||
toast.error(t("records.allergies.freeText.saveError"));
|
||||
showErrorToast(t("records.allergies.freeText.saveError"), { cause: err });
|
||||
},
|
||||
});
|
||||
|
||||
|
||||
@@ -4,6 +4,7 @@ import { useState, type ReactNode } from "react";
|
||||
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import { Pencil, Plus, ShieldAlert } from "lucide-react";
|
||||
import { toast } from "sonner";
|
||||
import { showErrorToast } from "@/lib/errors/error-toast";
|
||||
|
||||
import { DeleteButton } from "@/components/data-list";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
@@ -53,7 +54,8 @@ export function AllergyManager({
|
||||
toast.success(t("records.allergies.deletedToast"));
|
||||
queryClient.invalidateQueries({ queryKey: queryKeys.allergies() });
|
||||
},
|
||||
onError: () => toast.error(t("records.allergies.deleteError")),
|
||||
onError: (failure) =>
|
||||
showErrorToast(t("records.allergies.deleteError"), { cause: failure }),
|
||||
});
|
||||
|
||||
function openNew() {
|
||||
|
||||
@@ -24,6 +24,7 @@ import { useState } from "react";
|
||||
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import { Loader2 } from "lucide-react";
|
||||
import { toast } from "sonner";
|
||||
import { showErrorToast } from "@/lib/errors/error-toast";
|
||||
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { SettingsCardActions } from "@/components/settings/_card-actions";
|
||||
@@ -89,8 +90,8 @@ export function ConditionsManager() {
|
||||
queryKey: queryKeys.coachAboutMeQuestions(),
|
||||
});
|
||||
},
|
||||
onError: () => {
|
||||
toast.error(t("records.conditions.saveError"));
|
||||
onError: (failure) => {
|
||||
showErrorToast(t("records.conditions.saveError"), { cause: failure });
|
||||
},
|
||||
});
|
||||
|
||||
|
||||
Loaded 100 of 138 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user