fix: menu item/category create, demo banner reach, token refresh, blog publish

Dashboard & API bug fixes for owner-reported breakage:

- MenuController validators (PosValidators): NameEn was required but the
  dashboard sends null when blank, so every manual menu-item create failed
  and category create failed 100% (the form never sends nameEn). Now optional.
- DemoDataBanner: only showed when a cafe was exactly empty, so
  showcase-seeded cafes (2-3 cats / 3-5 items) could never trigger the
  one-click seed. Widened gate to sparse menus (<5 cats && <10 items) and
  added a clear "nothing to add" message when already populated.
- client.ts: added one-time JWT refresh-and-retry on 401 (shared in-flight
  promise) before bouncing to /login. Expired access tokens silently broke
  ticket list, add-table, and other reads.
- Surface API errors as toasts on menu + table mutations (were swallowed
  silently, so failures looked like "nothing happens").
- Admin blog editor: saving an edit dropped IsPublished (defaulted false,
  silently unpublishing the post on every save); now persisted with a
  toggle. Also hoisted the inner Field component to module scope - it was
  remounting every input on each keystroke and dropping focus.
- Admin integrations: replaced raw radio gateway selector with a styled
  RadioDot matching the iOS toggles.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
soroush.asadi
2026-06-01 18:23:31 +03:30
parent f687178238
commit 024a455ab3
10 changed files with 217 additions and 60 deletions
@@ -43,6 +43,12 @@ export function DemoDataBanner({ invalidateKeys, className }: Props) {
if (!cafeId || (role !== "Owner" && role !== "Manager")) return null;
if (done && summary) {
const nothingAdded =
summary.categoriesAdded === 0 &&
summary.itemsAdded === 0 &&
summary.tablesAdded === 0 &&
summary.ingredientsAdded === 0 &&
!summary.taxCreated;
return (
<div
className={cn(
@@ -52,10 +58,16 @@ export function DemoDataBanner({ invalidateKeys, className }: Props) {
>
<Sparkles className="size-4 shrink-0" />
<span>
دادههای نمونه اضافه شد {summary.categoriesAdded} دسته،{" "}
{summary.itemsAdded} آیتم، {summary.tablesAdded} میز،{" "}
{summary.ingredientsAdded} ماده اولیه
{summary.taxCreated ? "، مالیات ۹٪" : ""}.
{nothingAdded ? (
"همه داده‌های نمونه از قبل موجود بودند — موردی اضافه نشد."
) : (
<>
دادههای نمونه اضافه شد {summary.categoriesAdded} دسته،{" "}
{summary.itemsAdded} آیتم، {summary.tablesAdded} میز،{" "}
{summary.ingredientsAdded} ماده اولیه
{summary.taxCreated ? "، مالیات ۹٪" : ""}.
</>
)}
</span>
</div>
);
@@ -12,7 +12,8 @@ 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 { apiGet, apiPatch, apiPost } from "@/lib/api/client";
import { ApiClientError, apiGet, apiPatch, apiPost } from "@/lib/api/client";
import { notify } from "@/lib/notify";
import { useAuthStore } from "@/lib/stores/auth.store";
import { useBranchStore } from "@/lib/stores/branch.store";
import { formatCurrency, formatNumber } from "@/lib/format";
@@ -183,6 +184,11 @@ 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 isRtl = useIsRtl();
const locale = useLocale();
const numberLocale = locale === "en" ? "en-US" : "fa-IR";
@@ -267,6 +273,7 @@ export function MenuAdminScreen() {
setItemModalOpen(false);
invalidateMenu();
},
onError: showError,
});
const updateItemMutation = useMutation({
@@ -284,12 +291,14 @@ export function MenuAdminScreen() {
setItemModalOpen(false);
invalidateMenu();
},
onError: showError,
});
const toggleItemMutation = useMutation({
mutationFn: ({ id, isAvailable }: { id: string; isAvailable: boolean }) =>
apiPatch(`/api/cafes/${cafeId}/menu/items/${id}/availability`, { isAvailable }),
onSuccess: invalidateMenu,
onError: showError,
});
const addCategoryMutation = useMutation({
@@ -307,6 +316,7 @@ export function MenuAdminScreen() {
setCatModalOpen(false);
invalidateMenu();
},
onError: showError,
});
const updateCategoryMutation = useMutation({
@@ -322,6 +332,7 @@ export function MenuAdminScreen() {
setCatModalOpen(false);
invalidateMenu();
},
onError: showError,
});
// ── Modal openers ──────────────────────────────────────────────────────────
@@ -451,7 +462,7 @@ export function MenuAdminScreen() {
) : (
/* ── Catalog tab ─────────────────────────────────────────────────── */
<div className="flex min-h-0 flex-col gap-4">
{categories.length === 0 && items.length === 0 && (
{categories.length < 5 && items.length < 10 && (
<DemoDataBanner
invalidateKeys={[
["menu-categories", cafeId],
@@ -123,7 +123,9 @@ export function TablesScreen() {
refresh();
},
onError: (err) => {
setActionMessage(err instanceof ApiClientError ? err.message : t("createError"));
const msg = err instanceof ApiClientError ? err.message : t("createError");
setActionMessage(msg);
notify.error(msg);
},
});
@@ -185,6 +187,11 @@ export function TablesScreen() {
setActionMessage(null);
refresh();
},
onError: (err) => {
const msg = err instanceof ApiClientError ? err.message : t("createError");
setActionMessage(msg);
notify.error(msg);
},
});
const startEdit = (table: TableBoardItem) => {
+56 -3
View File
@@ -1,6 +1,10 @@
import axios, { type AxiosError } from "axios";
import type { ApiResponse } from "./types";
import axios, {
type AxiosError,
type InternalAxiosRequestConfig,
} from "axios";
import type { ApiResponse, AuthTokenResponse } from "./types";
import { getOrCreateTerminalId } from "@/lib/terminal";
import { useAuthStore } from "@/lib/stores/auth.store";
const baseURL =
process.env.NEXT_PUBLIC_API_URL ?? "https://localhost:7208";
@@ -21,14 +25,63 @@ api.interceptors.request.use((config) => {
return config;
});
/**
* Shared in-flight refresh promise so that a burst of concurrent 401s triggers
* exactly one POST /api/auth/refresh instead of one per failed request.
*/
let refreshPromise: Promise<string | null> | null = null;
async function refreshAccessToken(): Promise<string | null> {
if (typeof window === "undefined") return null;
const refreshToken = localStorage.getItem("meezi_refresh_token");
if (!refreshToken) return null;
try {
// Bare axios call (not `api`) to avoid recursing through this interceptor.
const { data } = await axios.post<ApiResponse<AuthTokenResponse>>(
`${baseURL}/api/auth/refresh`,
{ refreshToken },
{ headers: { "Content-Type": "application/json" } }
);
if (!data.success || !data.data) return null;
useAuthStore.getState().setAuth(data.data);
return data.data.accessToken;
} catch {
return null;
}
}
api.interceptors.response.use(
(response) => response,
async (error: AxiosError<ApiResponse<unknown>>) => {
const status = error.response?.status;
const original = error.config as
| (InternalAxiosRequestConfig & { _retry?: boolean })
| undefined;
// Expired access token → try a one-time refresh, then replay the request.
if (
status === 401 &&
original &&
!original._retry &&
typeof window !== "undefined" &&
!original.url?.includes("/api/auth/")
) {
original._retry = true;
refreshPromise ??= refreshAccessToken().finally(() => {
refreshPromise = null;
});
const newToken = await refreshPromise;
if (newToken) {
original.headers.Authorization = `Bearer ${newToken}`;
return api(original);
}
}
const apiError = error.response?.data?.error;
if (apiError?.code) {
return Promise.reject(new ApiClientError(apiError.code, apiError.message));
}
if (error.response?.status === 401 && typeof window !== "undefined") {
if (status === 401 && typeof window !== "undefined") {
const path = window.location.pathname;
const isPublicGuest = path.startsWith("/q/") || path.startsWith("/q");
const isAdmin = path.includes("/admin");