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
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:
@@ -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]"
|
||||
>
|
||||
|
||||
@@ -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);
|
||||
},
|
||||
}
|
||||
)
|
||||
);
|
||||
|
||||
Reference in New Issue
Block a user