diff --git a/.gitea/workflows/ci-cd.yml b/.gitea/workflows/ci-cd.yml
index 420c88c..fc11ddf 100644
--- a/.gitea/workflows/ci-cd.yml
+++ b/.gitea/workflows/ci-cd.yml
@@ -349,12 +349,4 @@ jobs:
if: always()
run: docker compose -f docker-compose.yml -f docker-compose.admin.yml ps
- - name: Prune old meezi images
- if: success()
- # Only remove untagged (dangling) meezi images — never touches other projects
- run: |
- docker images --format '{{.Repository}}:{{.Tag}} {{.ID}}' \
- | grep '^hostexecutor-' \
- | grep '' \
- | awk '{print $2}' \
- | xargs -r docker rmi || true
+ # Intentionally no image pruning — disk cleanup is done manually on the server.
diff --git a/web/admin/src/components/admin/admin-screens.tsx b/web/admin/src/components/admin/admin-screens.tsx
index 8aca463..c25c2ad 100644
--- a/web/admin/src/components/admin/admin-screens.tsx
+++ b/web/admin/src/components/admin/admin-screens.tsx
@@ -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 (
+
+ );
+}
+
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 (
+
+
+ {plan.displayNameFa}
+ {plan.tier}
+
+
+
+
+
+
+ );
+}
+
export function AdminPlansScreen() {
const t = useTranslations("admin.plans");
const qc = useQueryClient();
@@ -108,38 +177,7 @@ export function AdminPlansScreen() {
);
@@ -166,17 +204,34 @@ export function AdminSettingsScreen() {
{t("title")}
- {settings.map((s) => (
-
- {s.key}
- {s.descriptionFa}
- save.mutate({ key: s.key, value: e.target.value })}
- />
-
- ))}
+ {settings.map((s) => {
+ const isBool = s.value === "true" || s.value === "false";
+ return (
+
+
+
+
{s.key}
+ {s.descriptionFa ? (
+
{s.descriptionFa}
+ ) : null}
+
+ {isBool ? (
+
save.mutate({ key: s.key, value: String(v) })}
+ disabled={save.isPending}
+ />
+ ) : (
+ save.mutate({ key: s.key, value: e.target.value })}
+ />
+ )}
+
+
+ );
+ })}
);
@@ -588,23 +643,21 @@ export function AdminIntegrationsScreen() {
{t("active")}
) : null}
-
+ {t("enabled")}
+
-
+ {t("sandbox")}
+
{g.id === "zarinpal" ? (
-
+ {t("enabled")}
+
{t("meshyTitle")}
{t("meshyHint")}
-
-
+ setMeshy((m) => ({ ...m, isEnabled: e.target.checked }))}
+ onChange={(v) => setMeshy((m) => ({ ...m, isEnabled: v }))}
/>
- {t("enabled")}
-
+ {t("enabled")}
+
{t("meshyApiKey")}
setMeshy((m) => ({ ...m, apiKey: e.target.value }))}
/>
-
-
+ setMeshy((m) => ({ ...m, menu3dEnabled: e.target.checked }))}
+ onChange={(v) => setMeshy((m) => ({ ...m, menu3dEnabled: v }))}
/>
- {t("menu3dEnabled")}
-
+ {t("menu3dEnabled")}
+
diff --git a/web/admin/src/components/admin/admin-shell.tsx b/web/admin/src/components/admin/admin-shell.tsx
index f00c922..7379eb1 100644
--- a/web/admin/src/components/admin/admin-shell.tsx
+++ b/web/admin/src/components/admin/admin-shell.tsx
@@ -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 (
diff --git a/web/admin/src/components/admin/admin-website-screens.tsx b/web/admin/src/components/admin/admin-website-screens.tsx
index 1cd1172..88cf26e 100644
--- a/web/admin/src/components/admin/admin-website-screens.tsx
+++ b/web/admin/src/components/admin/admin-website-screens.tsx
@@ -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() {
{isLoading ? (
@@ -113,7 +114,7 @@ export function AdminBlogListScreen() {
asChild
className="h-8 px-2 text-xs"
>
-
{t("edit")}
+
{t("edit")}