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