Compare commits

..

3 Commits

Author SHA1 Message Date
soroush.asadi b5a6b1b68d fix(website): accurate Iran border on homepage map + slow on/off marker blink
CI/CD / CI · API (dotnet build + test) (pull_request) Successful in 46s
CI/CD / CI · Admin API (dotnet build) (pull_request) Successful in 31s
CI/CD / CI · Dashboard (tsc) (pull_request) Successful in 1m5s
CI/CD / CI · Admin Web (tsc) (pull_request) Successful in 35s
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
Replaced the rough 40-point hand-drawn polygon with the real national border (74 vertices, Natural Earth via world.geo.json) and fitted the projection bounding box to Iran's true extent, so the silhouette is recognisable and café markers stay aligned. Reworked the marker animation from a radar-style expanding ring into a slow 3.6s ease-in-out lamp fade (opacity 1->0.2->1) with a halo that glows on and off in sync. Verified via the SVG timeline: opacity 1.0 at 0s, 0.2 at 1.8s, 1.0 at 3.6s.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-01 21:38:25 +03:30
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
19 changed files with 359 additions and 118 deletions
+2 -2
View File
@@ -12,7 +12,7 @@ public class CreateMenuCategoryRequestValidator : AbstractValidator<CreateMenuCa
public CreateMenuCategoryRequestValidator() public CreateMenuCategoryRequestValidator()
{ {
RuleFor(x => x.Name).NotEmpty().MaximumLength(200); 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.SortOrder).GreaterThanOrEqualTo(0);
RuleFor(x => x.DiscountPercent).InclusiveBetween(0, 100); RuleFor(x => x.DiscountPercent).InclusiveBetween(0, 100);
RuleFor(x => x.Icon).MaximumLength(32).When(x => x.Icon is not null); 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.CategoryId).NotEmpty();
RuleFor(x => x.Name).NotEmpty().MaximumLength(200); 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.Price).GreaterThanOrEqualTo(0);
RuleFor(x => x.DiscountPercent).InclusiveBetween(0, 100); RuleFor(x => x.DiscountPercent).InclusiveBetween(0, 100);
} }
+3 -1
View File
@@ -1,5 +1,6 @@
using Meezi.API.Services; using Meezi.API.Services;
using Meezi.Core.Discover; using Meezi.Core.Discover;
using Xunit;
namespace Meezi.API.Tests; namespace Meezi.API.Tests;
@@ -44,7 +45,8 @@ public class DiscoverFilterTests
noise: "quiet", noise: "quiet",
priceTier: "mid", priceTier: "mid",
size: null, size: null,
requireProfile: true); requireProfile: true,
openNow: false);
Assert.Equal("تهران", f.City); Assert.Equal("تهران", f.City);
Assert.Equal(4, f.MinRating); Assert.Equal(4, f.MinRating);
Assert.Contains("modern", f.Themes!); 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) => public Task<IngredientDto?> UpdateAsync(string cafeId, string ingredientId, UpdateIngredientRequest request, CancellationToken ct = default) =>
Task.FromResult<IngredientDto?>(null); 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); 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) => public Task<MenuItemRecipeDto?> GetRecipeAsync(string cafeId, string menuItemId, CancellationToken ct = default) =>
Task.FromResult<MenuItemRecipeDto?>(null); Task.FromResult<MenuItemRecipeDto?>(null);
@@ -1,4 +1,5 @@
using Meezi.API.Services; using Meezi.API.Services;
using Meezi.Core.Entities;
namespace Meezi.API.Tests; namespace Meezi.API.Tests;
@@ -10,4 +11,11 @@ internal sealed class NoOpLoyaltyService : ILoyaltyService
decimal paidAmount, decimal paidAmount,
CancellationToken ct = default) => CancellationToken ct = default) =>
Task.CompletedTask; 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) => public Task NotifyOrderStatusChangedAsync(Order order, CancellationToken ct = default) =>
Task.CompletedTask; 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, 218_000m,
0m, 0m,
DateTime.UtcNow, DateTime.UtcNow,
1,
[ [
new OrderItemDto("i1", "m1", "Espresso", 2, 100_000m, null), new OrderItemDto("i1", "m1", "Espresso", 2, 100_000m, null),
new OrderItemDto("i2", "m2", "Void Latte", 1, 50_000m, null, true) 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.Menu;
using Meezi.API.Models.Orders; using Meezi.API.Models.Orders;
using Meezi.API.Models.Public; using Meezi.API.Models.Public;
using Meezi.API.Security;
using Meezi.API.Services; using Meezi.API.Services;
using Meezi.Core.Entities; using Meezi.Core.Entities;
using Meezi.Core.Enums; using Meezi.Core.Enums;
using Meezi.Core.Interfaces; using Meezi.Core.Interfaces;
using Meezi.Infrastructure.Data; using Meezi.Infrastructure.Data;
using Microsoft.AspNetCore.Http;
using Microsoft.EntityFrameworkCore; using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Configuration; using Microsoft.Extensions.Configuration;
using Xunit; using Xunit;
@@ -114,7 +116,11 @@ public class QrMenuTests
var tables = new TableService(db, config, kds, identity); var tables = new TableService(db, config, kds, identity);
var shifts = new ShiftService(db); 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 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); return (db, tables, publicSvc, cafeId, branchId, tableId, itemA, itemB, qrCode);
} }
+1
View File
@@ -1057,6 +1057,7 @@
"fieldCategoryEn": "الفئة بالإنجليزية", "fieldCategoryEn": "الفئة بالإنجليزية",
"fieldContentFa": "المحتوى بالفارسية (Markdown)", "fieldContentFa": "المحتوى بالفارسية (Markdown)",
"fieldContentEn": "المحتوى بالإنجليزية (Markdown)", "fieldContentEn": "المحتوى بالإنجليزية (Markdown)",
"fieldPublished": "منشور",
"commentsTitle": "إدارة التعليقات", "commentsTitle": "إدارة التعليقات",
"noComments": "لا توجد تعليقات", "noComments": "لا توجد تعليقات",
"approved": "موافق عليه", "approved": "موافق عليه",
+1
View File
@@ -1058,6 +1058,7 @@
"fieldCategoryEn": "Category (English)", "fieldCategoryEn": "Category (English)",
"fieldContentFa": "Content (Persian, Markdown)", "fieldContentFa": "Content (Persian, Markdown)",
"fieldContentEn": "Content (English, Markdown)", "fieldContentEn": "Content (English, Markdown)",
"fieldPublished": "Published",
"commentsTitle": "Comment management", "commentsTitle": "Comment management",
"noComments": "No comments found", "noComments": "No comments found",
"approved": "Approved", "approved": "Approved",
+1
View File
@@ -1058,6 +1058,7 @@
"fieldCategoryEn": "دسته‌بندی انگلیسی", "fieldCategoryEn": "دسته‌بندی انگلیسی",
"fieldContentFa": "محتوا فارسی (Markdown)", "fieldContentFa": "محتوا فارسی (Markdown)",
"fieldContentEn": "محتوا انگلیسی (Markdown)", "fieldContentEn": "محتوا انگلیسی (Markdown)",
"fieldPublished": "وضعیت انتشار",
"commentsTitle": "مدیریت نظرات", "commentsTitle": "مدیریت نظرات",
"noComments": "نظری یافت نشد", "noComments": "نظری یافت نشد",
"approved": "تأیید شده", "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() { export function AdminDashboardScreen() {
const t = useTranslations("admin.dashboard"); const t = useTranslations("admin.dashboard");
const { data } = useQuery({ 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"> <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 flex-wrap items-center justify-between gap-2">
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<input <RadioDot
type="radio" selected={activeGateway === g.id}
name="activeGateway" onSelect={() => setActiveGateway(g.id)}
checked={activeGateway === g.id}
onChange={() => setActiveGateway(g.id)}
/> />
<span className="font-medium">{g.displayNameFa}</span> <span className="font-medium">{g.displayNameFa}</span>
{activeGateway === g.id ? ( {activeGateway === g.id ? (
@@ -16,6 +16,7 @@ import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input"; import { Input } from "@/components/ui/input";
import { Badge } from "@/components/ui/badge"; import { Badge } from "@/components/ui/badge";
import { notify } from "@/lib/notify"; import { notify } from "@/lib/notify";
import { cn } from "@/lib/utils";
import { Link } from "@/i18n/routing"; import { Link } from "@/i18n/routing";
import { import {
CheckCircle2, CheckCircle2,
@@ -149,6 +150,74 @@ export function AdminBlogListScreen() {
// ── Blog Post Editor ───────────────────────────────────────────────────────── // ── 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 { interface PostEditorProps {
postId?: string; // undefined = new post postId?: string; // undefined = new post
} }
@@ -176,6 +245,7 @@ export function AdminBlogEditorScreen({ postId }: PostEditorProps) {
author: "تیم میزی", author: "تیم میزی",
categoryFa: "", categoryFa: "",
categoryEn: "", categoryEn: "",
isPublished: false,
}; };
const [form, setForm] = useState(emptyForm); const [form, setForm] = useState(emptyForm);
@@ -195,6 +265,7 @@ export function AdminBlogEditorScreen({ postId }: PostEditorProps) {
author: post.author, author: post.author,
categoryFa: post.categoryFa, categoryFa: post.categoryFa,
categoryEn: post.categoryEn, categoryEn: post.categoryEn,
isPublished: post.isPublished,
}); });
setFormReady(true); setFormReady(true);
} }
@@ -219,39 +290,6 @@ export function AdminBlogEditorScreen({ postId }: PostEditorProps) {
onError: () => notify.error(t("errorGeneric")), 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) { if (!isNew && !formReady) {
return <p className="text-sm text-muted-foreground">{t("loading")}</p>; 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"> <Card className="rounded-xl border-border/80">
<CardContent className="space-y-4 pt-5"> <CardContent className="space-y-4 pt-5">
<div className="grid gap-4 sm:grid-cols-2"> <div className="grid gap-4 sm:grid-cols-2">
<Field label={t("fieldSlug")} fieldKey="slug" /> <BlogField label={t("fieldSlug")} value={form.slug} onChange={setField("slug")} dir="ltr" />
<Field label={t("fieldAuthor")} fieldKey="author" /> <BlogField label={t("fieldAuthor")} value={form.author} onChange={setField("author")} dir="rtl" />
</div> </div>
<div className="grid gap-4 sm:grid-cols-2"> <div className="grid gap-4 sm:grid-cols-2">
<Field label={t("fieldTitleFa")} fieldKey="titleFa" /> <BlogField label={t("fieldTitleFa")} value={form.titleFa} onChange={setField("titleFa")} dir="rtl" />
<Field label={t("fieldTitleEn")} fieldKey="titleEn" /> <BlogField label={t("fieldTitleEn")} value={form.titleEn} onChange={setField("titleEn")} dir="ltr" />
</div> </div>
<div className="grid gap-4 sm:grid-cols-2"> <div className="grid gap-4 sm:grid-cols-2">
<Field label={t("fieldExcerptFa")} fieldKey="excerptFa" /> <BlogField label={t("fieldExcerptFa")} value={form.excerptFa} onChange={setField("excerptFa")} dir="rtl" />
<Field label={t("fieldExcerptEn")} fieldKey="excerptEn" /> <BlogField label={t("fieldExcerptEn")} value={form.excerptEn} onChange={setField("excerptEn")} dir="ltr" />
</div> </div>
<div className="grid gap-4 sm:grid-cols-2"> <div className="grid gap-4 sm:grid-cols-2">
<Field label={t("fieldCategoryFa")} fieldKey="categoryFa" /> <BlogField label={t("fieldCategoryFa")} value={form.categoryFa} onChange={setField("categoryFa")} dir="rtl" />
<Field label={t("fieldCategoryEn")} fieldKey="categoryEn" /> <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> </div>
<Field label={t("fieldContentFa")} fieldKey="contentFa" multiline />
<Field label={t("fieldContentEn")} fieldKey="contentEn" multiline />
<div className="flex justify-end pt-2"> <div className="flex justify-end pt-2">
<Button <Button
@@ -43,6 +43,12 @@ export function DemoDataBanner({ invalidateKeys, className }: Props) {
if (!cafeId || (role !== "Owner" && role !== "Manager")) return null; if (!cafeId || (role !== "Owner" && role !== "Manager")) return null;
if (done && summary) { if (done && summary) {
const nothingAdded =
summary.categoriesAdded === 0 &&
summary.itemsAdded === 0 &&
summary.tablesAdded === 0 &&
summary.ingredientsAdded === 0 &&
!summary.taxCreated;
return ( return (
<div <div
className={cn( className={cn(
@@ -52,10 +58,16 @@ export function DemoDataBanner({ invalidateKeys, className }: Props) {
> >
<Sparkles className="size-4 shrink-0" /> <Sparkles className="size-4 shrink-0" />
<span> <span>
{nothingAdded ? (
"همه داده‌های نمونه از قبل موجود بودند — موردی اضافه نشد."
) : (
<>
دادههای نمونه اضافه شد {summary.categoriesAdded} دسته،{" "} دادههای نمونه اضافه شد {summary.categoriesAdded} دسته،{" "}
{summary.itemsAdded} آیتم، {summary.tablesAdded} میز،{" "} {summary.itemsAdded} آیتم، {summary.tablesAdded} میز،{" "}
{summary.ingredientsAdded} ماده اولیه {summary.ingredientsAdded} ماده اولیه
{summary.taxCreated ? "، مالیات ۹٪" : ""}. {summary.taxCreated ? "، مالیات ۹٪" : ""}.
</>
)}
</span> </span>
</div> </div>
); );
@@ -12,7 +12,8 @@ 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 { 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 { 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";
@@ -183,6 +184,11 @@ 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 showError = (err: unknown) =>
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";
@@ -267,6 +273,7 @@ export function MenuAdminScreen() {
setItemModalOpen(false); setItemModalOpen(false);
invalidateMenu(); invalidateMenu();
}, },
onError: showError,
}); });
const updateItemMutation = useMutation({ const updateItemMutation = useMutation({
@@ -284,12 +291,14 @@ export function MenuAdminScreen() {
setItemModalOpen(false); setItemModalOpen(false);
invalidateMenu(); invalidateMenu();
}, },
onError: showError,
}); });
const toggleItemMutation = useMutation({ const toggleItemMutation = useMutation({
mutationFn: ({ id, isAvailable }: { id: string; isAvailable: boolean }) => mutationFn: ({ id, isAvailable }: { id: string; isAvailable: boolean }) =>
apiPatch(`/api/cafes/${cafeId}/menu/items/${id}/availability`, { isAvailable }), apiPatch(`/api/cafes/${cafeId}/menu/items/${id}/availability`, { isAvailable }),
onSuccess: invalidateMenu, onSuccess: invalidateMenu,
onError: showError,
}); });
const addCategoryMutation = useMutation({ const addCategoryMutation = useMutation({
@@ -307,6 +316,7 @@ export function MenuAdminScreen() {
setCatModalOpen(false); setCatModalOpen(false);
invalidateMenu(); invalidateMenu();
}, },
onError: showError,
}); });
const updateCategoryMutation = useMutation({ const updateCategoryMutation = useMutation({
@@ -322,6 +332,7 @@ export function MenuAdminScreen() {
setCatModalOpen(false); setCatModalOpen(false);
invalidateMenu(); invalidateMenu();
}, },
onError: showError,
}); });
// ── Modal openers ────────────────────────────────────────────────────────── // ── Modal openers ──────────────────────────────────────────────────────────
@@ -451,7 +462,7 @@ export function MenuAdminScreen() {
) : ( ) : (
/* ── Catalog tab ─────────────────────────────────────────────────── */ /* ── Catalog tab ─────────────────────────────────────────────────── */
<div className="flex min-h-0 flex-col gap-4"> <div className="flex min-h-0 flex-col gap-4">
{categories.length === 0 && items.length === 0 && ( {categories.length < 5 && items.length < 10 && (
<DemoDataBanner <DemoDataBanner
invalidateKeys={[ invalidateKeys={[
["menu-categories", cafeId], ["menu-categories", cafeId],
@@ -123,7 +123,9 @@ export function TablesScreen() {
refresh(); refresh();
}, },
onError: (err) => { 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); setActionMessage(null);
refresh(); refresh();
}, },
onError: (err) => {
const msg = err instanceof ApiClientError ? err.message : t("createError");
setActionMessage(msg);
notify.error(msg);
},
}); });
const startEdit = (table: TableBoardItem) => { const startEdit = (table: TableBoardItem) => {
+56 -3
View File
@@ -1,6 +1,10 @@
import axios, { type AxiosError } from "axios"; import axios, {
import type { ApiResponse } from "./types"; type AxiosError,
type InternalAxiosRequestConfig,
} from "axios";
import type { ApiResponse, AuthTokenResponse } from "./types";
import { getOrCreateTerminalId } from "@/lib/terminal"; import { getOrCreateTerminalId } from "@/lib/terminal";
import { useAuthStore } from "@/lib/stores/auth.store";
const baseURL = const baseURL =
process.env.NEXT_PUBLIC_API_URL ?? "https://localhost:7208"; process.env.NEXT_PUBLIC_API_URL ?? "https://localhost:7208";
@@ -21,14 +25,63 @@ api.interceptors.request.use((config) => {
return 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( api.interceptors.response.use(
(response) => response, (response) => response,
async (error: AxiosError<ApiResponse<unknown>>) => { 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; const apiError = error.response?.data?.error;
if (apiError?.code) { if (apiError?.code) {
return Promise.reject(new ApiClientError(apiError.code, apiError.message)); 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 path = window.location.pathname;
const isPublicGuest = path.startsWith("/q/") || path.startsWith("/q"); const isPublicGuest = path.startsWith("/q/") || path.startsWith("/q");
const isAdmin = path.includes("/admin"); const isAdmin = path.includes("/admin");
@@ -22,11 +22,14 @@ type MarkersApiResponse = {
// ── Coordinate transform ────────────────────────────────────────────────────── // ── Coordinate transform ──────────────────────────────────────────────────────
// Iran bounding box (degrees) // Iran bounding box (degrees) — fitted to the real border extent
const MIN_LNG = 44; // (lng 44.1163.32, lat 25.0839.71) with a small margin so the
const MAX_LNG = 64; // silhouette fills the viewBox. Markers reproject with the same box,
const MIN_LAT = 24; // so they stay aligned with the outline.
const MAX_LAT = 41; const MIN_LNG = 43.6;
const MAX_LNG = 63.8;
const MIN_LAT = 24.6;
const MAX_LAT = 40.2;
const SVG_W = 600; const SVG_W = 600;
const SVG_H = 500; const SVG_H = 500;
@@ -41,34 +44,24 @@ function toPt([lng, lat]: [number, number]) {
} }
// ── Iran silhouette ──────────────────────────────────────────────────────────── // ── Iran silhouette ────────────────────────────────────────────────────────────
// Simplified 40-point polygon; approximate but recognisable. // Real national border, simplified to 74 vertices (source: Natural Earth via
// Coordinates are [longitude, latitude] going clockwise from NW. // world.geo.json). Coordinates are [longitude, latitude]; the ring starts on
// the Caspian (NE) and runs clockwise. Projected through toX/toY below, the
// same transform used for the café markers, so dots land in the right place.
const IRAN_OUTLINE: [number, number][] = [ const IRAN_OUTLINE: [number, number][] = [
// NW corner / Turkey-Armenia-Azerbaijan [53.92, 37.20], [54.80, 37.39], [55.51, 37.96], [56.18, 37.94], [56.62, 38.12], [57.33, 38.03],
[44.8, 39.6], [45.5, 39.2], [46.2, 38.9], [58.44, 37.52], [59.23, 37.41], [60.38, 36.53], [61.12, 36.49], [61.21, 35.65], [60.80, 34.40],
[46.8, 39.1], [47.6, 38.9], [60.53, 33.68], [60.96, 33.53], [60.54, 32.98], [60.86, 32.18], [60.94, 31.55], [61.70, 31.38],
// Caspian coast (the concave notch heading south then north again) [61.78, 30.74], [60.87, 29.83], [61.37, 29.30], [61.77, 28.70], [62.73, 28.26], [62.76, 27.38],
[48.3, 38.4], [49.0, 37.5], [49.9, 37.2], [63.23, 27.22], [63.32, 26.76], [61.87, 26.24], [61.50, 25.08], [59.62, 25.38], [58.53, 25.61],
[51.0, 36.9], [52.2, 36.8], [53.0, 36.7], [57.40, 25.74], [56.97, 26.97], [56.49, 27.14], [55.72, 26.96], [54.72, 26.48], [53.49, 26.81],
[54.0, 37.1], [54.7, 37.5], [52.48, 27.58], [51.52, 27.87], [50.85, 28.81], [50.12, 30.15], [49.58, 29.99], [48.94, 30.32],
// NE / Turkmenistan [48.57, 29.93], [48.01, 30.45], [48.00, 30.99], [47.69, 30.98], [47.85, 31.71], [47.33, 32.47],
[55.6, 37.4], [56.9, 37.1], [57.7, 36.8], [46.11, 33.02], [45.42, 33.97], [45.65, 34.75], [46.15, 35.09], [46.08, 35.68], [45.42, 35.98],
[58.7, 37.5], [59.4, 36.8], [60.1, 36.7], [44.77, 37.17], [44.23, 37.97], [44.42, 38.28], [44.11, 39.43], [44.79, 39.71], [44.95, 39.34],
// East / Afghanistan [45.46, 38.87], [46.14, 38.74], [46.51, 38.77], [47.69, 39.51], [48.06, 39.58], [48.36, 39.29],
[61.2, 36.5], [61.3, 35.7], [62.0, 35.5], [48.01, 38.79], [48.63, 38.27], [48.88, 38.32], [49.20, 37.58], [50.15, 37.37], [50.84, 36.87],
[62.5, 34.0], [63.0, 33.0], [63.2, 31.5], [52.26, 36.70], [53.83, 36.97],
// SE / Pakistan Oman Sea
[61.8, 29.8], [60.9, 29.5], [60.0, 27.5],
[59.0, 25.9], [58.5, 25.4],
// South coast (Persian Gulf, west-bound)
[57.5, 25.3], [56.4, 25.9], [55.6, 26.0],
[54.5, 27.0], [53.4, 27.3], [52.4, 28.0],
[51.1, 28.4], [50.4, 29.1], [49.0, 29.6],
[48.5, 30.2], [48.2, 30.8],
// West / Iraq border
[47.7, 31.0], [47.2, 32.0], [46.8, 33.2],
[46.2, 34.4], [45.5, 36.0], [45.0, 37.0],
[44.8, 38.1], [44.5, 38.9], [44.8, 39.6],
]; ];
const IRAN_PATH = const IRAN_PATH =
@@ -153,42 +146,50 @@ async function IranMapSvg() {
</g> </g>
))} ))}
{/* Café blinking dots */} {/* Café markers — each glows slowly on and off like a small lamp.
Halo and core brighten/dim together (ease-in-out), staggered so the
map twinkles organically rather than pulsing in unison. */}
{markers.map((m, idx) => { {markers.map((m, idx) => {
const cx = toX(m.longitude); const cx = toX(m.longitude);
const cy = toY(m.latitude); const cy = toY(m.latitude);
// Stagger animation delay so dots don't all pulse in sync const delay = `${((idx * 0.7) % 3.6).toFixed(2)}s`;
const delay = `${(idx * 0.4) % 2}s`; const dur = "3.6s";
// ease-in-out for a smooth lamp-like fade
const ease = "0.4 0 0.6 1; 0.4 0 0.6 1";
return ( return (
<g key={m.id} filter="url(#glow)"> <g key={m.id} filter="url(#glow)">
{/* Outer pulse ring */} {/* Soft halo */}
<circle cx={cx} cy={cy} r={10} fill="#0F6E56" opacity={0.2}> <circle cx={cx} cy={cy} r={9} fill="#0F6E56">
<animate
attributeName="r"
values="8;16;8"
dur="2.4s"
begin={delay}
repeatCount="indefinite"
/>
<animate <animate
attributeName="opacity" attributeName="opacity"
values="0.25;0;0.25" values="0.45;0.04;0.45"
dur="2.4s" keyTimes="0;0.5;1"
calcMode="spline"
keySplines={ease}
dur={dur}
begin={delay} begin={delay}
repeatCount="indefinite" repeatCount="indefinite"
/> />
</circle> </circle>
{/* Core dot */} {/* Core dot — turns on (bright, slightly larger) and off (dim) */}
<circle <circle cx={cx} cy={cy} r={4.5} fill="#0F6E56">
cx={cx}
cy={cy}
r={5}
fill="#0F6E56"
>
<animate <animate
attributeName="opacity" attributeName="opacity"
values="1;0.5;1" values="1;0.2;1"
dur="2.4s" keyTimes="0;0.5;1"
calcMode="spline"
keySplines={ease}
dur={dur}
begin={delay}
repeatCount="indefinite"
/>
<animate
attributeName="r"
values="4.5;5.6;4.5"
keyTimes="0;0.5;1"
calcMode="spline"
keySplines={ease}
dur={dur}
begin={delay} begin={delay}
repeatCount="indefinite" repeatCount="indefinite"
/> />