From 75d5bbc84adee978519bd1dca6794cbf5b2c4562 Mon Sep 17 00:00:00 2001 From: "soroush.asadi" Date: Tue, 2 Jun 2026 00:04:48 +0330 Subject: [PATCH] fix(i18n): localize API error messages by code (no more raw English) Error toasts surfaced the raw English backend message. Added an errors namespace (fa/ar/en) keyed by error code + a useApiError() resolver that maps ApiClientError.code to the localized message (fallback to a localized generic). Wired into menu, tables, demo banner, and subscription checkout; hardened getErrorMessage so it never returns the raw backend message. Co-Authored-By: Claude Opus 4.8 --- web/dashboard/messages/ar.json | 27 +++++++++++++++++++ web/dashboard/messages/en.json | 27 +++++++++++++++++++ web/dashboard/messages/fa.json | 27 +++++++++++++++++++ .../src/components/demo/demo-data-banner.tsx | 10 +++---- .../src/components/menu/menu-admin-screen.tsx | 10 +++---- .../subscription/checkout-screen.tsx | 5 ++-- .../src/components/tables/tables-screen.tsx | 10 ++++--- web/dashboard/src/lib/notify.ts | 5 +++- web/dashboard/src/lib/use-api-error.ts | 21 +++++++++++++++ 9 files changed, 123 insertions(+), 19 deletions(-) create mode 100644 web/dashboard/src/lib/use-api-error.ts diff --git a/web/dashboard/messages/ar.json b/web/dashboard/messages/ar.json index e74b5af..23205c1 100644 --- a/web/dashboard/messages/ar.json +++ b/web/dashboard/messages/ar.json @@ -20,6 +20,33 @@ "saved": "تم الحفظ", "errorGeneric": "حدث خطأ. حاول مرة أخرى." }, + "errors": { + "generic": "حدث خطأ. حاول مرة أخرى.", + "REQUEST_FAILED": "فشل الطلب. حاول مرة أخرى.", + "VALIDATION_ERROR": "البيانات المدخلة غير صالحة.", + "FORBIDDEN": "ليس لديك إذن للقيام بذلك.", + "OWNER_REQUIRED": "يمكن لمالك المقهى فقط القيام بذلك.", + "MANAGER_REQUIRED": "يتطلب هذا الإجراء صلاحية المدير.", + "PLAN_LIMIT_REACHED": "لقد بلغت حد باقتك. قم بالترقية للمتابعة.", + "PLAN_FEATURE_DISABLED": "هذه الميزة غير متاحة في باقتك الحالية.", + "NOT_FOUND": "غير موجود.", + "ORDER_NOT_FOUND": "الطلب غير موجود.", + "ITEM_NOT_FOUND": "العنصر غير موجود.", + "ITEM_ALREADY_VOIDED": "تم إلغاء هذا العنصر بالفعل.", + "ORDER_ALREADY_CLOSED": "هذا الطلب مغلق بالفعل.", + "TABLE_OCCUPIED": "هذه الطاولة مشغولة حاليًا.", + "TABLE_CLEANING": "هذه الطاولة قيد التنظيف.", + "TABLE_NOT_FOUND": "الطاولة غير موجودة.", + "TABLE_HAS_OPEN_ORDER": "هذه الطاولة لديها طلب مفتوح ولا يمكن حذفها.", + "TABLE_SECTION_HAS_TABLES": "يحتوي هذا القسم على طاولات ولا يمكن حذفه.", + "BRANCH_NOT_FOUND": "الفرع غير موجود.", + "SECTION_NOT_FOUND": "القسم غير موجود.", + "RATE_LIMITED": "طلبات كثيرة جدًا. يرجى الانتظار قليلاً.", + "SMS_FAILED": "تعذّر إرسال الرسالة القصيرة. حاول مرة أخرى.", + "INVALID_OTP": "رمز التحقق غير صالح أو منتهي الصلاحية.", + "TICKET_CLOSED": "هذه التذكرة مغلقة ولا يمكنها استقبال الرسائل.", + "ALREADY_REGISTERED": "يوجد حساب بالفعل لهذا الرقم. يرجى تسجيل الدخول." + }, "brand": { "name": "ميزي" }, diff --git a/web/dashboard/messages/en.json b/web/dashboard/messages/en.json index a2340cd..751f725 100644 --- a/web/dashboard/messages/en.json +++ b/web/dashboard/messages/en.json @@ -20,6 +20,33 @@ "saved": "Saved", "errorGeneric": "Something went wrong. Please try again." }, + "errors": { + "generic": "Something went wrong. Please try again.", + "REQUEST_FAILED": "Request failed. Please try again.", + "VALIDATION_ERROR": "The information entered is invalid.", + "FORBIDDEN": "You don't have permission to do this.", + "OWNER_REQUIRED": "Only the café owner can do this.", + "MANAGER_REQUIRED": "This action requires manager access.", + "PLAN_LIMIT_REACHED": "You've reached your plan limit. Upgrade to continue.", + "PLAN_FEATURE_DISABLED": "This feature isn't available on your current plan.", + "NOT_FOUND": "Not found.", + "ORDER_NOT_FOUND": "Order not found.", + "ITEM_NOT_FOUND": "Item not found.", + "ITEM_ALREADY_VOIDED": "This item is already voided.", + "ORDER_ALREADY_CLOSED": "This order is already closed.", + "TABLE_OCCUPIED": "This table is currently occupied.", + "TABLE_CLEANING": "This table is being cleaned.", + "TABLE_NOT_FOUND": "Table not found.", + "TABLE_HAS_OPEN_ORDER": "This table has an open order and can't be removed.", + "TABLE_SECTION_HAS_TABLES": "This section has tables and can't be removed.", + "BRANCH_NOT_FOUND": "Branch not found.", + "SECTION_NOT_FOUND": "Section not found.", + "RATE_LIMITED": "Too many requests. Please wait a moment.", + "SMS_FAILED": "Could not send the SMS. Please try again.", + "INVALID_OTP": "Invalid or expired verification code.", + "TICKET_CLOSED": "This ticket is closed and can't receive messages.", + "ALREADY_REGISTERED": "An account already exists for this number. Please sign in." + }, "brand": { "name": "Meezi" }, diff --git a/web/dashboard/messages/fa.json b/web/dashboard/messages/fa.json index 5debd4c..ce456cd 100644 --- a/web/dashboard/messages/fa.json +++ b/web/dashboard/messages/fa.json @@ -20,6 +20,33 @@ "saved": "ذخیره شد", "errorGeneric": "خطایی رخ داد. دوباره تلاش کنید." }, + "errors": { + "generic": "خطایی رخ داد. دوباره تلاش کنید.", + "REQUEST_FAILED": "درخواست ناموفق بود. دوباره تلاش کنید.", + "VALIDATION_ERROR": "اطلاعات واردشده نامعتبر است.", + "FORBIDDEN": "شما اجازه این کار را ندارید.", + "OWNER_REQUIRED": "فقط مالک کافه می‌تواند این کار را انجام دهد.", + "MANAGER_REQUIRED": "این عملیات نیاز به دسترسی مدیر دارد.", + "PLAN_LIMIT_REACHED": "محدودیت پلن شما پر شده است. برای ادامه پلن را ارتقا دهید.", + "PLAN_FEATURE_DISABLED": "این قابلیت در پلن فعلی شما فعال نیست.", + "NOT_FOUND": "مورد موردنظر یافت نشد.", + "ORDER_NOT_FOUND": "سفارش یافت نشد.", + "ITEM_NOT_FOUND": "آیتم یافت نشد.", + "ITEM_ALREADY_VOIDED": "این آیتم قبلاً ابطال شده است.", + "ORDER_ALREADY_CLOSED": "این سفارش بسته شده است.", + "TABLE_OCCUPIED": "این میز هم‌اکنون مشغول است.", + "TABLE_CLEANING": "این میز در حال نظافت است.", + "TABLE_NOT_FOUND": "میز یافت نشد.", + "TABLE_HAS_OPEN_ORDER": "این میز سفارش باز دارد و قابل حذف نیست.", + "TABLE_SECTION_HAS_TABLES": "این بخش دارای میز است و قابل حذف نیست.", + "BRANCH_NOT_FOUND": "شعبه یافت نشد.", + "SECTION_NOT_FOUND": "بخش یافت نشد.", + "RATE_LIMITED": "تعداد درخواست بیش از حد مجاز است. کمی صبر کنید.", + "SMS_FAILED": "ارسال پیامک ناموفق بود. دوباره تلاش کنید.", + "INVALID_OTP": "کد تأیید نامعتبر یا منقضی شده است.", + "TICKET_CLOSED": "این تیکت بسته شده و امکان ارسال پیام ندارد.", + "ALREADY_REGISTERED": "برای این شماره قبلاً حساب ساخته شده است. وارد شوید." + }, "brand": { "name": "میزی" }, diff --git a/web/dashboard/src/components/demo/demo-data-banner.tsx b/web/dashboard/src/components/demo/demo-data-banner.tsx index a5f05b5..0482ae9 100644 --- a/web/dashboard/src/components/demo/demo-data-banner.tsx +++ b/web/dashboard/src/components/demo/demo-data-banner.tsx @@ -3,8 +3,9 @@ import { useState } from "react"; import { useMutation, useQueryClient } from "@tanstack/react-query"; import { Sparkles, Loader2 } from "lucide-react"; -import { ApiClientError, apiPost } from "@/lib/api/client"; +import { apiPost } from "@/lib/api/client"; import { notify } from "@/lib/notify"; +import { useApiError } from "@/lib/use-api-error"; import { useAuthStore } from "@/lib/stores/auth.store"; import { Button } from "@/components/ui/button"; import { cn } from "@/lib/utils"; @@ -27,6 +28,7 @@ export function DemoDataBanner({ invalidateKeys, className }: Props) { const cafeId = useAuthStore((s) => s.user?.cafeId); const role = useAuthStore((s) => s.user?.role); const qc = useQueryClient(); + const apiError = useApiError(); const [done, setDone] = useState(false); const [summary, setSummary] = useState(null); @@ -41,11 +43,7 @@ export function DemoDataBanner({ invalidateKeys, className }: Props) { } }, onError: (err) => { - notify.error( - err instanceof ApiClientError - ? err.message - : "افزودن داده‌های نمونه ناموفق بود. دوباره تلاش کنید." - ); + notify.error(apiError(err)); }, }); diff --git a/web/dashboard/src/components/menu/menu-admin-screen.tsx b/web/dashboard/src/components/menu/menu-admin-screen.tsx index 6c81442..f3afb1e 100644 --- a/web/dashboard/src/components/menu/menu-admin-screen.tsx +++ b/web/dashboard/src/components/menu/menu-admin-screen.tsx @@ -12,8 +12,9 @@ import { CategoryVisual } from "@/components/menu/category-visual"; import { CategoryMediaFields } from "@/components/menu/category-media-fields"; import type { CategoryIconSelection } from "@/components/menu/category-preset-picker"; import { DEFAULT_CATEGORY_ICON_STYLE } from "@/lib/category-icon-presets"; -import { ApiClientError, apiGet, apiPatch, apiPost } from "@/lib/api/client"; +import { apiGet, apiPatch, apiPost } from "@/lib/api/client"; import { notify } from "@/lib/notify"; +import { useApiError } from "@/lib/use-api-error"; import { useAuthStore } from "@/lib/stores/auth.store"; import { useBranchStore } from "@/lib/stores/branch.store"; import { formatCurrency, formatNumber } from "@/lib/format"; @@ -184,11 +185,8 @@ function Modal({ export function MenuAdminScreen() { const t = useTranslations("menuAdmin"); const tCommon = useTranslations("common"); - const tNotify = useTranslations("notify"); - const showError = (err: unknown) => - notify.error( - err instanceof ApiClientError ? err.message : tNotify("errorGeneric") - ); + const apiError = useApiError(); + const showError = (err: unknown) => notify.error(apiError(err)); const isRtl = useIsRtl(); const locale = useLocale(); const numberLocale = locale === "en" ? "en-US" : "fa-IR"; diff --git a/web/dashboard/src/components/subscription/checkout-screen.tsx b/web/dashboard/src/components/subscription/checkout-screen.tsx index aae4297..25ff9b4 100644 --- a/web/dashboard/src/components/subscription/checkout-screen.tsx +++ b/web/dashboard/src/components/subscription/checkout-screen.tsx @@ -4,6 +4,7 @@ import { useEffect, useMemo, useState } from "react"; import { useSearchParams } from "next/navigation"; import { useQuery, useMutation } from "@tanstack/react-query"; import { useTranslations } from "next-intl"; +import { useApiError } from "@/lib/use-api-error"; import { useRouter } from "@/i18n/routing"; import { ArrowRight, ArrowLeft, ShieldCheck } from "lucide-react"; import { apiGet, apiPost } from "@/lib/api/client"; @@ -34,6 +35,7 @@ export function CheckoutScreen() { const t = useTranslations("subscription"); const tc = useTranslations("subscription.checkout"); const tPlans = useTranslations("settings.plans"); + const apiError = useApiError(); const searchParams = useSearchParams(); const router = useRouter(); const user = useAuthStore((s) => s.user); @@ -81,8 +83,7 @@ export function CheckoutScreen() { window.location.href = data.paymentUrl; }, onError: (err: unknown) => { - const msg = err instanceof Error ? err.message : String(err); - setPayError(msg || tc("paymentFailed")); + setPayError(apiError(err, tc("paymentFailed"))); }, }); diff --git a/web/dashboard/src/components/tables/tables-screen.tsx b/web/dashboard/src/components/tables/tables-screen.tsx index 26a0062..749e462 100644 --- a/web/dashboard/src/components/tables/tables-screen.tsx +++ b/web/dashboard/src/components/tables/tables-screen.tsx @@ -7,6 +7,7 @@ import * as signalR from "@microsoft/signalr"; import { Plus, QrCode, Pencil, Video, Trash2, Copy, ExternalLink } from "lucide-react"; import { DemoDataBanner } from "@/components/demo/demo-data-banner"; import { notify } from "@/lib/notify"; +import { useApiError } from "@/lib/use-api-error"; import { MediaPairUpload } from "@/components/media/media-pair-upload"; import { PageHeader } from "@/components/layout/page-header"; import { @@ -53,6 +54,7 @@ export function TablesScreen() { const branchId = useBranchStore((s) => s.branchId); const queryClient = useQueryClient(); const confirmDialog = useConfirm(); + const apiError = useApiError(); const [actionMessage, setActionMessage] = useState(null); const [showForm, setShowForm] = useState(false); const [number, setNumber] = useState(""); @@ -123,7 +125,7 @@ export function TablesScreen() { refresh(); }, onError: (err) => { - const msg = err instanceof ApiClientError ? err.message : t("createError"); + const msg = apiError(err, t("createError")); setActionMessage(msg); notify.error(msg); }, @@ -142,7 +144,7 @@ export function TablesScreen() { refresh(); }, onError: (err) => { - setActionMessage(err instanceof ApiClientError ? err.message : t("cleaningError")); + setActionMessage(apiError(err, t("cleaningError"))); }, }); @@ -158,7 +160,7 @@ export function TablesScreen() { setActionMessage(t("tableHasOpenOrder")); return; } - setActionMessage(err instanceof ApiClientError ? err.message : t("deleteError")); + setActionMessage(apiError(err, t("deleteError"))); }, }); @@ -188,7 +190,7 @@ export function TablesScreen() { refresh(); }, onError: (err) => { - const msg = err instanceof ApiClientError ? err.message : t("createError"); + const msg = apiError(err, t("createError")); setActionMessage(msg); notify.error(msg); }, diff --git a/web/dashboard/src/lib/notify.ts b/web/dashboard/src/lib/notify.ts index e1ccc9e..fd66baa 100644 --- a/web/dashboard/src/lib/notify.ts +++ b/web/dashboard/src/lib/notify.ts @@ -46,7 +46,10 @@ export const notify = { }; export function getErrorMessage(err: unknown, fallback: string): string { - if (err instanceof ApiClientError) return err.message; + // ApiClientError.message is the raw (usually English) backend message; prefer + // the caller's localized fallback. For code-specific localized text, use the + // useApiError() hook instead of this helper. + if (err instanceof ApiClientError) return fallback; if (err instanceof Error && err.message) return err.message; return fallback; } diff --git a/web/dashboard/src/lib/use-api-error.ts b/web/dashboard/src/lib/use-api-error.ts new file mode 100644 index 0000000..7d74742 --- /dev/null +++ b/web/dashboard/src/lib/use-api-error.ts @@ -0,0 +1,21 @@ +import { useTranslations } from "next-intl"; +import { ApiClientError } from "@/lib/api/client"; + +/** + * Returns a resolver that turns any caught error into a localized, user-facing + * message using the "errors" namespace. Known ApiClientError codes map to their + * translated message; otherwise the provided fallback is used, then a generic + * localized message. Never surfaces the raw (English) backend message. + * + * const apiError = useApiError(); + * onError: (err) => notify.error(apiError(err)) + */ +export function useApiError() { + const t = useTranslations("errors"); + return (err: unknown, fallback?: string): string => { + if (err instanceof ApiClientError && err.code && t.has(err.code)) { + return t(err.code); + } + return fallback ?? t("generic"); + }; +}