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:
@@ -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");
|
||||
|
||||
Reference in New Issue
Block a user