UX optimization
This commit is contained in:
@@ -0,0 +1,200 @@
|
||||
/**
|
||||
* Maps raw errors to user-friendly Swedish messages.
|
||||
*
|
||||
* Priority chain:
|
||||
* 1. Zod validation field errors
|
||||
* 2. Postgres error code map
|
||||
* 3. HTTP status code map
|
||||
* 4. Context-specific fallback
|
||||
* 5. Generic fallback
|
||||
*/
|
||||
|
||||
type ErrorContext =
|
||||
| 'invoice'
|
||||
| 'supplier_invoice'
|
||||
| 'customer'
|
||||
| 'supplier'
|
||||
| 'transaction'
|
||||
| 'journal_entry'
|
||||
| 'settings'
|
||||
| 'auth'
|
||||
|
||||
interface GetErrorMessageOptions {
|
||||
context?: ErrorContext
|
||||
statusCode?: number
|
||||
}
|
||||
|
||||
// Postgres error codes -> Swedish messages
|
||||
const POSTGRES_ERROR_MAP: Record<string, string> = {
|
||||
'23505': 'En post med samma uppgifter finns redan.',
|
||||
'23503': 'Posten kan inte ändras eftersom den refereras av annan data.',
|
||||
'23502': 'Ett obligatoriskt fält saknas.',
|
||||
'42501': 'Du har inte behörighet att utföra denna åtgärd.',
|
||||
'42P01': 'Resursen kunde inte hittas.',
|
||||
'23514': 'Värdet uppfyller inte de tillåtna kraven.',
|
||||
'40001': 'En annan ändring pågick samtidigt. Försök igen.',
|
||||
'40P01': 'En konflikt uppstod. Försök igen.',
|
||||
'22P02': 'Ogiltigt värde angavs.',
|
||||
'22003': 'Värdet är utanför tillåtet intervall.',
|
||||
}
|
||||
|
||||
// HTTP status codes -> Swedish messages
|
||||
const HTTP_STATUS_MAP: Record<number, string> = {
|
||||
400: 'Förfrågan innehåller ogiltiga uppgifter.',
|
||||
401: 'Din session har gått ut. Logga in igen.',
|
||||
403: 'Du har inte behörighet att utföra denna åtgärd.',
|
||||
404: 'Resursen kunde inte hittas.',
|
||||
409: 'En konflikt uppstod. Ladda om sidan och försök igen.',
|
||||
422: 'Uppgifterna kunde inte bearbetas. Kontrollera fälten och försök igen.',
|
||||
429: 'För många förfrågningar. Vänta en stund och försök igen.',
|
||||
500: 'Ett oväntat serverfel uppstod. Försök igen senare.',
|
||||
502: 'Servern är tillfälligt otillgänglig. Försök igen om en stund.',
|
||||
503: 'Tjänsten är tillfälligt otillgänglig. Försök igen om en stund.',
|
||||
}
|
||||
|
||||
// Context-specific fallbacks
|
||||
const CONTEXT_FALLBACKS: Record<ErrorContext, string> = {
|
||||
invoice: 'Kunde inte hantera fakturan. Försök igen.',
|
||||
supplier_invoice: 'Kunde inte hantera leverantörsfakturan. Försök igen.',
|
||||
customer: 'Kunde inte hantera kunden. Försök igen.',
|
||||
supplier: 'Kunde inte hantera leverantören. Försök igen.',
|
||||
transaction: 'Kunde inte hantera transaktionen. Försök igen.',
|
||||
journal_entry: 'Kunde inte hantera verifikationen. Försök igen.',
|
||||
settings: 'Kunde inte spara inställningarna. Försök igen.',
|
||||
auth: 'Ett fel uppstod vid inloggningen. Försök igen.',
|
||||
}
|
||||
|
||||
const GENERIC_FALLBACK = 'Något gick fel. Försök igen.'
|
||||
|
||||
/**
|
||||
* Simple heuristic to detect already-translated Swedish messages.
|
||||
* If the message contains common Swedish words/patterns, pass it through.
|
||||
*/
|
||||
function isSwedishUserMessage(message: string): boolean {
|
||||
const swedishPatterns = [
|
||||
/kunde inte/i,
|
||||
/försök igen/i,
|
||||
/ogiltigt?/i,
|
||||
/saknas/i,
|
||||
/måste/i,
|
||||
/redan finns/i,
|
||||
/gick fel/i,
|
||||
/behörighet/i,
|
||||
/session/i,
|
||||
/förfrågan/i,
|
||||
/obligatorisk/i,
|
||||
]
|
||||
return swedishPatterns.some((p) => p.test(message))
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract a user-friendly message from a Zod validation error shape.
|
||||
* Returns null if the error is not a Zod error.
|
||||
*/
|
||||
function tryParseZodErrors(error: unknown): string | null {
|
||||
if (typeof error !== 'object' || error === null) return null
|
||||
|
||||
const obj = error as Record<string, unknown>
|
||||
|
||||
// Check for Zod-style field errors: { fieldName: ["message"] } or { issues: [...] }
|
||||
if (Array.isArray(obj.issues)) {
|
||||
const issues = obj.issues as Array<{ message?: string; path?: string[] }>
|
||||
const messages = issues
|
||||
.slice(0, 3)
|
||||
.map((issue) => {
|
||||
const field = issue.path?.join('.') || ''
|
||||
const msg = issue.message || 'ogiltigt värde'
|
||||
return field ? `${field}: ${msg}` : msg
|
||||
})
|
||||
if (messages.length > 0) return messages.join('. ')
|
||||
}
|
||||
|
||||
// Check for { errors: { field: ["msg"] } } shape from validateBody
|
||||
if (typeof obj.errors === 'object' && obj.errors !== null) {
|
||||
const fieldErrors = obj.errors as Record<string, string[]>
|
||||
const messages: string[] = []
|
||||
for (const [field, msgs] of Object.entries(fieldErrors)) {
|
||||
if (Array.isArray(msgs) && msgs.length > 0) {
|
||||
messages.push(`${field}: ${msgs[0]}`)
|
||||
}
|
||||
if (messages.length >= 3) break
|
||||
}
|
||||
if (messages.length > 0) return messages.join('. ')
|
||||
}
|
||||
|
||||
return null
|
||||
}
|
||||
|
||||
/**
|
||||
* Get a user-friendly Swedish error message from a raw error.
|
||||
*
|
||||
* @param error - The raw error. Can be an API response body (object), Error instance, string, or unknown.
|
||||
* @param options - Optional context and HTTP status code.
|
||||
*/
|
||||
export function getErrorMessage(
|
||||
error: unknown,
|
||||
options: GetErrorMessageOptions = {}
|
||||
): string {
|
||||
const { context, statusCode } = options
|
||||
|
||||
// 1. If it's a string, check if it's already Swedish
|
||||
if (typeof error === 'string' && error.trim()) {
|
||||
if (isSwedishUserMessage(error)) return error
|
||||
}
|
||||
|
||||
// 2. If it's an object, try various parsing strategies
|
||||
if (typeof error === 'object' && error !== null) {
|
||||
const obj = error as Record<string, unknown>
|
||||
|
||||
// Try Zod validation errors
|
||||
const zodMessage = tryParseZodErrors(obj)
|
||||
if (zodMessage) return zodMessage
|
||||
|
||||
// Try Postgres error code
|
||||
if (typeof obj.code === 'string' && POSTGRES_ERROR_MAP[obj.code]) {
|
||||
return POSTGRES_ERROR_MAP[obj.code]
|
||||
}
|
||||
|
||||
// Try error.message if it's already a good Swedish message
|
||||
if (typeof obj.error === 'string' && obj.error.trim()) {
|
||||
if (isSwedishUserMessage(obj.error)) return obj.error
|
||||
}
|
||||
|
||||
if (typeof obj.message === 'string' && obj.message.trim()) {
|
||||
if (isSwedishUserMessage(obj.message)) return obj.message
|
||||
}
|
||||
}
|
||||
|
||||
// 3. Error instance
|
||||
if (error instanceof Error && error.message.trim()) {
|
||||
if (isSwedishUserMessage(error.message)) return error.message
|
||||
}
|
||||
|
||||
// 4. HTTP status code map
|
||||
if (statusCode && HTTP_STATUS_MAP[statusCode]) {
|
||||
return HTTP_STATUS_MAP[statusCode]
|
||||
}
|
||||
|
||||
// 5. Context-specific fallback
|
||||
if (context && CONTEXT_FALLBACKS[context]) {
|
||||
return CONTEXT_FALLBACKS[context]
|
||||
}
|
||||
|
||||
// 6. Generic fallback
|
||||
return GENERIC_FALLBACK
|
||||
}
|
||||
|
||||
/**
|
||||
* Helper that parses a Response body and returns a user-friendly error message.
|
||||
*/
|
||||
export async function getResponseErrorMessage(
|
||||
response: Response,
|
||||
context?: ErrorContext
|
||||
): Promise<string> {
|
||||
try {
|
||||
const body = await response.json()
|
||||
return getErrorMessage(body, { context, statusCode: response.status })
|
||||
} catch {
|
||||
return getErrorMessage(null, { context, statusCode: response.status })
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
'use client'
|
||||
|
||||
import { useEffect } from 'react'
|
||||
|
||||
/**
|
||||
* Attaches a `beforeunload` event listener when the form has unsaved changes.
|
||||
* This guards against browser close, tab close, and page refresh.
|
||||
*
|
||||
* Does NOT guard in-app Next.js navigation (App Router has no supported mechanism).
|
||||
*/
|
||||
export function useUnsavedChanges(isDirty: boolean) {
|
||||
useEffect(() => {
|
||||
if (!isDirty) return
|
||||
|
||||
const handler = (e: BeforeUnloadEvent) => {
|
||||
e.preventDefault()
|
||||
}
|
||||
|
||||
window.addEventListener('beforeunload', handler)
|
||||
return () => window.removeEventListener('beforeunload', handler)
|
||||
}, [isDirty])
|
||||
}
|
||||
Reference in New Issue
Block a user