Compare commits

2 Commits

Author SHA1 Message Date
soroush.asadi f813cc4854 test: repair test suite broken by feature drift (red -> 81 passing)
CI/CD / CI · API (dotnet build + test) (pull_request) Successful in 43s
CI/CD / CI · Admin API (dotnet build) (pull_request) Successful in 44s
CI/CD / CI · Dashboard (tsc) (pull_request) Successful in 1m7s
CI/CD / CI · Admin Web (tsc) (pull_request) Successful in 36s
CI/CD / CI · Website (tsc) (pull_request) Successful in 45s
CI/CD / CI · Koja (tsc) (pull_request) Successful in 51s
CI/CD / Deploy · all services (pull_request) Has been skipped
The test project no longer compiled: recent feature commits changed
interfaces and DTOs without updating the test doubles/call sites, so the
whole suite (and therefore CI) was failing to build.

- NoOpInventoryService: add IInventoryService.GetPurchasesSummaryAsync and
  the new string? userId param on AdjustAsync.
- NoOpLoyaltyService: add ILoyaltyService.RedeemOnOrderAsync.
- NoOpOrderNotificationService: add NotifyCallWaiterAsync.
- New NoOpAbuseProtectionService and NoOpMediaStorageService test doubles.
- QrMenuTests: ReviewService/PublicService gained IAbuseProtectionService +
  IHttpContextAccessor (and ReviewService an IMediaStorageService); wire the
  new no-op doubles + a real HttpContextAccessor.
- PrintingTests: OrderDto gained a DisplayNumber int between CreatedAt and
  Items; pass it.
- DiscoverFilterTests: add missing `using Xunit;` and the new openNow arg on
  DiscoverFilterParams.FromQuery.

Result: dotnet test -> Passed: 81, Failed: 0.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-01 18:44:41 +03:30
soroush.asadi 024a455ab3 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>
2026-06-01 18:25:34 +03:30
18 changed files with 303 additions and 63 deletions
+2 -2
View File
@@ -12,7 +12,7 @@ public class CreateMenuCategoryRequestValidator : AbstractValidator<CreateMenuCa
public CreateMenuCategoryRequestValidator()
{
RuleFor(x => x.Name).NotEmpty().MaximumLength(200);
RuleFor(x => x.NameEn).NotEmpty().MaximumLength(200);
RuleFor(x => x.NameEn).MaximumLength(200).When(x => !string.IsNullOrWhiteSpace(x.NameEn));
RuleFor(x => x.SortOrder).GreaterThanOrEqualTo(0);
RuleFor(x => x.DiscountPercent).InclusiveBetween(0, 100);
RuleFor(x => x.Icon).MaximumLength(32).When(x => x.Icon is not null);
@@ -39,7 +39,7 @@ public class CreateMenuItemRequestValidator : AbstractValidator<CreateMenuItemRe
{
RuleFor(x => x.CategoryId).NotEmpty();
RuleFor(x => x.Name).NotEmpty().MaximumLength(200);
RuleFor(x => x.NameEn).NotEmpty().MaximumLength(200);
RuleFor(x => x.NameEn).MaximumLength(200).When(x => !string.IsNullOrWhiteSpace(x.NameEn));
RuleFor(x => x.Price).GreaterThanOrEqualTo(0);
RuleFor(x => x.DiscountPercent).InclusiveBetween(0, 100);
}
+3 -1
View File
@@ -1,5 +1,6 @@
using Meezi.API.Services;
using Meezi.Core.Discover;
using Xunit;
namespace Meezi.API.Tests;
@@ -44,7 +45,8 @@ public class DiscoverFilterTests
noise: "quiet",
priceTier: "mid",
size: null,
requireProfile: true);
requireProfile: true,
openNow: false);
Assert.Equal("تهران", f.City);
Assert.Equal(4, f.MinRating);
Assert.Contains("modern", f.Themes!);
@@ -0,0 +1,26 @@
using Meezi.API.Security;
namespace Meezi.API.Tests;
/// <summary>Test double that allows every action and has no captcha configured.</summary>
internal sealed class NoOpAbuseProtectionService : IAbuseProtectionService
{
public bool IsCaptchaConfigured => false;
public string? CaptchaSiteKey => null;
public Task<(bool Allowed, string? ErrorCode, string? Message)> CheckAuthOtpByIpAsync(
string clientIp, CancellationToken cancellationToken = default) =>
Task.FromResult<(bool, string?, string?)>((true, null, null));
public Task<(bool Allowed, string? ErrorCode, string? Message)> CheckGuestOrderAsync(
string cafeId, string clientIp, CancellationToken cancellationToken = default) =>
Task.FromResult<(bool, string?, string?)>((true, null, null));
public Task<(bool Allowed, string? ErrorCode, string? Message)> CheckPublicWriteByIpAsync(
string clientIp, CancellationToken cancellationToken = default) =>
Task.FromResult<(bool, string?, string?)>((true, null, null));
public Task<(bool Ok, string? ErrorCode, string? Message)> VerifyCaptchaAsync(
string? captchaToken, CancellationToken cancellationToken = default) =>
Task.FromResult<(bool, string?, string?)>((true, null, null));
}
@@ -16,9 +16,17 @@ internal sealed class NoOpInventoryService : IInventoryService
public Task<IngredientDto?> UpdateAsync(string cafeId, string ingredientId, UpdateIngredientRequest request, CancellationToken ct = default) =>
Task.FromResult<IngredientDto?>(null);
public Task<IngredientDto?> AdjustAsync(string cafeId, string ingredientId, AdjustStockRequest request, CancellationToken ct = default) =>
public Task<IngredientDto?> AdjustAsync(string cafeId, string ingredientId, AdjustStockRequest request, string? userId, CancellationToken ct = default) =>
Task.FromResult<IngredientDto?>(null);
public Task<InventoryPurchasesSummaryDto> GetPurchasesSummaryAsync(
string cafeId,
string branchId,
DateOnly from,
DateOnly to,
CancellationToken ct = default) =>
Task.FromResult(new InventoryPurchasesSummaryDto(0, 0, []));
public Task<MenuItemRecipeDto?> GetRecipeAsync(string cafeId, string menuItemId, CancellationToken ct = default) =>
Task.FromResult<MenuItemRecipeDto?>(null);
@@ -1,4 +1,5 @@
using Meezi.API.Services;
using Meezi.Core.Entities;
namespace Meezi.API.Tests;
@@ -10,4 +11,11 @@ internal sealed class NoOpLoyaltyService : ILoyaltyService
decimal paidAmount,
CancellationToken ct = default) =>
Task.CompletedTask;
public Task<(bool Success, LoyaltyRedeemResult? Data, string? ErrorCode)> RedeemOnOrderAsync(
string cafeId,
Order order,
int pointsRequested,
CancellationToken ct = default) =>
Task.FromResult<(bool, LoyaltyRedeemResult?, string?)>((false, null, null));
}
@@ -0,0 +1,29 @@
using Meezi.API.Services;
using Microsoft.AspNetCore.Http;
namespace Meezi.API.Tests;
/// <summary>Test double that stores nothing and returns no URL.</summary>
internal sealed class NoOpMediaStorageService : IMediaStorageService
{
public Task<string?> SaveMenuImageAsync(string cafeId, IFormFile file, CancellationToken cancellationToken = default) =>
Task.FromResult<string?>(null);
public Task<string?> SaveMenuVideoAsync(string cafeId, IFormFile file, CancellationToken cancellationToken = default) =>
Task.FromResult<string?>(null);
public Task<string?> SaveTableImageAsync(string cafeId, IFormFile file, CancellationToken cancellationToken = default) =>
Task.FromResult<string?>(null);
public Task<string?> SaveTableVideoAsync(string cafeId, IFormFile file, CancellationToken cancellationToken = default) =>
Task.FromResult<string?>(null);
public Task<string?> SaveCafeLogoAsync(string cafeId, IFormFile file, CancellationToken cancellationToken = default) =>
Task.FromResult<string?>(null);
public Task<string?> SaveCafeCoverAsync(string cafeId, IFormFile file, CancellationToken cancellationToken = default) =>
Task.FromResult<string?>(null);
public Task<string?> SaveMenuModel3dAsync(string cafeId, IFormFile file, CancellationToken cancellationToken = default) =>
Task.FromResult<string?>(null);
public Task<string?> SaveMenuModel3dFromBytesAsync(string cafeId, byte[] glbBytes, CancellationToken cancellationToken = default) =>
Task.FromResult<string?>(null);
public Task<string?> SaveReviewPhotoAsync(string cafeId, IFormFile file, CancellationToken cancellationToken = default) =>
Task.FromResult<string?>(null);
public Task<string?> SaveCafeGalleryPhotoAsync(string cafeId, IFormFile file, CancellationToken cancellationToken = default) =>
Task.FromResult<string?>(null);
}
@@ -11,4 +11,7 @@ internal sealed class NoOpOrderNotificationService : IOrderNotificationService
public Task NotifyOrderStatusChangedAsync(Order order, CancellationToken ct = default) =>
Task.CompletedTask;
public Task NotifyCallWaiterAsync(string cafeId, string tableId, string tableNumber, CancellationToken ct = default) =>
Task.CompletedTask;
}
+1
View File
@@ -28,6 +28,7 @@ public class PrintingTests
218_000m,
0m,
DateTime.UtcNow,
1,
[
new OrderItemDto("i1", "m1", "Espresso", 2, 100_000m, null),
new OrderItemDto("i2", "m2", "Void Latte", 1, 50_000m, null, true)
+7 -1
View File
@@ -1,11 +1,13 @@
using Meezi.API.Models.Menu;
using Meezi.API.Models.Orders;
using Meezi.API.Models.Public;
using Meezi.API.Security;
using Meezi.API.Services;
using Meezi.Core.Entities;
using Meezi.Core.Enums;
using Meezi.Core.Interfaces;
using Meezi.Infrastructure.Data;
using Microsoft.AspNetCore.Http;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Configuration;
using Xunit;
@@ -114,7 +116,11 @@ public class QrMenuTests
var tables = new TableService(db, config, kds, identity);
var shifts = new ShiftService(db);
var orders = new OrderService(db, kds, new NoOpSnappfood(), new NoOpDeliverySync(), shifts, TestServiceScopeFactory.Create(), new NoOpOrderNotificationService(), new NoOpInventoryService(), new NoOpLoyaltyService());
var publicSvc = new PublicService(db, orders, new ReviewService(db), kds, branchMenu, identity);
var abuse = new NoOpAbuseProtectionService();
var http = new HttpContextAccessor();
var media = new NoOpMediaStorageService();
var reviews = new ReviewService(db, abuse, http, media);
var publicSvc = new PublicService(db, orders, reviews, kds, branchMenu, identity, abuse, http);
return (db, tables, publicSvc, cafeId, branchId, tableId, itemA, itemB, qrCode);
}
+1
View File
@@ -1057,6 +1057,7 @@
"fieldCategoryEn": "الفئة بالإنجليزية",
"fieldContentFa": "المحتوى بالفارسية (Markdown)",
"fieldContentEn": "المحتوى بالإنجليزية (Markdown)",
"fieldPublished": "منشور",
"commentsTitle": "إدارة التعليقات",
"noComments": "لا توجد تعليقات",
"approved": "موافق عليه",
+1
View File
@@ -1058,6 +1058,7 @@
"fieldCategoryEn": "Category (English)",
"fieldContentFa": "Content (Persian, Markdown)",
"fieldContentEn": "Content (English, Markdown)",
"fieldPublished": "Published",
"commentsTitle": "Comment management",
"noComments": "No comments found",
"approved": "Approved",
+1
View File
@@ -1058,6 +1058,7 @@
"fieldCategoryEn": "دسته‌بندی انگلیسی",
"fieldContentFa": "محتوا فارسی (Markdown)",
"fieldContentEn": "محتوا انگلیسی (Markdown)",
"fieldPublished": "وضعیت انتشار",
"commentsTitle": "مدیریت نظرات",
"noComments": "نظری یافت نشد",
"approved": "تأیید شده",
@@ -62,6 +62,33 @@ function Toggle({ checked, onChange, disabled }: { checked: boolean; onChange: (
);
}
// Styled single-select indicator (replaces raw <input type="radio">).
function RadioDot({
selected,
onSelect,
disabled,
}: {
selected: boolean;
onSelect: () => void;
disabled?: boolean;
}) {
return (
<button
type="button"
role="radio"
aria-checked={selected}
disabled={disabled}
onClick={onSelect}
className={cn(
"relative inline-flex h-5 w-5 shrink-0 items-center justify-center rounded-full border-2 transition-colors focus:outline-none focus-visible:ring-2 focus-visible:ring-ring disabled:cursor-not-allowed disabled:opacity-50",
selected ? "border-[#0F6E56]" : "border-muted-foreground/40 hover:border-muted-foreground/70"
)}
>
{selected ? <span className="h-2.5 w-2.5 rounded-full bg-[#0F6E56]" /> : null}
</button>
);
}
export function AdminDashboardScreen() {
const t = useTranslations("admin.dashboard");
const { data } = useQuery({
@@ -632,11 +659,9 @@ export function AdminIntegrationsScreen() {
<Card key={g.id} className="rounded-xl border border-border/80 p-4 space-y-3">
<div className="flex flex-wrap items-center justify-between gap-2">
<div className="flex items-center gap-2">
<input
type="radio"
name="activeGateway"
checked={activeGateway === g.id}
onChange={() => setActiveGateway(g.id)}
<RadioDot
selected={activeGateway === g.id}
onSelect={() => setActiveGateway(g.id)}
/>
<span className="font-medium">{g.displayNameFa}</span>
{activeGateway === g.id ? (
@@ -16,6 +16,7 @@ import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Badge } from "@/components/ui/badge";
import { notify } from "@/lib/notify";
import { cn } from "@/lib/utils";
import { Link } from "@/i18n/routing";
import {
CheckCircle2,
@@ -149,6 +150,74 @@ export function AdminBlogListScreen() {
// ── Blog Post Editor ─────────────────────────────────────────────────────────
// iOS-style toggle (mirrors the one in admin-screens.tsx).
function BlogToggle({
checked,
onChange,
}: {
checked: boolean;
onChange: (v: boolean) => void;
}) {
return (
<button
type="button"
role="switch"
aria-checked={checked}
onClick={() => onChange(!checked)}
className={cn(
"relative inline-flex h-6 w-11 shrink-0 cursor-pointer rounded-full border-2 border-transparent transition-colors duration-200 ease-in-out focus:outline-none focus-visible:ring-2 focus-visible:ring-ring",
checked ? "bg-[#0F6E56]" : "bg-muted-foreground/30"
)}
>
<span
className={cn(
"pointer-events-none inline-block h-5 w-5 transform rounded-full bg-white shadow-lg ring-0 transition duration-200 ease-in-out",
checked ? "translate-x-5" : "translate-x-0"
)}
/>
</button>
);
}
// Module-level so it keeps a stable component identity across renders.
// (Previously defined inside the editor, which remounted every input on each
// keystroke and dropped focus after a single character.)
function BlogField({
label,
value,
onChange,
multiline,
dir,
}: {
label: string;
value: string;
onChange: (v: string) => void;
multiline?: boolean;
dir: "rtl" | "ltr";
}) {
return (
<div>
<label className="mb-1 block text-xs font-medium text-muted-foreground">{label}</label>
{multiline ? (
<textarea
rows={8}
value={value}
onChange={(e) => onChange(e.target.value)}
className="w-full resize-y rounded-lg border border-border bg-background px-3 py-2 font-mono text-xs outline-none focus:ring-2 focus:ring-primary/30"
dir={dir}
/>
) : (
<Input
value={value}
onChange={(e) => onChange(e.target.value)}
className="h-9 text-sm"
dir={dir}
/>
)}
</div>
);
}
interface PostEditorProps {
postId?: string; // undefined = new post
}
@@ -176,6 +245,7 @@ export function AdminBlogEditorScreen({ postId }: PostEditorProps) {
author: "تیم میزی",
categoryFa: "",
categoryEn: "",
isPublished: false,
};
const [form, setForm] = useState(emptyForm);
@@ -195,6 +265,7 @@ export function AdminBlogEditorScreen({ postId }: PostEditorProps) {
author: post.author,
categoryFa: post.categoryFa,
categoryEn: post.categoryEn,
isPublished: post.isPublished,
});
setFormReady(true);
}
@@ -219,39 +290,6 @@ export function AdminBlogEditorScreen({ postId }: PostEditorProps) {
onError: () => notify.error(t("errorGeneric")),
});
const Field = ({
label,
fieldKey,
multiline,
}: {
label: string;
fieldKey: keyof typeof form;
multiline?: boolean;
}) => {
const isFa = label.toLowerCase().includes("fa") || label.includes("فارسی") || label.includes("فا");
return (
<div>
<label className="mb-1 block text-xs font-medium text-muted-foreground">{label}</label>
{multiline ? (
<textarea
rows={8}
value={form[fieldKey]}
onChange={(e) => setField(fieldKey)(e.target.value)}
className="w-full resize-y rounded-lg border border-border bg-background px-3 py-2 font-mono text-xs outline-none focus:ring-2 focus:ring-primary/30"
dir={isFa ? "rtl" : "ltr"}
/>
) : (
<Input
value={form[fieldKey]}
onChange={(e) => setField(fieldKey)(e.target.value)}
className="h-9 text-sm"
dir={isFa ? "rtl" : "ltr"}
/>
)}
</div>
);
};
if (!isNew && !formReady) {
return <p className="text-sm text-muted-foreground">{t("loading")}</p>;
}
@@ -273,23 +311,31 @@ export function AdminBlogEditorScreen({ postId }: PostEditorProps) {
<Card className="rounded-xl border-border/80">
<CardContent className="space-y-4 pt-5">
<div className="grid gap-4 sm:grid-cols-2">
<Field label={t("fieldSlug")} fieldKey="slug" />
<Field label={t("fieldAuthor")} fieldKey="author" />
<BlogField label={t("fieldSlug")} value={form.slug} onChange={setField("slug")} dir="ltr" />
<BlogField label={t("fieldAuthor")} value={form.author} onChange={setField("author")} dir="rtl" />
</div>
<div className="grid gap-4 sm:grid-cols-2">
<Field label={t("fieldTitleFa")} fieldKey="titleFa" />
<Field label={t("fieldTitleEn")} fieldKey="titleEn" />
<BlogField label={t("fieldTitleFa")} value={form.titleFa} onChange={setField("titleFa")} dir="rtl" />
<BlogField label={t("fieldTitleEn")} value={form.titleEn} onChange={setField("titleEn")} dir="ltr" />
</div>
<div className="grid gap-4 sm:grid-cols-2">
<Field label={t("fieldExcerptFa")} fieldKey="excerptFa" />
<Field label={t("fieldExcerptEn")} fieldKey="excerptEn" />
<BlogField label={t("fieldExcerptFa")} value={form.excerptFa} onChange={setField("excerptFa")} dir="rtl" />
<BlogField label={t("fieldExcerptEn")} value={form.excerptEn} onChange={setField("excerptEn")} dir="ltr" />
</div>
<div className="grid gap-4 sm:grid-cols-2">
<Field label={t("fieldCategoryFa")} fieldKey="categoryFa" />
<Field label={t("fieldCategoryEn")} fieldKey="categoryEn" />
<BlogField label={t("fieldCategoryFa")} value={form.categoryFa} onChange={setField("categoryFa")} dir="rtl" />
<BlogField label={t("fieldCategoryEn")} value={form.categoryEn} onChange={setField("categoryEn")} dir="ltr" />
</div>
<BlogField label={t("fieldContentFa")} value={form.contentFa} onChange={setField("contentFa")} dir="rtl" multiline />
<BlogField label={t("fieldContentEn")} value={form.contentEn} onChange={setField("contentEn")} dir="ltr" multiline />
<div className="flex items-center justify-between rounded-lg border border-border/80 px-3 py-2">
<span className="text-sm font-medium">{t("fieldPublished")}</span>
<BlogToggle
checked={form.isPublished}
onChange={(v) => setForm((f) => ({ ...f, isPublished: v }))}
/>
</div>
<Field label={t("fieldContentFa")} fieldKey="contentFa" multiline />
<Field label={t("fieldContentEn")} fieldKey="contentEn" multiline />
<div className="flex justify-end pt-2">
<Button
@@ -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");