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). // from their city, so the public map lights up. Idempotent (fills nulls).
await BackfillCafeLocationsAsync(db, logger); 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()) if (!env.IsDevelopment())
{ {
// Production: also ensure integration settings (Kavenegar enabled/template, // Production: also ensure integration settings (Kavenegar enabled/template,
@@ -43,8 +49,6 @@ public static class PlatformDataSeeder
await EnsureCatalogUpgradesAsync(db, logger); await EnsureCatalogUpgradesAsync(db, logger);
await SeedSystemAdminAsync(db, logger); await SeedSystemAdminAsync(db, logger);
await SeedPlansAsync(db, logger);
await SeedFeaturesAsync(db, logger);
await SeedSettingsAsync(db, logger); await SeedSettingsAsync(db, logger);
await EnsureIntegrationSettingsAsync(db, logger); await EnsureIntegrationSettingsAsync(db, logger);
} }
@@ -357,9 +361,6 @@ public static class PlatformDataSeeder
private static async Task SeedPlansAsync(AppDbContext db, ILogger logger) private static async Task SeedPlansAsync(AppDbContext db, ILogger logger)
{ {
if (await db.PlatformPlanDefinitions.AnyAsync())
return;
var plans = new[] var plans = new[]
{ {
new PlatformPlanDefinition 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(); 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) private static async Task SeedFeaturesAsync(AppDbContext db, ILogger logger)
{ {
if (await db.PlatformFeatures.AnyAsync())
return;
var features = new[] var features = new[]
{ {
F("pos", "صندوق", "POS", "core"), F("pos", "صندوق", "POS", "core"),
@@ -456,9 +459,14 @@ public static class PlatformDataSeeder
F("discover_profile", "پروفایل کشف", "Discover profile", "growth") 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(); 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() 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 <button
type="button" type="button"
role="switch" role="switch"
dir="ltr"
aria-checked={checked} aria-checked={checked}
disabled={disabled} disabled={disabled}
onClick={() => onChange(!checked)} onClick={() => onChange(!checked)}
+27
View File
@@ -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": "ميزي"
}, },
+27
View File
@@ -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"
}, },
+27
View File
@@ -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);
}, },
+4 -1
View File
@@ -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;
} }
+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");
};
}