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 <noreply@anthropic.com>
This commit is contained in:
@@ -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": "ميزي"
|
||||
},
|
||||
|
||||
@@ -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"
|
||||
},
|
||||
|
||||
@@ -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": "میزی"
|
||||
},
|
||||
|
||||
@@ -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<DemoSeedResult | null>(null);
|
||||
|
||||
@@ -41,11 +43,7 @@ export function DemoDataBanner({ invalidateKeys, className }: Props) {
|
||||
}
|
||||
},
|
||||
onError: (err) => {
|
||||
notify.error(
|
||||
err instanceof ApiClientError
|
||||
? err.message
|
||||
: "افزودن دادههای نمونه ناموفق بود. دوباره تلاش کنید."
|
||||
);
|
||||
notify.error(apiError(err));
|
||||
},
|
||||
});
|
||||
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -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")));
|
||||
},
|
||||
});
|
||||
|
||||
|
||||
@@ -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<string | null>(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);
|
||||
},
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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");
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user