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:
@@ -1057,6 +1057,7 @@
|
||||
"fieldCategoryEn": "الفئة بالإنجليزية",
|
||||
"fieldContentFa": "المحتوى بالفارسية (Markdown)",
|
||||
"fieldContentEn": "المحتوى بالإنجليزية (Markdown)",
|
||||
"fieldPublished": "منشور",
|
||||
"commentsTitle": "إدارة التعليقات",
|
||||
"noComments": "لا توجد تعليقات",
|
||||
"approved": "موافق عليه",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user