fix: credentials lost on refresh + admin UI improvements + CI safe deploy
CI/CD / CI · API (dotnet build + test) (push) Successful in 39s
CI/CD / CI · Admin API (dotnet build) (push) Successful in 30s
CI/CD / CI · Dashboard (tsc) (push) Successful in 1m2s
CI/CD / CI · Admin Web (tsc) (push) Failing after 35s
CI/CD / CI · Website (tsc) (push) Successful in 44s
CI/CD / CI · Koja (tsc) (push) Successful in 48s
CI/CD / Deploy · all services (push) Has been skipped

- dashboard layout: wait for Zustand _hasHydrated before redirecting to /login
  (was redirecting on first render before localStorage was read)
- admin shell: same fix using new _hasHydrated on admin auth store
- admin-auth.store: add _hasHydrated + onRehydrateStorage to mirror merchant store
- AdminPlansScreen: replace direct cache mutation with per-plan PlanCard component
  that owns its own useState — fixes other plans disappearing after save
- AdminSettingsScreen: detect boolean values and render iOS-style Toggle switches
- AdminIntegrationsScreen: replace all <input type=checkbox> with Toggle switches;
  replace OpenAI model text input with <select> dropdown (gpt-4o-mini/4o/4-turbo/4/3.5)
- blog editor: fix form never syncing existing post data into state (editing was broken);
  all fields now use local form state, save uses form directly
- blog links: fix broken relative hrefs (website/blog/new → /admin/website/blog/new)
  and back button using proper Link components
- ci-cd: remove image prune step entirely — never removes containers or images

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
soroush.asadi
2026-05-31 23:56:16 +03:30
parent aec5b21f98
commit ae5896d440
6 changed files with 239 additions and 227 deletions
+1 -9
View File
@@ -349,12 +349,4 @@ jobs:
if: always() if: always()
run: docker compose -f docker-compose.yml -f docker-compose.admin.yml ps run: docker compose -f docker-compose.yml -f docker-compose.admin.yml ps
- name: Prune old meezi images # Intentionally no image pruning — disk cleanup is done manually on the server.
if: success()
# Only remove untagged (dangling) meezi images — never touches other projects
run: |
docker images --format '{{.Repository}}:{{.Tag}} {{.ID}}' \
| grep '^hostexecutor-' \
| grep '<none>' \
| awk '{print $2}' \
| xargs -r docker rmi || true
+142 -90
View File
@@ -5,6 +5,7 @@ import { useTranslations } from "next-intl";
import { useEffect, useState } from "react"; import { useEffect, useState } from "react";
import { useParams } from "next/navigation"; import { useParams } from "next/navigation";
import { Link } from "@/i18n/routing"; import { Link } from "@/i18n/routing";
import { cn } from "@/lib/utils";
import { import {
adminDelete, adminDelete,
adminGet, adminGet,
@@ -37,6 +38,30 @@ import {
type TicketStatus, type TicketStatus,
} from "@/components/support/ticket-status-badge"; } from "@/components/support/ticket-status-badge";
// iOS-style toggle switch used throughout this file
function Toggle({ checked, onChange, disabled }: { checked: boolean; onChange: (v: boolean) => void; disabled?: boolean }) {
return (
<button
type="button"
role="switch"
aria-checked={checked}
disabled={disabled}
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 disabled:cursor-not-allowed disabled:opacity-50",
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>
);
}
export function AdminDashboardScreen() { export function AdminDashboardScreen() {
const t = useTranslations("admin.dashboard"); const t = useTranslations("admin.dashboard");
const { data } = useQuery({ const { data } = useQuery({
@@ -78,6 +103,50 @@ function StatCard({ label, value }: { label: string; value: number }) {
); );
} }
function PlanCard({ plan, onSave }: { plan: AdminPlan; onSave: (p: AdminPlan) => void }) {
const t = useTranslations("admin.plans");
const [price, setPrice] = useState(plan.monthlyPriceToman);
const [maxOrders, setMaxOrders] = useState(plan.limits.maxOrdersPerDay);
// Sync server values if they change (e.g. after successful save + refetch)
useEffect(() => { setPrice(plan.monthlyPriceToman); }, [plan.monthlyPriceToman]);
useEffect(() => { setMaxOrders(plan.limits.maxOrdersPerDay); }, [plan.limits.maxOrdersPerDay]);
const flush = () =>
onSave({ ...plan, monthlyPriceToman: price, limits: { ...plan.limits, maxOrdersPerDay: maxOrders } });
return (
<Card className="rounded-xl border border-border/80">
<CardHeader className="pb-2">
<CardTitle className="text-base">{plan.displayNameFa}</CardTitle>
<p className="text-xs text-muted-foreground">{plan.tier}</p>
</CardHeader>
<CardContent className="grid gap-3 sm:grid-cols-2">
<label className="text-sm">
{t("monthlyPrice")}
<Input
type="number"
className="mt-1"
value={price}
onChange={(e) => setPrice(Number(e.target.value))}
onBlur={flush}
/>
</label>
<label className="text-sm">
{t("maxOrders")}
<Input
type="number"
className="mt-1"
value={maxOrders}
onChange={(e) => setMaxOrders(Number(e.target.value))}
onBlur={flush}
/>
</label>
</CardContent>
</Card>
);
}
export function AdminPlansScreen() { export function AdminPlansScreen() {
const t = useTranslations("admin.plans"); const t = useTranslations("admin.plans");
const qc = useQueryClient(); const qc = useQueryClient();
@@ -108,38 +177,7 @@ export function AdminPlansScreen() {
<div className="space-y-4"> <div className="space-y-4">
<h1 className="text-lg font-medium">{t("title")}</h1> <h1 className="text-lg font-medium">{t("title")}</h1>
{plans.map((plan) => ( {plans.map((plan) => (
<Card key={plan.tier} className="rounded-xl border border-border/80"> <PlanCard key={plan.tier} plan={plan} onSave={(p) => save.mutate(p)} />
<CardHeader className="pb-2">
<CardTitle className="text-base">{plan.displayNameFa}</CardTitle>
<p className="text-xs text-muted-foreground">{plan.tier}</p>
</CardHeader>
<CardContent className="grid gap-3 sm:grid-cols-2">
<label className="text-sm">
{t("monthlyPrice")}
<Input
type="number"
className="mt-1"
value={plan.monthlyPriceToman}
onChange={(e) => {
plan.monthlyPriceToman = Number(e.target.value);
}}
onBlur={() => save.mutate(plan)}
/>
</label>
<label className="text-sm">
{t("maxOrders")}
<Input
type="number"
className="mt-1"
defaultValue={plan.limits.maxOrdersPerDay}
onBlur={(e) => {
plan.limits.maxOrdersPerDay = Number(e.target.value);
save.mutate(plan);
}}
/>
</label>
</CardContent>
</Card>
))} ))}
</div> </div>
); );
@@ -166,17 +204,34 @@ export function AdminSettingsScreen() {
<div className="space-y-4"> <div className="space-y-4">
<h1 className="text-lg font-medium">{t("title")}</h1> <h1 className="text-lg font-medium">{t("title")}</h1>
<div className="space-y-2"> <div className="space-y-2">
{settings.map((s) => ( {settings.map((s) => {
<Card key={s.id} className="rounded-xl border border-border/80 p-4"> const isBool = s.value === "true" || s.value === "false";
<p className="text-xs text-muted-foreground">{s.key}</p> return (
<p className="text-[11px] text-muted-foreground">{s.descriptionFa}</p> <Card key={s.id} className="rounded-xl border border-border/80 p-4">
<Input <div className={isBool ? "flex items-center justify-between gap-3" : undefined}>
className="mt-2" <div className="min-w-0">
defaultValue={s.value} <p className="text-sm font-medium text-foreground">{s.key}</p>
onBlur={(e) => save.mutate({ key: s.key, value: e.target.value })} {s.descriptionFa ? (
/> <p className="text-[11px] text-muted-foreground">{s.descriptionFa}</p>
</Card> ) : null}
))} </div>
{isBool ? (
<Toggle
checked={s.value === "true"}
onChange={(v) => save.mutate({ key: s.key, value: String(v) })}
disabled={save.isPending}
/>
) : (
<Input
className="mt-2"
defaultValue={s.value}
onBlur={(e) => save.mutate({ key: s.key, value: e.target.value })}
/>
)}
</div>
</Card>
);
})}
</div> </div>
</div> </div>
); );
@@ -588,23 +643,21 @@ export function AdminIntegrationsScreen() {
<Badge className="bg-[#E1F5EE] text-[#0F6E56]">{t("active")}</Badge> <Badge className="bg-[#E1F5EE] text-[#0F6E56]">{t("active")}</Badge>
) : null} ) : null}
</div> </div>
<label className="flex items-center gap-2 text-sm"> <div className="flex items-center gap-2 text-sm">
<input <Toggle
type="checkbox"
checked={g.isEnabled} checked={g.isEnabled}
onChange={(e) => updateGateway(g.id, { isEnabled: e.target.checked })} onChange={(v) => updateGateway(g.id, { isEnabled: v })}
/> />
{t("enabled")} <span>{t("enabled")}</span>
</label> </div>
</div> </div>
<label className="flex items-center gap-2 text-sm text-muted-foreground"> <div className="flex items-center gap-2 text-sm text-muted-foreground">
<input <Toggle
type="checkbox"
checked={g.sandbox} checked={g.sandbox}
onChange={(e) => updateGateway(g.id, { sandbox: e.target.checked })} onChange={(v) => updateGateway(g.id, { sandbox: v })}
/> />
{t("sandbox")} <span>{t("sandbox")}</span>
</label> </div>
{g.id === "zarinpal" ? ( {g.id === "zarinpal" ? (
<label className="block text-sm"> <label className="block text-sm">
{t("merchantId")} {t("merchantId")}
@@ -779,14 +832,13 @@ export function AdminIntegrationsScreen() {
{t("kavenegarTitle")} {t("kavenegarTitle")}
</p> </p>
<Card className="rounded-xl border border-border/80 p-4 space-y-3"> <Card className="rounded-xl border border-border/80 p-4 space-y-3">
<label className="flex items-center gap-2 text-sm"> <div className="flex items-center gap-2 text-sm">
<input <Toggle
type="checkbox"
checked={kavenegar.isEnabled} checked={kavenegar.isEnabled}
onChange={(e) => setKavenegar((k) => ({ ...k, isEnabled: e.target.checked }))} onChange={(v) => setKavenegar((k) => ({ ...k, isEnabled: v }))}
/> />
{t("enabled")} <span>{t("enabled")}</span>
</label> </div>
<label className="block text-sm"> <label className="block text-sm">
{t("apiKey")} {t("apiKey")}
<Input <Input
@@ -815,14 +867,13 @@ export function AdminIntegrationsScreen() {
<Card className="rounded-xl border border-border/80 p-4 space-y-3"> <Card className="rounded-xl border border-border/80 p-4 space-y-3">
<p className="text-sm font-medium">{t("openAiTitle")}</p> <p className="text-sm font-medium">{t("openAiTitle")}</p>
<p className="text-xs text-muted-foreground">{t("openAiHint")}</p> <p className="text-xs text-muted-foreground">{t("openAiHint")}</p>
<label className="flex items-center gap-2 text-sm"> <div className="flex items-center gap-2 text-sm">
<input <Toggle
type="checkbox"
checked={openAi.isEnabled} checked={openAi.isEnabled}
onChange={(e) => setOpenAi((o) => ({ ...o, isEnabled: e.target.checked }))} onChange={(v) => setOpenAi((o) => ({ ...o, isEnabled: v }))}
/> />
{t("enabled")} <span>{t("enabled")}</span>
</label> </div>
<label className="block text-sm"> <label className="block text-sm">
{t("openAiApiKey")} {t("openAiApiKey")}
<Input <Input
@@ -836,35 +887,37 @@ export function AdminIntegrationsScreen() {
</label> </label>
<label className="block text-sm"> <label className="block text-sm">
{t("openAiModel")} {t("openAiModel")}
<Input <select
className="mt-1" className="mt-1 block w-full rounded-md border border-input bg-background px-3 py-2 text-sm"
dir="ltr" dir="ltr"
value={openAi.model} value={openAi.model}
onChange={(e) => setOpenAi((o) => ({ ...o, model: e.target.value }))} onChange={(e) => setOpenAi((o) => ({ ...o, model: e.target.value }))}
/> >
<option value="gpt-4o-mini">gpt-4o-mini (fast, cheap)</option>
<option value="gpt-4o">gpt-4o (best quality)</option>
<option value="gpt-4-turbo">gpt-4-turbo</option>
<option value="gpt-4">gpt-4</option>
<option value="gpt-3.5-turbo">gpt-3.5-turbo (legacy)</option>
</select>
</label> </label>
<label className="flex items-center gap-2 text-sm"> <div className="flex items-center gap-2 text-sm">
<input <Toggle
type="checkbox"
checked={openAi.coffeeAdvisorEnabled} checked={openAi.coffeeAdvisorEnabled}
onChange={(e) => onChange={(v) => setOpenAi((o) => ({ ...o, coffeeAdvisorEnabled: v }))}
setOpenAi((o) => ({ ...o, coffeeAdvisorEnabled: e.target.checked }))
}
/> />
{t("coffeeAdvisorEnabled")} <span>{t("coffeeAdvisorEnabled")}</span>
</label> </div>
</Card> </Card>
<Card className="rounded-xl border border-border/80 p-4 space-y-3"> <Card className="rounded-xl border border-border/80 p-4 space-y-3">
<p className="text-sm font-medium">{t("meshyTitle")}</p> <p className="text-sm font-medium">{t("meshyTitle")}</p>
<p className="text-xs text-muted-foreground">{t("meshyHint")}</p> <p className="text-xs text-muted-foreground">{t("meshyHint")}</p>
<label className="flex items-center gap-2 text-sm"> <div className="flex items-center gap-2 text-sm">
<input <Toggle
type="checkbox"
checked={meshy.isEnabled} checked={meshy.isEnabled}
onChange={(e) => setMeshy((m) => ({ ...m, isEnabled: e.target.checked }))} onChange={(v) => setMeshy((m) => ({ ...m, isEnabled: v }))}
/> />
{t("enabled")} <span>{t("enabled")}</span>
</label> </div>
<label className="block text-sm"> <label className="block text-sm">
{t("meshyApiKey")} {t("meshyApiKey")}
<Input <Input
@@ -876,14 +929,13 @@ export function AdminIntegrationsScreen() {
onChange={(e) => setMeshy((m) => ({ ...m, apiKey: e.target.value }))} onChange={(e) => setMeshy((m) => ({ ...m, apiKey: e.target.value }))}
/> />
</label> </label>
<label className="flex items-center gap-2 text-sm"> <div className="flex items-center gap-2 text-sm">
<input <Toggle
type="checkbox"
checked={meshy.menu3dEnabled} checked={meshy.menu3dEnabled}
onChange={(e) => setMeshy((m) => ({ ...m, menu3dEnabled: e.target.checked }))} onChange={(v) => setMeshy((m) => ({ ...m, menu3dEnabled: v }))}
/> />
{t("menu3dEnabled")} <span>{t("menu3dEnabled")}</span>
</label> </div>
</Card> </Card>
</section> </section>
</div> </div>
@@ -44,11 +44,12 @@ export function AdminShell({ children }: { children: React.ReactNode }) {
const pathname = usePathname(); const pathname = usePathname();
const router = useRouter(); const router = useRouter();
const user = useAdminAuthStore((s) => s.user); const user = useAdminAuthStore((s) => s.user);
const hasHydrated = useAdminAuthStore((s) => s._hasHydrated);
const clearAuth = useAdminAuthStore((s) => s.clearAuth); const clearAuth = useAdminAuthStore((s) => s.clearAuth);
useEffect(() => { useEffect(() => {
if (!user?.accessToken) router.replace("/admin/login"); if (hasHydrated && !user?.accessToken) router.replace("/admin/login");
}, [user, router]); }, [user, hasHydrated, router]);
return ( return (
<div className="flex min-h-screen bg-muted/30" dir="rtl"> <div className="flex min-h-screen bg-muted/30" dir="rtl">
@@ -15,6 +15,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 { Link } from "@/i18n/routing";
import { import {
CheckCircle2, CheckCircle2,
XCircle, XCircle,
@@ -66,13 +67,13 @@ export function AdminBlogListScreen() {
<div className="space-y-4"> <div className="space-y-4">
<div className="flex items-center justify-between"> <div className="flex items-center justify-between">
<h1 className="text-lg font-medium">{t("blogTitle")}</h1> <h1 className="text-lg font-medium">{t("blogTitle")}</h1>
<a <Link
href="website/blog/new" href="/admin/website/blog/new"
className="inline-flex items-center gap-1.5 rounded-lg bg-primary px-3 py-2 text-sm font-medium text-primary-foreground hover:bg-primary/90" className="inline-flex items-center gap-1.5 rounded-lg bg-primary px-3 py-2 text-sm font-medium text-primary-foreground hover:bg-primary/90"
> >
<Plus className="size-4" /> <Plus className="size-4" />
{t("newPost")} {t("newPost")}
</a> </Link>
</div> </div>
{isLoading ? ( {isLoading ? (
@@ -113,7 +114,7 @@ export function AdminBlogListScreen() {
asChild asChild
className="h-8 px-2 text-xs" className="h-8 px-2 text-xs"
> >
<a href={`website/blog/${post.id}`}>{t("edit")}</a> <Link href={`/admin/website/blog/${post.id}`}>{t("edit")}</Link>
</Button> </Button>
<Button <Button
size="sm" size="sm"
@@ -162,7 +163,7 @@ export function AdminBlogEditorScreen({ postId }: PostEditorProps) {
enabled: !isNew, enabled: !isNew,
}); });
const [form, setForm] = useState({ const emptyForm = {
slug: "", slug: "",
titleFa: "", titleFa: "",
titleEn: "", titleEn: "",
@@ -173,30 +174,38 @@ export function AdminBlogEditorScreen({ postId }: PostEditorProps) {
author: "تیم میزی", author: "تیم میزی",
categoryFa: "", categoryFa: "",
categoryEn: "", categoryEn: "",
}); };
// Sync fetched data into form once loaded const [form, setForm] = useState(emptyForm);
const initialised = !isNew && post; const [formReady, setFormReady] = useState(isNew);
const displayForm = initialised
? { // Populate form from server data the first time it arrives
slug: post!.slug, useEffect(() => {
titleFa: post!.titleFa, if (post && !formReady) {
titleEn: post!.titleEn, setForm({
excerptFa: post!.excerptFa, slug: post.slug,
excerptEn: post!.excerptEn, titleFa: post.titleFa,
contentFa: post!.contentFa, titleEn: post.titleEn,
contentEn: post!.contentEn, excerptFa: post.excerptFa,
author: post!.author, excerptEn: post.excerptEn,
categoryFa: post!.categoryFa, contentFa: post.contentFa,
categoryEn: post!.categoryEn, contentEn: post.contentEn,
} author: post.author,
: form; categoryFa: post.categoryFa,
categoryEn: post.categoryEn,
});
setFormReady(true);
}
}, [post, formReady]);
const setField = (key: keyof typeof form) => (v: string) =>
setForm((f) => ({ ...f, [key]: v }));
const saveMut = useMutation({ const saveMut = useMutation({
mutationFn: (data: typeof form) => mutationFn: () =>
isNew isNew
? adminPost("/api/admin/website/posts", data) ? adminPost("/api/admin/website/posts", form)
: adminPut(`/api/admin/website/posts/${postId}`, data), : adminPut(`/api/admin/website/posts/${postId}`, form),
onSuccess: () => { onSuccess: () => {
qc.invalidateQueries({ queryKey: ["admin", "website", "blog"] }); qc.invalidateQueries({ queryKey: ["admin", "website", "blog"] });
notify.success(t("saved")); notify.success(t("saved"));
@@ -206,52 +215,49 @@ export function AdminBlogEditorScreen({ postId }: PostEditorProps) {
const Field = ({ const Field = ({
label, label,
value, fieldKey,
onChange,
multiline, multiline,
}: { }: {
label: string; label: string;
value: string; fieldKey: keyof typeof form;
onChange: (v: string) => void;
multiline?: boolean; multiline?: boolean;
}) => ( }) => {
<div> const isFa = label.toLowerCase().includes("fa") || label.includes("فارسی") || label.includes("فا");
<label className="mb-1 block text-xs font-medium text-muted-foreground">{label}</label> return (
{multiline ? ( <div>
<textarea <label className="mb-1 block text-xs font-medium text-muted-foreground">{label}</label>
rows={8} {multiline ? (
value={value} <textarea
onChange={(e) => onChange(e.target.value)} rows={8}
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" value={form[fieldKey]}
dir={label.toLowerCase().includes("fa") || label.includes("فارسی") ? "rtl" : "ltr"} 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={value} ) : (
onChange={(e) => onChange(e.target.value)} <Input
className="h-9 text-sm" value={form[fieldKey]}
dir={label.toLowerCase().includes("fa") || label.includes("فارسی") ? "rtl" : "ltr"} onChange={(e) => setField(fieldKey)(e.target.value)}
/> className="h-9 text-sm"
)} dir={isFa ? "rtl" : "ltr"}
</div> />
); )}
</div>
const current = initialised ? post! : form; );
const setField = (key: keyof typeof form) => (v: string) => {
if (initialised) {
// We'd need local state override — keep it simple for demo
}
setForm((f) => ({ ...f, [key]: v }));
}; };
if (!isNew && !formReady) {
return <p className="text-sm text-muted-foreground">{t("loading")}</p>;
}
return ( return (
<div className="space-y-4"> <div className="space-y-4">
<div className="flex items-center gap-3"> <div className="flex items-center gap-3">
<Button variant="ghost" size="sm" asChild> <Button variant="ghost" size="sm" asChild>
<a href="." className="flex items-center gap-1.5"> <Link href="/admin/website/blog" className="flex items-center gap-1.5">
<ArrowLeft className="size-4" /> <ArrowLeft className="size-4" />
{t("backToBlog")} {t("backToBlog")}
</a> </Link>
</Button> </Button>
<h1 className="text-lg font-medium"> <h1 className="text-lg font-medium">
{isNew ? t("newPost") : t("editPost")} {isNew ? t("newPost") : t("editPost")}
@@ -261,80 +267,27 @@ 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 <Field label={t("fieldSlug")} fieldKey="slug" />
label={t("fieldSlug")} <Field label={t("fieldAuthor")} fieldKey="author" />
value={isNew ? form.slug : (post?.slug ?? "")}
onChange={setField("slug")}
/>
<Field
label={t("fieldAuthor")}
value={isNew ? form.author : (post?.author ?? "")}
onChange={setField("author")}
/>
</div> </div>
<div className="grid gap-4 sm:grid-cols-2"> <div className="grid gap-4 sm:grid-cols-2">
<Field <Field label={t("fieldTitleFa")} fieldKey="titleFa" />
label={t("fieldTitleFa")} <Field label={t("fieldTitleEn")} fieldKey="titleEn" />
value={isNew ? form.titleFa : (post?.titleFa ?? "")}
onChange={setField("titleFa")}
/>
<Field
label={t("fieldTitleEn")}
value={isNew ? form.titleEn : (post?.titleEn ?? "")}
onChange={setField("titleEn")}
/>
</div> </div>
<div className="grid gap-4 sm:grid-cols-2"> <div className="grid gap-4 sm:grid-cols-2">
<Field <Field label={t("fieldExcerptFa")} fieldKey="excerptFa" />
label={t("fieldExcerptFa")} <Field label={t("fieldExcerptEn")} fieldKey="excerptEn" />
value={isNew ? form.excerptFa : (post?.excerptFa ?? "")}
onChange={setField("excerptFa")}
/>
<Field
label={t("fieldExcerptEn")}
value={isNew ? form.excerptEn : (post?.excerptEn ?? "")}
onChange={setField("excerptEn")}
/>
</div> </div>
<div className="grid gap-4 sm:grid-cols-2"> <div className="grid gap-4 sm:grid-cols-2">
<Field <Field label={t("fieldCategoryFa")} fieldKey="categoryFa" />
label={t("fieldCategoryFa")} <Field label={t("fieldCategoryEn")} fieldKey="categoryEn" />
value={isNew ? form.categoryFa : (post?.categoryFa ?? "")}
onChange={setField("categoryFa")}
/>
<Field
label={t("fieldCategoryEn")}
value={isNew ? form.categoryEn : (post?.categoryEn ?? "")}
onChange={setField("categoryEn")}
/>
</div> </div>
<Field <Field label={t("fieldContentFa")} fieldKey="contentFa" multiline />
label={t("fieldContentFa")} <Field label={t("fieldContentEn")} fieldKey="contentEn" multiline />
value={isNew ? form.contentFa : (post?.contentFa ?? "")}
onChange={setField("contentFa")}
multiline
/>
<Field
label={t("fieldContentEn")}
value={isNew ? form.contentEn : (post?.contentEn ?? "")}
onChange={setField("contentEn")}
multiline
/>
<div className="flex justify-end pt-2"> <div className="flex justify-end pt-2">
<Button <Button
onClick={() => saveMut.mutate(isNew ? form : { onClick={() => saveMut.mutate()}
slug: post?.slug ?? form.slug,
titleFa: post?.titleFa ?? form.titleFa,
titleEn: post?.titleEn ?? form.titleEn,
excerptFa: post?.excerptFa ?? form.excerptFa,
excerptEn: post?.excerptEn ?? form.excerptEn,
contentFa: post?.contentFa ?? form.contentFa,
contentEn: post?.contentEn ?? form.contentEn,
author: post?.author ?? form.author,
categoryFa: post?.categoryFa ?? form.categoryFa,
categoryEn: post?.categoryEn ?? form.categoryEn,
})}
disabled={saveMut.isPending} disabled={saveMut.isPending}
className="min-w-[100px]" className="min-w-[100px]"
> >
+11 -1
View File
@@ -4,15 +4,20 @@ import type { AuthTokenResponse } from "@/lib/api/types";
interface AdminAuthState { interface AdminAuthState {
user: AuthTokenResponse | null; user: AuthTokenResponse | null;
/** True once Zustand has finished rehydrating from localStorage. */
_hasHydrated: boolean;
setAuth: (user: AuthTokenResponse) => void; setAuth: (user: AuthTokenResponse) => void;
clearAuth: () => void; clearAuth: () => void;
isAuthenticated: () => boolean; isAuthenticated: () => boolean;
_setHasHydrated: (v: boolean) => void;
} }
export const useAdminAuthStore = create<AdminAuthState>()( export const useAdminAuthStore = create<AdminAuthState>()(
persist( persist(
(set, get) => ({ (set, get) => ({
user: null, user: null,
_hasHydrated: false,
_setHasHydrated: (v) => set({ _hasHydrated: v }),
setAuth: (user) => { setAuth: (user) => {
if (typeof window !== "undefined") { if (typeof window !== "undefined") {
localStorage.setItem("meezi_admin_access_token", user.accessToken); localStorage.setItem("meezi_admin_access_token", user.accessToken);
@@ -29,6 +34,11 @@ export const useAdminAuthStore = create<AdminAuthState>()(
}, },
isAuthenticated: () => !!get().user?.accessToken, isAuthenticated: () => !!get().user?.accessToken,
}), }),
{ name: "meezi_admin_auth" } {
name: "meezi_admin_auth",
onRehydrateStorage: () => (state) => {
state?._setHasHydrated(true);
},
}
) )
); );
@@ -18,13 +18,17 @@ export default function DashboardLayout({
const locale = useLocale(); const locale = useLocale();
const router = useRouter(); const router = useRouter();
const user = useAuthStore((s) => s.user); const user = useAuthStore((s) => s.user);
const hasHydrated = useAuthStore((s) => s._hasHydrated);
useOfflineSync(); // register online/offline listeners + load queue count useOfflineSync(); // register online/offline listeners + load queue count
useEffect(() => { useEffect(() => {
if (!user?.accessToken) { // Wait for Zustand to finish reading localStorage before deciding to redirect.
// Without this guard, the effect fires while user is still null on first render,
// causing a spurious redirect to /login even when the token exists in storage.
if (hasHydrated && !user?.accessToken) {
router.replace("/login"); router.replace("/login");
} }
}, [user, router]); }, [user, hasHydrated, router]);
const isRtl = locale !== "en"; const isRtl = locale !== "en";