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": "تم الحفظ",
|
"saved": "تم الحفظ",
|
||||||
"errorGeneric": "حدث خطأ. حاول مرة أخرى."
|
"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": {
|
"brand": {
|
||||||
"name": "ميزي"
|
"name": "ميزي"
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -20,6 +20,33 @@
|
|||||||
"saved": "Saved",
|
"saved": "Saved",
|
||||||
"errorGeneric": "Something went wrong. Please try again."
|
"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": {
|
"brand": {
|
||||||
"name": "Meezi"
|
"name": "Meezi"
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -20,6 +20,33 @@
|
|||||||
"saved": "ذخیره شد",
|
"saved": "ذخیره شد",
|
||||||
"errorGeneric": "خطایی رخ داد. دوباره تلاش کنید."
|
"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": {
|
"brand": {
|
||||||
"name": "میزی"
|
"name": "میزی"
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -3,8 +3,9 @@
|
|||||||
import { useState } from "react";
|
import { useState } from "react";
|
||||||
import { useMutation, useQueryClient } from "@tanstack/react-query";
|
import { useMutation, useQueryClient } from "@tanstack/react-query";
|
||||||
import { Sparkles, Loader2 } from "lucide-react";
|
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 { notify } from "@/lib/notify";
|
||||||
|
import { useApiError } from "@/lib/use-api-error";
|
||||||
import { useAuthStore } from "@/lib/stores/auth.store";
|
import { useAuthStore } from "@/lib/stores/auth.store";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import { cn } from "@/lib/utils";
|
import { cn } from "@/lib/utils";
|
||||||
@@ -27,6 +28,7 @@ export function DemoDataBanner({ invalidateKeys, className }: Props) {
|
|||||||
const cafeId = useAuthStore((s) => s.user?.cafeId);
|
const cafeId = useAuthStore((s) => s.user?.cafeId);
|
||||||
const role = useAuthStore((s) => s.user?.role);
|
const role = useAuthStore((s) => s.user?.role);
|
||||||
const qc = useQueryClient();
|
const qc = useQueryClient();
|
||||||
|
const apiError = useApiError();
|
||||||
const [done, setDone] = useState(false);
|
const [done, setDone] = useState(false);
|
||||||
const [summary, setSummary] = useState<DemoSeedResult | null>(null);
|
const [summary, setSummary] = useState<DemoSeedResult | null>(null);
|
||||||
|
|
||||||
@@ -41,11 +43,7 @@ export function DemoDataBanner({ invalidateKeys, className }: Props) {
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
onError: (err) => {
|
onError: (err) => {
|
||||||
notify.error(
|
notify.error(apiError(err));
|
||||||
err instanceof ApiClientError
|
|
||||||
? err.message
|
|
||||||
: "افزودن دادههای نمونه ناموفق بود. دوباره تلاش کنید."
|
|
||||||
);
|
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -12,8 +12,9 @@ import { CategoryVisual } from "@/components/menu/category-visual";
|
|||||||
import { CategoryMediaFields } from "@/components/menu/category-media-fields";
|
import { CategoryMediaFields } from "@/components/menu/category-media-fields";
|
||||||
import type { CategoryIconSelection } from "@/components/menu/category-preset-picker";
|
import type { CategoryIconSelection } from "@/components/menu/category-preset-picker";
|
||||||
import { DEFAULT_CATEGORY_ICON_STYLE } from "@/lib/category-icon-presets";
|
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 { notify } from "@/lib/notify";
|
||||||
|
import { useApiError } from "@/lib/use-api-error";
|
||||||
import { useAuthStore } from "@/lib/stores/auth.store";
|
import { useAuthStore } from "@/lib/stores/auth.store";
|
||||||
import { useBranchStore } from "@/lib/stores/branch.store";
|
import { useBranchStore } from "@/lib/stores/branch.store";
|
||||||
import { formatCurrency, formatNumber } from "@/lib/format";
|
import { formatCurrency, formatNumber } from "@/lib/format";
|
||||||
@@ -184,11 +185,8 @@ function Modal({
|
|||||||
export function MenuAdminScreen() {
|
export function MenuAdminScreen() {
|
||||||
const t = useTranslations("menuAdmin");
|
const t = useTranslations("menuAdmin");
|
||||||
const tCommon = useTranslations("common");
|
const tCommon = useTranslations("common");
|
||||||
const tNotify = useTranslations("notify");
|
const apiError = useApiError();
|
||||||
const showError = (err: unknown) =>
|
const showError = (err: unknown) => notify.error(apiError(err));
|
||||||
notify.error(
|
|
||||||
err instanceof ApiClientError ? err.message : tNotify("errorGeneric")
|
|
||||||
);
|
|
||||||
const isRtl = useIsRtl();
|
const isRtl = useIsRtl();
|
||||||
const locale = useLocale();
|
const locale = useLocale();
|
||||||
const numberLocale = locale === "en" ? "en-US" : "fa-IR";
|
const numberLocale = locale === "en" ? "en-US" : "fa-IR";
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import { useEffect, useMemo, useState } from "react";
|
|||||||
import { useSearchParams } from "next/navigation";
|
import { useSearchParams } from "next/navigation";
|
||||||
import { useQuery, useMutation } from "@tanstack/react-query";
|
import { useQuery, useMutation } from "@tanstack/react-query";
|
||||||
import { useTranslations } from "next-intl";
|
import { useTranslations } from "next-intl";
|
||||||
|
import { useApiError } from "@/lib/use-api-error";
|
||||||
import { useRouter } from "@/i18n/routing";
|
import { useRouter } from "@/i18n/routing";
|
||||||
import { ArrowRight, ArrowLeft, ShieldCheck } from "lucide-react";
|
import { ArrowRight, ArrowLeft, ShieldCheck } from "lucide-react";
|
||||||
import { apiGet, apiPost } from "@/lib/api/client";
|
import { apiGet, apiPost } from "@/lib/api/client";
|
||||||
@@ -34,6 +35,7 @@ export function CheckoutScreen() {
|
|||||||
const t = useTranslations("subscription");
|
const t = useTranslations("subscription");
|
||||||
const tc = useTranslations("subscription.checkout");
|
const tc = useTranslations("subscription.checkout");
|
||||||
const tPlans = useTranslations("settings.plans");
|
const tPlans = useTranslations("settings.plans");
|
||||||
|
const apiError = useApiError();
|
||||||
const searchParams = useSearchParams();
|
const searchParams = useSearchParams();
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
const user = useAuthStore((s) => s.user);
|
const user = useAuthStore((s) => s.user);
|
||||||
@@ -81,8 +83,7 @@ export function CheckoutScreen() {
|
|||||||
window.location.href = data.paymentUrl;
|
window.location.href = data.paymentUrl;
|
||||||
},
|
},
|
||||||
onError: (err: unknown) => {
|
onError: (err: unknown) => {
|
||||||
const msg = err instanceof Error ? err.message : String(err);
|
setPayError(apiError(err, tc("paymentFailed")));
|
||||||
setPayError(msg || tc("paymentFailed"));
|
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ import * as signalR from "@microsoft/signalr";
|
|||||||
import { Plus, QrCode, Pencil, Video, Trash2, Copy, ExternalLink } from "lucide-react";
|
import { Plus, QrCode, Pencil, Video, Trash2, Copy, ExternalLink } from "lucide-react";
|
||||||
import { DemoDataBanner } from "@/components/demo/demo-data-banner";
|
import { DemoDataBanner } from "@/components/demo/demo-data-banner";
|
||||||
import { notify } from "@/lib/notify";
|
import { notify } from "@/lib/notify";
|
||||||
|
import { useApiError } from "@/lib/use-api-error";
|
||||||
import { MediaPairUpload } from "@/components/media/media-pair-upload";
|
import { MediaPairUpload } from "@/components/media/media-pair-upload";
|
||||||
import { PageHeader } from "@/components/layout/page-header";
|
import { PageHeader } from "@/components/layout/page-header";
|
||||||
import {
|
import {
|
||||||
@@ -53,6 +54,7 @@ export function TablesScreen() {
|
|||||||
const branchId = useBranchStore((s) => s.branchId);
|
const branchId = useBranchStore((s) => s.branchId);
|
||||||
const queryClient = useQueryClient();
|
const queryClient = useQueryClient();
|
||||||
const confirmDialog = useConfirm();
|
const confirmDialog = useConfirm();
|
||||||
|
const apiError = useApiError();
|
||||||
const [actionMessage, setActionMessage] = useState<string | null>(null);
|
const [actionMessage, setActionMessage] = useState<string | null>(null);
|
||||||
const [showForm, setShowForm] = useState(false);
|
const [showForm, setShowForm] = useState(false);
|
||||||
const [number, setNumber] = useState("");
|
const [number, setNumber] = useState("");
|
||||||
@@ -123,7 +125,7 @@ export function TablesScreen() {
|
|||||||
refresh();
|
refresh();
|
||||||
},
|
},
|
||||||
onError: (err) => {
|
onError: (err) => {
|
||||||
const msg = err instanceof ApiClientError ? err.message : t("createError");
|
const msg = apiError(err, t("createError"));
|
||||||
setActionMessage(msg);
|
setActionMessage(msg);
|
||||||
notify.error(msg);
|
notify.error(msg);
|
||||||
},
|
},
|
||||||
@@ -142,7 +144,7 @@ export function TablesScreen() {
|
|||||||
refresh();
|
refresh();
|
||||||
},
|
},
|
||||||
onError: (err) => {
|
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"));
|
setActionMessage(t("tableHasOpenOrder"));
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
setActionMessage(err instanceof ApiClientError ? err.message : t("deleteError"));
|
setActionMessage(apiError(err, t("deleteError")));
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -188,7 +190,7 @@ export function TablesScreen() {
|
|||||||
refresh();
|
refresh();
|
||||||
},
|
},
|
||||||
onError: (err) => {
|
onError: (err) => {
|
||||||
const msg = err instanceof ApiClientError ? err.message : t("createError");
|
const msg = apiError(err, t("createError"));
|
||||||
setActionMessage(msg);
|
setActionMessage(msg);
|
||||||
notify.error(msg);
|
notify.error(msg);
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -46,7 +46,10 @@ export const notify = {
|
|||||||
};
|
};
|
||||||
|
|
||||||
export function getErrorMessage(err: unknown, fallback: string): string {
|
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;
|
if (err instanceof Error && err.message) return err.message;
|
||||||
return fallback;
|
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