chore: merge admin repo history into monorepo at admin/

git-subtree-dir: admin
git-subtree-mainline: 35f5084005
git-subtree-split: 9f1eccd2f7
This commit is contained in:
2026-09-27 10:30:47 +03:30
49 changed files with 11446 additions and 0 deletions
@@ -0,0 +1,253 @@
"use client";
import { useEffect, useState, useCallback } from "react";
import { Plus, Pencil, Trash2 } from "lucide-react";
import { Card } from "@/components/ui/Card";
import { Button } from "@/components/ui/Button";
import { TextField } from "@/components/ui/TextField";
import { Switch } from "@/components/ui/Controls";
import { Modal } from "@/components/ui/Modal";
import { ConfirmDialog } from "@/components/ui/ConfirmDialog";
import { FullScreenLoader } from "@/components/ui/FullScreenLoader";
import { ErrorBanner } from "@/components/ui/ErrorBanner";
import { useToast } from "@/components/ui/Toast";
import { formatApiError } from "@/lib/hooks";
import {
ProfileQuestion,
listQuestions,
createQuestion,
updateQuestion,
deleteQuestion,
} from "@/lib/api";
export default function QuestionsPage() {
const toast = useToast();
const [questions, setQuestions] = useState<ProfileQuestion[] | null>(null);
const [error, setError] = useState<string | null>(null);
const [formModal, setFormModal] = useState<{ mode: "create" | "edit"; q?: ProfileQuestion } | null>(
null
);
const [deleteTarget, setDeleteTarget] = useState<ProfileQuestion | null>(null);
const load = useCallback(async () => {
setError(null);
try {
setQuestions(await listQuestions());
} catch (err) {
setError(formatApiError(err));
}
}, []);
useEffect(() => {
// eslint-disable-next-line react-hooks/set-state-in-effect
load();
}, [load]);
if (error && !questions) {
return (
<div className="max-w-2xl">
<ErrorBanner message={error} />
</div>
);
}
if (!questions) return <FullScreenLoader />;
return (
<div className="flex flex-col gap-6 max-w-3xl">
<div className="flex items-start justify-between gap-4">
<div>
<h1 className="font-[var(--font-display)] font-extrabold text-[24px]">
سوالات پروفایل 💬
</h1>
<p className="text-[14px] text-(--color-content-secondary) mt-1">
کاربر توی مرحله‌ی سوم آنبوردینگ دقیقاً ۳ تا از این سوالات رو انتخاب و
جواب می‌ده.
</p>
</div>
<Button size="sm" icon={<Plus size={16} />} onClick={() => setFormModal({ mode: "create" })}>
افزودن سوال
</Button>
</div>
<Card padded={false} className="overflow-hidden">
{questions.length === 0 ? (
<p className="text-[13px] text-(--color-content-tertiary) text-center py-10">
هنوز سوالی اضافه نشده.
</p>
) : (
<table className="w-full text-[13px]">
<thead>
<tr className="text-(--color-content-tertiary) text-[11px] border-b border-(--color-border-subtle)">
<th className="text-right font-semibold px-4 py-3">متن سوال</th>
<th className="text-right font-semibold px-4 py-3 w-20">ترتیب</th>
<th className="text-right font-semibold px-4 py-3 w-24">فعال</th>
<th className="text-right font-semibold px-4 py-3 w-24">عملیات</th>
</tr>
</thead>
<tbody>
{[...questions]
.sort((a, b) => a.sort_order - b.sort_order)
.map((q) => (
<tr key={q.id} className="border-b border-(--color-border-subtle) last:border-0">
<td className="px-4 py-3 font-medium text-(--color-content-primary)">
{q.text}
</td>
<td className="px-4 py-3 text-(--color-content-tertiary)">{q.sort_order}</td>
<td className="px-4 py-3">
<Switch
defaultChecked={q.is_active}
onChange={async (next) => {
try {
await updateQuestion(q.id, { is_active: next });
setQuestions((prev) =>
prev!.map((x) => (x.id === q.id ? { ...x, is_active: next } : x))
);
} catch (err) {
toast.showError(formatApiError(err));
}
}}
/>
</td>
<td className="px-4 py-3">
<div className="flex items-center gap-1">
<button
onClick={() => setFormModal({ mode: "edit", q })}
className="w-7 h-7 rounded-full flex items-center justify-center hover:bg-(--color-bg-secondary) text-(--color-content-tertiary)"
>
<Pencil size={13} />
</button>
<button
onClick={() => setDeleteTarget(q)}
className="w-7 h-7 rounded-full flex items-center justify-center hover:bg-red-50 text-(--color-status-error)"
>
<Trash2 size={13} />
</button>
</div>
</td>
</tr>
))}
</tbody>
</table>
)}
</Card>
<QuestionFormModal
open={!!formModal}
mode={formModal?.mode ?? "create"}
question={formModal?.q}
onClose={() => setFormModal(null)}
onSaved={(q, mode) => {
if (mode === "create") {
setQuestions((prev) => [...(prev ?? []), q]);
toast.showSuccess("سوال اضافه شد");
} else {
setQuestions((prev) => prev!.map((x) => (x.id === q.id ? q : x)));
toast.showSuccess("سوال ویرایش شد");
}
setFormModal(null);
}}
/>
<ConfirmDialog
open={!!deleteTarget}
title="حذف سوال؟"
description="این سوال از لیست سوالات قابل‌انتخاب حذف می‌شه. پاسخ‌هایی که کاربرها قبلاً بهش دادن دست‌نخورده می‌مونه."
onCancel={() => setDeleteTarget(null)}
onConfirm={async () => {
if (!deleteTarget) return;
try {
await deleteQuestion(deleteTarget.id);
setQuestions((prev) => prev!.filter((x) => x.id !== deleteTarget.id));
toast.showSuccess("سوال حذف شد");
} catch (err) {
toast.showError(formatApiError(err));
} finally {
setDeleteTarget(null);
}
}}
/>
</div>
);
}
function QuestionFormModal({
open,
mode,
question,
onClose,
onSaved,
}: {
open: boolean;
mode: "create" | "edit";
question?: ProfileQuestion;
onClose: () => void;
onSaved: (q: ProfileQuestion, mode: "create" | "edit") => void;
}) {
const [text, setText] = useState(question?.text ?? "");
const [sortOrder, setSortOrder] = useState(String(question?.sort_order ?? 0));
const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
useEffect(() => {
// eslint-disable-next-line react-hooks/set-state-in-effect
setText(question?.text ?? "");
setSortOrder(String(question?.sort_order ?? 0));
setError(null);
}, [question, open]);
async function handleSave() {
if (text.trim().length < 3) return;
setLoading(true);
setError(null);
try {
if (mode === "create") {
const created = await createQuestion({ text: text.trim(), sort_order: parseInt(sortOrder, 10) || 0 });
onSaved(created, "create");
} else if (question) {
const updated = await updateQuestion(question.id, {
text: text.trim(),
sort_order: parseInt(sortOrder, 10) || 0,
});
onSaved(updated, "edit");
}
} catch (err) {
setError(formatApiError(err));
} finally {
setLoading(false);
}
}
return (
<Modal open={open} onClose={onClose} title={mode === "create" ? "افزودن سوال" : "ویرایش سوال"}>
<div className="flex flex-col gap-4">
<div className="flex flex-col gap-1.5">
<label className="text-[13px] font-semibold text-(--color-content-secondary) px-1">
متن سوال
</label>
<textarea
value={text}
onChange={(e) => setText(e.target.value)}
rows={3}
className="rounded-(--radius-sm) border-2 border-(--color-border-default) bg-(--color-surface-default) px-4 py-3 text-[14px] focus:outline-none focus:border-(--color-border-interactive) focus:ring-4 focus:ring-(--color-brand-100) resize-none"
autoFocus
/>
</div>
<TextField
label="ترتیب نمایش"
type="number"
value={sortOrder}
onChange={(e) => setSortOrder(e.target.value)}
/>
<ErrorBanner message={error} />
<div className="flex justify-end gap-2 pt-2">
<Button variant="ghost" size="sm" onClick={onClose}>
انصراف
</Button>
<Button size="sm" isLoading={loading} disabled={text.trim().length < 3} onClick={handleSave}>
ذخیره
</Button>
</div>
</div>
</Modal>
);
}