Compare commits

2 Commits

Author SHA1 Message Date
soroush.asadi a83edf7667 fix: seed all plans/features in prod (upsert); fix admin toggle RTL knob
CI/CD / CI · API (dotnet build + test) (push) Successful in 50s
CI/CD / CI · Admin API (dotnet build) (push) Successful in 39s
CI/CD / CI · Dashboard (tsc) (push) Successful in 1m5s
CI/CD / CI · Admin Web (tsc) (push) Successful in 35s
CI/CD / CI · Website (tsc) (push) Successful in 45s
CI/CD / CI · Koja (tsc) (push) Successful in 49s
CI/CD / Deploy · all services (push) Failing after 5m44s
Plan + feature seeding was dev-gated and all-or-nothing, so production only had the Free plan (admin Plans page showed one). Now runs in every environment and upserts missing rows (adds Pro/Business/Enterprise on top of the existing Free). Also force LTR on the admin toggle switch so the knob doesn't render off-track under the RTL page.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-02 00:23:17 +03:30
soroush.asadi 75d5bbc84a 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>
2026-06-02 00:04:48 +03:30
11 changed files with 144 additions and 31 deletions
@@ -33,6 +33,12 @@ public static class PlatformDataSeeder
// from their city, so the public map lights up. Idempotent (fills nulls).
await BackfillCafeLocationsAsync(db, logger);
// Subscription plans + feature flags are platform config the admin panel
// reads in every environment. Idempotent: adds any rows that are missing
// (so prod, which only had the Free plan, gets Pro/Business/Enterprise).
await SeedPlansAsync(db, logger);
await SeedFeaturesAsync(db, logger);
if (!env.IsDevelopment())
{
// Production: also ensure integration settings (Kavenegar enabled/template,
@@ -43,8 +49,6 @@ public static class PlatformDataSeeder
await EnsureCatalogUpgradesAsync(db, logger);
await SeedSystemAdminAsync(db, logger);
await SeedPlansAsync(db, logger);
await SeedFeaturesAsync(db, logger);
await SeedSettingsAsync(db, logger);
await EnsureIntegrationSettingsAsync(db, logger);
}
@@ -357,9 +361,6 @@ public static class PlatformDataSeeder
private static async Task SeedPlansAsync(AppDbContext db, ILogger logger)
{
if (await db.PlatformPlanDefinitions.AnyAsync())
return;
var plans = new[]
{
new PlatformPlanDefinition
@@ -421,16 +422,18 @@ public static class PlatformDataSeeder
}
};
db.PlatformPlanDefinitions.AddRange(plans);
var existingIds = (await db.PlatformPlanDefinitions.Select(p => p.Id).ToListAsync())
.ToHashSet(StringComparer.Ordinal);
var missing = plans.Where(p => !existingIds.Contains(p.Id)).ToArray();
if (missing.Length == 0) return;
db.PlatformPlanDefinitions.AddRange(missing);
await db.SaveChangesAsync();
logger.LogInformation("Platform seed: {Count} subscription plans", plans.Length);
logger.LogInformation("Platform seed: +{Count} subscription plans", missing.Length);
}
private static async Task SeedFeaturesAsync(AppDbContext db, ILogger logger)
{
if (await db.PlatformFeatures.AnyAsync())
return;
var features = new[]
{
F("pos", "صندوق", "POS", "core"),
@@ -456,9 +459,14 @@ public static class PlatformDataSeeder
F("discover_profile", "پروفایل کشف", "Discover profile", "growth")
};
db.PlatformFeatures.AddRange(features);
var existingIds = (await db.PlatformFeatures.Select(f => f.Id).ToListAsync())
.ToHashSet(StringComparer.Ordinal);
var missing = features.Where(f => !existingIds.Contains(f.Id)).ToArray();
if (missing.Length == 0) return;
db.PlatformFeatures.AddRange(missing);
await db.SaveChangesAsync();
logger.LogInformation("Platform seed: {Count} feature flags", features.Length);
logger.LogInformation("Platform seed: +{Count} feature flags", missing.Length);
}
private static PlatformFeature F(string key, string fa, string en, string group) => new()
@@ -44,6 +44,7 @@ function Toggle({ checked, onChange, disabled }: { checked: boolean; onChange: (
<button
type="button"
role="switch"
dir="ltr"
aria-checked={checked}
disabled={disabled}
onClick={() => onChange(!checked)}
+27
View File
@@ -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": "ميزي"
},
+27
View File
@@ -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"
},
+27
View File
@@ -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);
},
+4 -1
View File
@@ -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;
}
+21
View File
@@ -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");
};
}