feat: complete url deeplinking across all admin modal/tab pages
All checks were successful
Deploy Canina / deploy (push) Successful in 30s

This commit is contained in:
parsa aghaei 2026-08-23 10:36:47 +03:30
parent 5029edaaeb
commit fc324aacaf
4 changed files with 198 additions and 36 deletions

View File

@ -11,6 +11,8 @@ import Modal from '../components/ui/Modal';
import { useSearchParams } from 'react-router-dom';
export interface BlogPost {
author?: {
firstName?: string;
@ -29,10 +31,11 @@ export interface BlogPost {
}
export default function Blogs() {
const [searchParams, setSearchParams] = useSearchParams();
const [blogs, setBlogs] = useState<BlogPost[]>([]);
const [isLoading, setIsLoading] = useState(true);
const [search, setSearch] = useState('');
const [page, setPage] = useState(1);
const [search, setSearch] = useState(() => searchParams.get('search') || '');
const [page, setPage] = useState(() => Number(searchParams.get('page')) || 1);
const [totalPages, setTotalPages] = useState(1);
const limit = 10;
@ -41,6 +44,16 @@ export default function Blogs() {
const [editingBlog, setEditingBlog] = useState<BlogPost | null>(null);
const [deleteTargetId, setDeleteTargetId] = useState<string | null>(null);
const updateUrlParams = (paramsObj: Record<string, string | number | undefined | null>) => {
const current = Object.fromEntries(searchParams.entries());
const merged = { ...current, ...paramsObj };
const cleaned: Record<string, string> = {};
Object.entries(merged).forEach(([k, v]) => {
if (v !== undefined && v !== null && String(v).trim() !== '') cleaned[k] = String(v);
});
setSearchParams(cleaned, { replace: true });
};
const [formData, setFormData] = useState({
title: '',
slug: '',
@ -57,15 +70,25 @@ export default function Blogs() {
setIsLoading(true);
const res = await api.get('/admin/blogs', { params: { page, limit, search } });
if (res.data?.data) {
setBlogs(res.data.data);
const blogList: BlogPost[] = res.data.data;
setBlogs(blogList);
setTotalPages(res.data.meta?.lastPage || 1);
const modalParam = searchParams.get('modal');
const blogId = searchParams.get('blogId');
if (modalParam === 'create') {
openModal(null);
} else if (modalParam === 'edit' && blogId) {
const found = blogList.find((b) => b.id === blogId);
if (found) openModal(found);
}
}
} catch (err) {
console.error(err);
} finally {
setIsLoading(false);
}
}, [page, search]);
}, [page, search, searchParams]);
useEffect(() => {
const timer = setTimeout(() => {
@ -84,7 +107,7 @@ export default function Blogs() {
await api.post('/admin/blogs', formData);
toast.success('مقاله جدید با موفقیت ذخیره شد');
}
setIsModalOpen(false);
closeModal();
fetchBlogs();
} catch (error) {
console.error('Save failed', error);
@ -109,6 +132,7 @@ export default function Blogs() {
const openModal = (blog: BlogPost | null = null) => {
if (blog) {
setEditingBlog(blog);
updateUrlParams({ modal: 'edit', blogId: blog.id });
setFormData({
title: blog.title,
slug: blog.slug,
@ -121,6 +145,7 @@ export default function Blogs() {
});
} else {
setEditingBlog(null);
updateUrlParams({ modal: 'create', blogId: undefined });
setFormData({
title: '', slug: '', content: '', isPublished: true, metaTitle: '', metaDescription: '', keywords: '', imageUrl: ''
});
@ -128,6 +153,12 @@ export default function Blogs() {
setIsModalOpen(true);
};
const closeModal = () => {
setIsModalOpen(false);
updateUrlParams({ modal: undefined, blogId: undefined });
};
return (
<div className="space-y-6">
<div className="flex flex-col sm:flex-row justify-between gap-4">
@ -234,7 +265,7 @@ export default function Blogs() {
{isModalOpen && (
<Modal
isOpen={isModalOpen}
onClose={() => setIsModalOpen(false)}
onClose={closeModal}
title={editingBlog ? 'ویرایش مقاله' : 'افزودن مقاله جدید'}
icon={FileText}
maxWidth="4xl"
@ -244,10 +275,11 @@ export default function Blogs() {
variant="secondary"
size="sm"
type="button"
onClick={() => setIsModalOpen(false)}
onClick={closeModal}
>
انصراف
</Button>
<Button
variant="primary"
size="sm"

View File

@ -11,6 +11,8 @@ import Modal from '../components/ui/Modal';
import { useSearchParams } from 'react-router-dom';
export interface Category {
id: string;
name: string;
@ -24,10 +26,11 @@ export interface Category {
}
export default function Categories() {
const [searchParams, setSearchParams] = useSearchParams();
const [categories, setCategories] = useState<Category[]>([]);
const [isLoading, setIsLoading] = useState(true);
const [search, setSearch] = useState('');
const [page, setPage] = useState(1);
const [search, setSearch] = useState(() => searchParams.get('search') || '');
const [page, setPage] = useState(() => Number(searchParams.get('page')) || 1);
const [totalPages, setTotalPages] = useState(1);
const limit = 10;
@ -36,6 +39,16 @@ export default function Categories() {
const [editingCategory, setEditingCategory] = useState<Category | null>(null);
const [deleteTargetId, setDeleteTargetId] = useState<string | null>(null);
const updateUrlParams = (paramsObj: Record<string, string | number | undefined | null>) => {
const current = Object.fromEntries(searchParams.entries());
const merged = { ...current, ...paramsObj };
const cleaned: Record<string, string> = {};
Object.entries(merged).forEach(([k, v]) => {
if (v !== undefined && v !== null && String(v).trim() !== '') cleaned[k] = String(v);
});
setSearchParams(cleaned, { replace: true });
};
const [formData, setFormData] = useState({
name: '',
slug: '',
@ -51,15 +64,25 @@ export default function Categories() {
setIsLoading(true);
const res = await api.get('/admin/categories', { params: { page, limit, search } });
if (res.data?.data) {
setCategories(res.data.data);
const catList: Category[] = res.data.data;
setCategories(catList);
setTotalPages(res.data.meta?.lastPage || 1);
const modalParam = searchParams.get('modal');
const catId = searchParams.get('categoryId');
if (modalParam === 'create') {
openModal(null);
} else if (modalParam === 'edit' && catId) {
const found = catList.find((c) => c.id === catId);
if (found) openModal(found);
}
}
} catch (err) {
console.error(err);
} finally {
setIsLoading(false);
}
}, [page, search]);
}, [page, search, searchParams]);
useEffect(() => {
const timer = setTimeout(() => {
@ -78,7 +101,7 @@ export default function Categories() {
await api.post('/admin/categories', formData);
toast.success('دسته‌بندی جدید با موفقیت ایجاد شد');
}
setIsModalOpen(false);
closeModal();
fetchCategories();
} catch (error) {
console.error('Save failed', error);
@ -103,6 +126,7 @@ export default function Categories() {
const openModal = (category: Category | null = null) => {
if (category) {
setEditingCategory(category);
updateUrlParams({ modal: 'edit', categoryId: category.id });
setFormData({
name: category.name,
slug: category.slug,
@ -114,6 +138,7 @@ export default function Categories() {
});
} else {
setEditingCategory(null);
updateUrlParams({ modal: 'create', categoryId: undefined });
setFormData({
name: '', slug: '', description: '', metaTitle: '', metaDescription: '', keywords: '', imageUrl: ''
});
@ -121,6 +146,12 @@ export default function Categories() {
setIsModalOpen(true);
};
const closeModal = () => {
setIsModalOpen(false);
updateUrlParams({ modal: undefined, categoryId: undefined });
};
return (
<div className="space-y-6">
<div className="flex flex-col sm:flex-row justify-between gap-4">
@ -237,7 +268,7 @@ export default function Categories() {
{isModalOpen && (
<Modal
isOpen={isModalOpen}
onClose={() => setIsModalOpen(false)}
onClose={closeModal}
title={editingCategory ? 'ویرایش دسته‌بندی' : 'افزودن دسته‌بندی جدید'}
icon={FolderTree}
maxWidth="2xl"
@ -247,10 +278,11 @@ export default function Categories() {
variant="secondary"
size="sm"
type="button"
onClick={() => setIsModalOpen(false)}
onClick={closeModal}
>
انصراف
</Button>
<Button
variant="primary"
size="sm"

View File

@ -37,14 +37,17 @@ export interface UserRecord {
pets?: PetSummary[];
}
import { useSearchParams } from 'react-router-dom';
export default function Users() {
const [searchParams, setSearchParams] = useSearchParams();
const [users, setUsers] = useState<UserRecord[]>([]);
const [isLoading, setIsLoading] = useState(true);
// Queries
const [page, setPage] = useState(1);
const [search, setSearch] = useState('');
const [role, setRole] = useState('');
const [page, setPage] = useState(() => Number(searchParams.get('page')) || 1);
const [search, setSearch] = useState(() => searchParams.get('search') || '');
const [role, setRole] = useState(() => searchParams.get('role') || '');
const [totalPages, setTotalPages] = useState(1);
const [totalCount, setTotalCount] = useState(0);
@ -55,6 +58,16 @@ export default function Users() {
const [userToDelete, setUserToDelete] = useState<UserRecord | null>(null);
const [walletUser, setWalletUser] = useState<UserRecord | null>(null);
const updateUrlParams = (paramsObj: Record<string, string | number | undefined | null>) => {
const current = Object.fromEntries(searchParams.entries());
const merged = { ...current, ...paramsObj };
const cleaned: Record<string, string> = {};
Object.entries(merged).forEach(([k, v]) => {
if (v !== undefined && v !== null && String(v).trim() !== '') cleaned[k] = String(v);
});
setSearchParams(cleaned, { replace: true });
};
// Form States
const [createFormData, setCreateFormData] = useState({
firstName: '',
@ -101,16 +114,30 @@ export default function Users() {
const response = await api.get(`/admin/users?${params.toString()}`);
if (response.data?.success) {
setUsers(response.data.data || []);
const userList = response.data.data || [];
setUsers(userList);
setTotalPages(response.data.meta?.lastPage || 1);
setTotalCount(response.data.meta?.total || 0);
const modalParam = searchParams.get('modal');
const userId = searchParams.get('userId');
if (modalParam === 'create') {
handleOpenCreate();
} else if (userId && userList.length > 0) {
const found = userList.find((u: UserRecord) => u.id === userId);
if (found) {
if (modalParam === 'view') handleOpenView(found);
else if (modalParam === 'edit') handleOpenEdit(found);
else if (modalParam === 'wallet') handleOpenWallet(found);
}
}
}
} catch (err) {
console.error('Failed to fetch users', err);
} finally {
setIsLoading(false);
}
}, [page, search, role]);
}, [page, search, role, searchParams]);
useEffect(() => {
const delayDebounceFn = setTimeout(() => {
@ -120,9 +147,32 @@ export default function Users() {
return () => clearTimeout(delayDebounceFn);
}, [fetchUsers]);
// Open View Modal
const handleOpenView = (user: UserRecord) => {
setViewUser(user);
updateUrlParams({ modal: 'view', userId: user.id });
};
const handleCloseView = () => {
setViewUser(null);
updateUrlParams({ modal: undefined, userId: undefined });
};
// Open Create Modal
const handleOpenCreate = () => {
setShowCreateModal(true);
updateUrlParams({ modal: 'create', userId: undefined });
};
const handleCloseCreate = () => {
setShowCreateModal(false);
updateUrlParams({ modal: undefined, userId: undefined });
};
// Open Edit Modal
const handleOpenEdit = (user: UserRecord) => {
setEditUser(user);
updateUrlParams({ modal: 'edit', userId: user.id });
setEditFormData({
firstName: user.firstName || '',
lastName: user.lastName || '',
@ -134,9 +184,15 @@ export default function Users() {
});
};
const handleCloseEdit = () => {
setEditUser(null);
updateUrlParams({ modal: undefined, userId: undefined });
};
// Open Wallet Modal
const handleOpenWallet = (user: UserRecord) => {
setWalletUser(user);
updateUrlParams({ modal: 'wallet', userId: user.id });
setWalletFormData({
amount: 50000,
type: 'deposit',
@ -144,6 +200,17 @@ export default function Users() {
});
};
const handleCloseWallet = () => {
setWalletUser(null);
updateUrlParams({ modal: undefined, userId: undefined });
};
const handleCloseDelete = () => {
setUserToDelete(null);
};
// Create User
const handleCreateUser = async (e: React.FormEvent) => {
e.preventDefault();
@ -441,7 +508,7 @@ export default function Users() {
{showCreateModal && (
<Modal
isOpen={showCreateModal}
onClose={() => setShowCreateModal(false)}
onClose={handleCloseCreate}
title="افزودن کاربر جدید"
icon={Plus}
maxWidth="lg"
@ -450,7 +517,7 @@ export default function Users() {
<Button
variant="outline"
size="sm"
onClick={() => setShowCreateModal(false)}
onClick={handleCloseCreate}
>
انصراف
</Button>
@ -562,6 +629,7 @@ export default function Users() {
type="number"
min="0"
step="1000"
placeholder="0"
value={createFormData.walletBalance}
onChange={(e) => setCreateFormData({ ...createFormData, walletBalance: Number(e.target.value) })}
className="w-full px-3 py-2.5 rounded-xl border border-gray-200 focus:border-purple-500 outline-none font-mono"
@ -575,7 +643,7 @@ export default function Users() {
{editUser && (
<Modal
isOpen={!!editUser}
onClose={() => setEditUser(null)}
onClose={handleCloseEdit}
title="ویرایش اطلاعات کاربر"
icon={Edit3}
maxWidth="lg"
@ -584,7 +652,7 @@ export default function Users() {
<Button
variant="outline"
size="sm"
onClick={() => setEditUser(null)}
onClick={handleCloseEdit}
>
انصراف
</Button>
@ -691,7 +759,7 @@ export default function Users() {
{walletUser && (
<Modal
isOpen={!!walletUser}
onClose={() => setWalletUser(null)}
onClose={handleCloseWallet}
title="مدیریت کیف پول کاربر"
icon={Wallet}
maxWidth="md"
@ -700,7 +768,7 @@ export default function Users() {
<Button
variant="outline"
size="sm"
onClick={() => setWalletUser(null)}
onClick={handleCloseWallet}
>
انصراف
</Button>
@ -774,7 +842,6 @@ export default function Users() {
type="number"
min="1000"
step="1000"
required
value={walletFormData.amount}
onChange={(e) => setWalletFormData({ ...walletFormData, amount: Number(e.target.value) })}
className="w-full px-3 py-2.5 rounded-xl border border-gray-200 focus:border-purple-500 outline-none font-mono font-bold text-sm"
@ -800,7 +867,7 @@ export default function Users() {
{userToDelete && (
<Modal
isOpen={!!userToDelete}
onClose={() => setUserToDelete(null)}
onClose={handleCloseDelete}
title="تایید حذف کاربر"
icon={AlertTriangle}
maxWidth="md"
@ -809,7 +876,7 @@ export default function Users() {
<Button
variant="outline"
size="sm"
onClick={() => setUserToDelete(null)}
onClick={handleCloseDelete}
>
انصراف
</Button>
@ -840,7 +907,7 @@ export default function Users() {
{viewUser && (
<Modal
isOpen={!!viewUser}
onClose={() => setViewUser(null)}
onClose={handleCloseView}
title="مشاهده جزئیات پروفایل کاربر"
icon={UsersIcon}
maxWidth="lg"

View File

@ -7,6 +7,7 @@ import Pagination from '../components/ui/Pagination';
import ConfirmModal from '../components/ui/ConfirmModal';
import Button from '../components/ui/Button';
import Modal from '../components/ui/Modal';
import { useSearchParams } from 'react-router-dom';
export interface WikiTerm {
@ -27,10 +28,11 @@ export interface ProductItem {
}
export default function Wiki() {
const [searchParams, setSearchParams] = useSearchParams();
const [terms, setTerms] = useState<WikiTerm[]>([]);
const [isLoading, setIsLoading] = useState(true);
const [search, setSearch] = useState('');
const [page, setPage] = useState(1);
const [search, setSearch] = useState(() => searchParams.get('search') || '');
const [page, setPage] = useState(() => Number(searchParams.get('page')) || 1);
const [totalPages, setTotalPages] = useState(1);
const limit = 10;
@ -38,6 +40,17 @@ export default function Wiki() {
const [editingTerm, setEditingTerm] = useState<WikiTerm | null>(null);
const [deleteTargetKey, setDeleteTargetKey] = useState<string | null>(null);
const [productsList, setProductsList] = useState<ProductItem[]>([]);
const updateUrlParams = (paramsObj: Record<string, string | number | undefined | null>) => {
const current = Object.fromEntries(searchParams.entries());
const merged = { ...current, ...paramsObj };
const cleaned: Record<string, string> = {};
Object.entries(merged).forEach(([k, v]) => {
if (v !== undefined && v !== null && String(v).trim() !== '') cleaned[k] = String(v);
});
setSearchParams(cleaned, { replace: true });
};
const [formData, setFormData] = useState({
key: '',
term: '',
@ -54,15 +67,25 @@ export default function Wiki() {
setIsLoading(true);
const res = await api.get('/admin/wiki', { params: { page, limit, search } });
if (res.data?.data) {
setTerms(res.data.data);
const termList: WikiTerm[] = res.data.data;
setTerms(termList);
setTotalPages(res.data.meta?.lastPage || 1);
const modalParam = searchParams.get('modal');
const termKey = searchParams.get('termKey');
if (modalParam === 'create') {
openModal(null);
} else if (modalParam === 'edit' && termKey) {
const found = termList.find((t) => t.key === termKey);
if (found) openModal(found);
}
}
} catch (err) {
console.error(err);
} finally {
setIsLoading(false);
}
}, [page, search]);
}, [page, search, searchParams]);
useEffect(() => {
const timer = setTimeout(() => {
@ -81,7 +104,7 @@ export default function Wiki() {
await api.post('/admin/wiki', formData);
toast.success('اصطلاح علمی جدید با موفقیت ایجاد شد');
}
setIsModalOpen(false);
closeModal();
fetchTerms();
} catch (error) {
console.error('Save failed', error);
@ -112,6 +135,7 @@ export default function Wiki() {
const openModal = (term: WikiTerm | null = null) => {
if (term) {
setEditingTerm(term);
updateUrlParams({ modal: 'edit', termKey: term.key });
setFormData({
key: term.key,
term: term.term,
@ -124,6 +148,7 @@ export default function Wiki() {
});
} else {
setEditingTerm(null);
updateUrlParams({ modal: 'create', termKey: undefined });
setFormData({
key: '', term: '', definition: '', relatedProducts: [], wikiId: 'general', metaTitle: '', metaDescription: '', keywords: ''
});
@ -131,6 +156,11 @@ export default function Wiki() {
setIsModalOpen(true);
};
const closeModal = () => {
setIsModalOpen(false);
updateUrlParams({ modal: undefined, termKey: undefined });
};
return (
<div className="space-y-6">
<div className="flex flex-col sm:flex-row justify-between gap-4">
@ -221,7 +251,7 @@ export default function Wiki() {
{isModalOpen && (
<Modal
isOpen={isModalOpen}
onClose={() => setIsModalOpen(false)}
onClose={closeModal}
title={editingTerm ? 'ویرایش دانشنامه' : 'افزودن مفهوم جدید'}
icon={BookOpen}
maxWidth="2xl"
@ -231,10 +261,11 @@ export default function Wiki() {
variant="secondary"
size="sm"
type="button"
onClick={() => setIsModalOpen(false)}
onClick={closeModal}
>
انصراف
</Button>
<Button
variant="primary"
size="sm"