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

Dashboard & API bug fixes for owner-reported breakage:

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

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
soroush.asadi
2026-06-01 18:23:31 +03:30
parent f687178238
commit 024a455ab3
10 changed files with 217 additions and 60 deletions
+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