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
+142 -90
View File
@@ -5,6 +5,7 @@ import { useTranslations } from "next-intl";
import { useEffect, useState } from "react";
import { useParams } from "next/navigation";
import { Link } from "@/i18n/routing";
import { cn } from "@/lib/utils";
import {
adminDelete,
adminGet,
@@ -37,6 +38,30 @@ import {
type TicketStatus,
} 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() {
const t = useTranslations("admin.dashboard");
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() {
const t = useTranslations("admin.plans");
const qc = useQueryClient();
@@ -108,38 +177,7 @@ export function AdminPlansScreen() {
<div className="space-y-4">
<h1 className="text-lg font-medium">{t("title")}</h1>
{plans.map((plan) => (
<Card key={plan.tier} 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={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>
<PlanCard key={plan.tier} plan={plan} onSave={(p) => save.mutate(p)} />
))}
</div>
);
@@ -166,17 +204,34 @@ export function AdminSettingsScreen() {
<div className="space-y-4">
<h1 className="text-lg font-medium">{t("title")}</h1>
<div className="space-y-2">
{settings.map((s) => (
<Card key={s.id} className="rounded-xl border border-border/80 p-4">
<p className="text-xs text-muted-foreground">{s.key}</p>
<p className="text-[11px] text-muted-foreground">{s.descriptionFa}</p>
<Input
className="mt-2"
defaultValue={s.value}
onBlur={(e) => save.mutate({ key: s.key, value: e.target.value })}
/>
</Card>
))}
{settings.map((s) => {
const isBool = s.value === "true" || s.value === "false";
return (
<Card key={s.id} className="rounded-xl border border-border/80 p-4">
<div className={isBool ? "flex items-center justify-between gap-3" : undefined}>
<div className="min-w-0">
<p className="text-sm font-medium text-foreground">{s.key}</p>
{s.descriptionFa ? (
<p className="text-[11px] text-muted-foreground">{s.descriptionFa}</p>
) : 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>
);
@@ -588,23 +643,21 @@ export function AdminIntegrationsScreen() {
<Badge className="bg-[#E1F5EE] text-[#0F6E56]">{t("active")}</Badge>
) : null}
</div>
<label className="flex items-center gap-2 text-sm">
<input
type="checkbox"
<div className="flex items-center gap-2 text-sm">
<Toggle
checked={g.isEnabled}
onChange={(e) => updateGateway(g.id, { isEnabled: e.target.checked })}
onChange={(v) => updateGateway(g.id, { isEnabled: v })}
/>
{t("enabled")}
</label>
<span>{t("enabled")}</span>
</div>
</div>
<label className="flex items-center gap-2 text-sm text-muted-foreground">
<input
type="checkbox"
<div className="flex items-center gap-2 text-sm text-muted-foreground">
<Toggle
checked={g.sandbox}
onChange={(e) => updateGateway(g.id, { sandbox: e.target.checked })}
onChange={(v) => updateGateway(g.id, { sandbox: v })}
/>
{t("sandbox")}
</label>
<span>{t("sandbox")}</span>
</div>
{g.id === "zarinpal" ? (
<label className="block text-sm">
{t("merchantId")}
@@ -779,14 +832,13 @@ export function AdminIntegrationsScreen() {
{t("kavenegarTitle")}
</p>
<Card className="rounded-xl border border-border/80 p-4 space-y-3">
<label className="flex items-center gap-2 text-sm">
<input
type="checkbox"
<div className="flex items-center gap-2 text-sm">
<Toggle
checked={kavenegar.isEnabled}
onChange={(e) => setKavenegar((k) => ({ ...k, isEnabled: e.target.checked }))}
onChange={(v) => setKavenegar((k) => ({ ...k, isEnabled: v }))}
/>
{t("enabled")}
</label>
<span>{t("enabled")}</span>
</div>
<label className="block text-sm">
{t("apiKey")}
<Input
@@ -815,14 +867,13 @@ export function AdminIntegrationsScreen() {
<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-xs text-muted-foreground">{t("openAiHint")}</p>
<label className="flex items-center gap-2 text-sm">
<input
type="checkbox"
<div className="flex items-center gap-2 text-sm">
<Toggle
checked={openAi.isEnabled}
onChange={(e) => setOpenAi((o) => ({ ...o, isEnabled: e.target.checked }))}
onChange={(v) => setOpenAi((o) => ({ ...o, isEnabled: v }))}
/>
{t("enabled")}
</label>
<span>{t("enabled")}</span>
</div>
<label className="block text-sm">
{t("openAiApiKey")}
<Input
@@ -836,35 +887,37 @@ export function AdminIntegrationsScreen() {
</label>
<label className="block text-sm">
{t("openAiModel")}
<Input
className="mt-1"
<select
className="mt-1 block w-full rounded-md border border-input bg-background px-3 py-2 text-sm"
dir="ltr"
value={openAi.model}
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 className="flex items-center gap-2 text-sm">
<input
type="checkbox"
<div className="flex items-center gap-2 text-sm">
<Toggle
checked={openAi.coffeeAdvisorEnabled}
onChange={(e) =>
setOpenAi((o) => ({ ...o, coffeeAdvisorEnabled: e.target.checked }))
}
onChange={(v) => setOpenAi((o) => ({ ...o, coffeeAdvisorEnabled: v }))}
/>
{t("coffeeAdvisorEnabled")}
</label>
<span>{t("coffeeAdvisorEnabled")}</span>
</div>
</Card>
<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-xs text-muted-foreground">{t("meshyHint")}</p>
<label className="flex items-center gap-2 text-sm">
<input
type="checkbox"
<div className="flex items-center gap-2 text-sm">
<Toggle
checked={meshy.isEnabled}
onChange={(e) => setMeshy((m) => ({ ...m, isEnabled: e.target.checked }))}
onChange={(v) => setMeshy((m) => ({ ...m, isEnabled: v }))}
/>
{t("enabled")}
</label>
<span>{t("enabled")}</span>
</div>
<label className="block text-sm">
{t("meshyApiKey")}
<Input
@@ -876,14 +929,13 @@ export function AdminIntegrationsScreen() {
onChange={(e) => setMeshy((m) => ({ ...m, apiKey: e.target.value }))}
/>
</label>
<label className="flex items-center gap-2 text-sm">
<input
type="checkbox"
<div className="flex items-center gap-2 text-sm">
<Toggle
checked={meshy.menu3dEnabled}
onChange={(e) => setMeshy((m) => ({ ...m, menu3dEnabled: e.target.checked }))}
onChange={(v) => setMeshy((m) => ({ ...m, menu3dEnabled: v }))}
/>
{t("menu3dEnabled")}
</label>
<span>{t("menu3dEnabled")}</span>
</div>
</Card>
</section>
</div>
@@ -44,11 +44,12 @@ export function AdminShell({ children }: { children: React.ReactNode }) {
const pathname = usePathname();
const router = useRouter();
const user = useAdminAuthStore((s) => s.user);
const hasHydrated = useAdminAuthStore((s) => s._hasHydrated);
const clearAuth = useAdminAuthStore((s) => s.clearAuth);
useEffect(() => {
if (!user?.accessToken) router.replace("/admin/login");
}, [user, router]);
if (hasHydrated && !user?.accessToken) router.replace("/admin/login");
}, [user, hasHydrated, router]);
return (
<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 { Badge } from "@/components/ui/badge";
import { notify } from "@/lib/notify";
import { Link } from "@/i18n/routing";
import {
CheckCircle2,
XCircle,
@@ -66,13 +67,13 @@ export function AdminBlogListScreen() {
<div className="space-y-4">
<div className="flex items-center justify-between">
<h1 className="text-lg font-medium">{t("blogTitle")}</h1>
<a
href="website/blog/new"
<Link
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"
>
<Plus className="size-4" />
{t("newPost")}
</a>
</Link>
</div>
{isLoading ? (
@@ -113,7 +114,7 @@ export function AdminBlogListScreen() {
asChild
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
size="sm"
@@ -162,7 +163,7 @@ export function AdminBlogEditorScreen({ postId }: PostEditorProps) {
enabled: !isNew,
});
const [form, setForm] = useState({
const emptyForm = {
slug: "",
titleFa: "",
titleEn: "",
@@ -173,30 +174,38 @@ export function AdminBlogEditorScreen({ postId }: PostEditorProps) {
author: "تیم میزی",
categoryFa: "",
categoryEn: "",
});
};
// Sync fetched data into form once loaded
const initialised = !isNew && post;
const displayForm = initialised
? {
slug: post!.slug,
titleFa: post!.titleFa,
titleEn: post!.titleEn,
excerptFa: post!.excerptFa,
excerptEn: post!.excerptEn,
contentFa: post!.contentFa,
contentEn: post!.contentEn,
author: post!.author,
categoryFa: post!.categoryFa,
categoryEn: post!.categoryEn,
}
: form;
const [form, setForm] = useState(emptyForm);
const [formReady, setFormReady] = useState(isNew);
// Populate form from server data the first time it arrives
useEffect(() => {
if (post && !formReady) {
setForm({
slug: post.slug,
titleFa: post.titleFa,
titleEn: post.titleEn,
excerptFa: post.excerptFa,
excerptEn: post.excerptEn,
contentFa: post.contentFa,
contentEn: post.contentEn,
author: post.author,
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({
mutationFn: (data: typeof form) =>
mutationFn: () =>
isNew
? adminPost("/api/admin/website/posts", data)
: adminPut(`/api/admin/website/posts/${postId}`, data),
? adminPost("/api/admin/website/posts", form)
: adminPut(`/api/admin/website/posts/${postId}`, form),
onSuccess: () => {
qc.invalidateQueries({ queryKey: ["admin", "website", "blog"] });
notify.success(t("saved"));
@@ -206,52 +215,49 @@ export function AdminBlogEditorScreen({ postId }: PostEditorProps) {
const Field = ({
label,
value,
onChange,
fieldKey,
multiline,
}: {
label: string;
value: string;
onChange: (v: string) => void;
fieldKey: keyof typeof form;
multiline?: boolean;
}) => (
<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={label.toLowerCase().includes("fa") || label.includes("فارسی") ? "rtl" : "ltr"}
/>
) : (
<Input
value={value}
onChange={(e) => onChange(e.target.value)}
className="h-9 text-sm"
dir={label.toLowerCase().includes("fa") || label.includes("فارسی") ? "rtl" : "ltr"}
/>
)}
</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 }));
}) => {
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>;
}
return (
<div className="space-y-4">
<div className="flex items-center gap-3">
<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" />
{t("backToBlog")}
</a>
</Link>
</Button>
<h1 className="text-lg font-medium">
{isNew ? t("newPost") : t("editPost")}
@@ -261,80 +267,27 @@ 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")}
value={isNew ? form.slug : (post?.slug ?? "")}
onChange={setField("slug")}
/>
<Field
label={t("fieldAuthor")}
value={isNew ? form.author : (post?.author ?? "")}
onChange={setField("author")}
/>
<Field label={t("fieldSlug")} fieldKey="slug" />
<Field label={t("fieldAuthor")} fieldKey="author" />
</div>
<div className="grid gap-4 sm:grid-cols-2">
<Field
label={t("fieldTitleFa")}
value={isNew ? form.titleFa : (post?.titleFa ?? "")}
onChange={setField("titleFa")}
/>
<Field
label={t("fieldTitleEn")}
value={isNew ? form.titleEn : (post?.titleEn ?? "")}
onChange={setField("titleEn")}
/>
<Field label={t("fieldTitleFa")} fieldKey="titleFa" />
<Field label={t("fieldTitleEn")} fieldKey="titleEn" />
</div>
<div className="grid gap-4 sm:grid-cols-2">
<Field
label={t("fieldExcerptFa")}
value={isNew ? form.excerptFa : (post?.excerptFa ?? "")}
onChange={setField("excerptFa")}
/>
<Field
label={t("fieldExcerptEn")}
value={isNew ? form.excerptEn : (post?.excerptEn ?? "")}
onChange={setField("excerptEn")}
/>
<Field label={t("fieldExcerptFa")} fieldKey="excerptFa" />
<Field label={t("fieldExcerptEn")} fieldKey="excerptEn" />
</div>
<div className="grid gap-4 sm:grid-cols-2">
<Field
label={t("fieldCategoryFa")}
value={isNew ? form.categoryFa : (post?.categoryFa ?? "")}
onChange={setField("categoryFa")}
/>
<Field
label={t("fieldCategoryEn")}
value={isNew ? form.categoryEn : (post?.categoryEn ?? "")}
onChange={setField("categoryEn")}
/>
<Field label={t("fieldCategoryFa")} fieldKey="categoryFa" />
<Field label={t("fieldCategoryEn")} fieldKey="categoryEn" />
</div>
<Field
label={t("fieldContentFa")}
value={isNew ? form.contentFa : (post?.contentFa ?? "")}
onChange={setField("contentFa")}
multiline
/>
<Field
label={t("fieldContentEn")}
value={isNew ? form.contentEn : (post?.contentEn ?? "")}
onChange={setField("contentEn")}
multiline
/>
<Field label={t("fieldContentFa")} fieldKey="contentFa" multiline />
<Field label={t("fieldContentEn")} fieldKey="contentEn" multiline />
<div className="flex justify-end pt-2">
<Button
onClick={() => saveMut.mutate(isNew ? form : {
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,
})}
onClick={() => saveMut.mutate()}
disabled={saveMut.isPending}
className="min-w-[100px]"
>
+11 -1
View File
@@ -4,15 +4,20 @@ import type { AuthTokenResponse } from "@/lib/api/types";
interface AdminAuthState {
user: AuthTokenResponse | null;
/** True once Zustand has finished rehydrating from localStorage. */
_hasHydrated: boolean;
setAuth: (user: AuthTokenResponse) => void;
clearAuth: () => void;
isAuthenticated: () => boolean;
_setHasHydrated: (v: boolean) => void;
}
export const useAdminAuthStore = create<AdminAuthState>()(
persist(
(set, get) => ({
user: null,
_hasHydrated: false,
_setHasHydrated: (v) => set({ _hasHydrated: v }),
setAuth: (user) => {
if (typeof window !== "undefined") {
localStorage.setItem("meezi_admin_access_token", user.accessToken);
@@ -29,6 +34,11 @@ export const useAdminAuthStore = create<AdminAuthState>()(
},
isAuthenticated: () => !!get().user?.accessToken,
}),
{ name: "meezi_admin_auth" }
{
name: "meezi_admin_auth",
onRehydrateStorage: () => (state) => {
state?._setHasHydrated(true);
},
}
)
);