Compare commits

4 Commits

Author SHA1 Message Date
soroush.asadi 8ce0b3e3e8 feat(discover): seed showcase café coordinates so the map shows blinking lights
CI/CD / CI · API (dotnet build + test) (push) Successful in 40s
CI/CD / CI · Admin API (dotnet build) (push) Successful in 32s
CI/CD / CI · Dashboard (tsc) (push) Successful in 1m4s
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 48s
CI/CD / Deploy · all services (push) Successful in 4m11s
Showcase cafés (dev/staging only) now get Latitude/Longitude scattered around their real city (Tehran/Karaj) with a deterministic per-id offset, so the homepage Iran map renders a realistic cluster of blinking merchant lights. Backfills existing rows where coords are null. Production cafés get coordinates when owners set their location in dashboard Settings.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-01 22:00:14 +03:30
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
20 changed files with 390 additions and 118 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);
}
@@ -11,6 +11,28 @@ namespace Meezi.Infrastructure.Data;
/// <summary>Seeds 30 Persian showcase cafés for public discover (development only).</summary>
public static class DiscoverShowcaseSeeder
{
// Approximate city centres. Each café is scattered around its city with a
// small deterministic offset (derived from its id) so the marketing map
// shows a realistic cluster of blinking lights instead of one stacked dot.
private static readonly Dictionary<string, (double Lat, double Lng, double Spread)> CityGeo = new()
{
["تهران"] = (35.70, 51.39, 0.13),
["کرج"] = (35.83, 50.99, 0.07),
};
private static (double Lat, double Lng) GeoFor(string id, string city)
{
var (lat, lng, spread) = CityGeo.TryGetValue(city, out var g) ? g : (35.70, 51.39, 0.13);
unchecked
{
var h = 17;
foreach (var ch in id) h = (h * 31) + ch;
var ox = (((h & 0xFFFF) / 65535.0) - 0.5) * 2 * spread;
var oy = ((((h >> 16) & 0xFFFF) / 65535.0) - 0.5) * 2 * spread;
return (Math.Round(lat + oy, 5), Math.Round(lng + ox, 5));
}
}
private static readonly string[] ReviewAuthors = ["سارا", "علی", "مینا", "رضا", "نازنین"];
private static readonly string[] ReviewComments =
[
@@ -27,6 +49,7 @@ public static class DiscoverShowcaseSeeder
foreach (var spec in DiscoverShowcaseCatalog.Cafes)
{
var cafe = await db.Cafes.FirstOrDefaultAsync(c => c.Id == spec.Id);
var (geoLat, geoLng) = GeoFor(spec.Id, spec.City);
if (cafe is null)
{
cafe = new Cafe
@@ -37,6 +60,8 @@ public static class DiscoverShowcaseSeeder
Slug = spec.Slug,
City = spec.City,
Address = spec.Address,
Latitude = geoLat,
Longitude = geoLng,
Description = spec.Description,
PlanTier = spec.PlanTier,
PreferredLanguage = "fa",
@@ -100,6 +125,12 @@ public static class DiscoverShowcaseSeeder
cafe.IsVerified = true;
changed = true;
}
if (cafe.Latitude is null || cafe.Longitude is null)
{
cafe.Latitude = geoLat;
cafe.Longitude = geoLng;
changed = true;
}
if (changed)
await db.SaveChangesAsync();
}
+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");
@@ -22,11 +22,14 @@ type MarkersApiResponse = {
// ── Coordinate transform ──────────────────────────────────────────────────────
// Iran bounding box (degrees)
const MIN_LNG = 44;
const MAX_LNG = 64;
const MIN_LAT = 24;
const MAX_LAT = 41;
// Iran bounding box (degrees) — fitted to the real border extent
// (lng 44.1163.32, lat 25.0839.71) with a small margin so the
// silhouette fills the viewBox. Markers reproject with the same box,
// so they stay aligned with the outline.
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_H = 500;
@@ -41,34 +44,24 @@ function toPt([lng, lat]: [number, number]) {
}
// ── Iran silhouette ────────────────────────────────────────────────────────────
// Simplified 40-point polygon; approximate but recognisable.
// Coordinates are [longitude, latitude] going clockwise from NW.
// Real national border, simplified to 74 vertices (source: Natural Earth via
// 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][] = [
// NW corner / Turkey-Armenia-Azerbaijan
[44.8, 39.6], [45.5, 39.2], [46.2, 38.9],
[46.8, 39.1], [47.6, 38.9],
// Caspian coast (the concave notch heading south then north again)
[48.3, 38.4], [49.0, 37.5], [49.9, 37.2],
[51.0, 36.9], [52.2, 36.8], [53.0, 36.7],
[54.0, 37.1], [54.7, 37.5],
// NE / Turkmenistan
[55.6, 37.4], [56.9, 37.1], [57.7, 36.8],
[58.7, 37.5], [59.4, 36.8], [60.1, 36.7],
// East / Afghanistan
[61.2, 36.5], [61.3, 35.7], [62.0, 35.5],
[62.5, 34.0], [63.0, 33.0], [63.2, 31.5],
// 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],
[53.92, 37.20], [54.80, 37.39], [55.51, 37.96], [56.18, 37.94], [56.62, 38.12], [57.33, 38.03],
[58.44, 37.52], [59.23, 37.41], [60.38, 36.53], [61.12, 36.49], [61.21, 35.65], [60.80, 34.40],
[60.53, 33.68], [60.96, 33.53], [60.54, 32.98], [60.86, 32.18], [60.94, 31.55], [61.70, 31.38],
[61.78, 30.74], [60.87, 29.83], [61.37, 29.30], [61.77, 28.70], [62.73, 28.26], [62.76, 27.38],
[63.23, 27.22], [63.32, 26.76], [61.87, 26.24], [61.50, 25.08], [59.62, 25.38], [58.53, 25.61],
[57.40, 25.74], [56.97, 26.97], [56.49, 27.14], [55.72, 26.96], [54.72, 26.48], [53.49, 26.81],
[52.48, 27.58], [51.52, 27.87], [50.85, 28.81], [50.12, 30.15], [49.58, 29.99], [48.94, 30.32],
[48.57, 29.93], [48.01, 30.45], [48.00, 30.99], [47.69, 30.98], [47.85, 31.71], [47.33, 32.47],
[46.11, 33.02], [45.42, 33.97], [45.65, 34.75], [46.15, 35.09], [46.08, 35.68], [45.42, 35.98],
[44.77, 37.17], [44.23, 37.97], [44.42, 38.28], [44.11, 39.43], [44.79, 39.71], [44.95, 39.34],
[45.46, 38.87], [46.14, 38.74], [46.51, 38.77], [47.69, 39.51], [48.06, 39.58], [48.36, 39.29],
[48.01, 38.79], [48.63, 38.27], [48.88, 38.32], [49.20, 37.58], [50.15, 37.37], [50.84, 36.87],
[52.26, 36.70], [53.83, 36.97],
];
const IRAN_PATH =
@@ -153,42 +146,50 @@ async function IranMapSvg() {
</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) => {
const cx = toX(m.longitude);
const cy = toY(m.latitude);
// Stagger animation delay so dots don't all pulse in sync
const delay = `${(idx * 0.4) % 2}s`;
const delay = `${((idx * 0.7) % 3.6).toFixed(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 (
<g key={m.id} filter="url(#glow)">
{/* Outer pulse ring */}
<circle cx={cx} cy={cy} r={10} fill="#0F6E56" opacity={0.2}>
<animate
attributeName="r"
values="8;16;8"
dur="2.4s"
begin={delay}
repeatCount="indefinite"
/>
{/* Soft halo */}
<circle cx={cx} cy={cy} r={9} fill="#0F6E56">
<animate
attributeName="opacity"
values="0.25;0;0.25"
dur="2.4s"
values="0.45;0.04;0.45"
keyTimes="0;0.5;1"
calcMode="spline"
keySplines={ease}
dur={dur}
begin={delay}
repeatCount="indefinite"
/>
</circle>
{/* Core dot */}
<circle
cx={cx}
cy={cy}
r={5}
fill="#0F6E56"
>
{/* Core dot — turns on (bright, slightly larger) and off (dim) */}
<circle cx={cx} cy={cy} r={4.5} fill="#0F6E56">
<animate
attributeName="opacity"
values="1;0.5;1"
dur="2.4s"
values="1;0.2;1"
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}
repeatCount="indefinite"
/>