feat: complete url modal/tab deeplinking, standardized inputs, and sync dynamic menu
All checks were successful
Deploy Canina / deploy (push) Successful in 1m44s

This commit is contained in:
parsa aghaei 2026-08-23 10:32:20 +03:30
parent 33c1bee483
commit 5029edaaeb
9 changed files with 454 additions and 70 deletions

View File

@ -30,14 +30,62 @@ export class MenuService implements OnModuleInit {
return;
}
// Seed with current site navigation
const headerItems = [
{ label: 'راهکارهای درمانی', href: '/shop?view=symptoms', order: 1 },
{ label: 'محصولات تخصصی', href: '/shop', order: 2 },
{ label: 'دانشنامه علمی', href: '/wiki', order: 3 },
{ label: 'مجله سلامت پت', href: '/blog', order: 4 },
{ label: 'شناسنامه پت‌ها', href: '/dashboard/pets', order: 5 },
{ label: 'ویدیوها', href: '/videos', order: 6 },
// Seed with comprehensive hierarchical site navigation matching application
const headerGroups = [
{
label: 'دسته‌بندی درمانی محصولات',
href: '/shop?view=symptoms',
order: 1,
icon: 'Pill',
children: [
{ label: 'مفاصل و استخوان', href: '/shop?category=joints', order: 1 },
{ label: 'تقویت سیستم ایمنی و گوارش', href: '/shop?category=immune', order: 2 },
{ label: 'ویتامین‌ها و انرژی‌بخش‌ها', href: '/shop?category=energy', order: 3 },
{ label: 'مراقبت‌های ویژه (پوست، دندان و چشم)', href: '/shop?category=special-care', order: 4 },
],
},
{
label: 'فروشگاه و محصولات',
href: '/shop',
order: 2,
icon: 'ShoppingBag',
children: [
{ label: 'فروشگاه تخصصی محصولات کنینا', href: '/shop', order: 1 },
{ label: 'کاتالوگ دیجیتال و راهنمای بالینی', href: '/catalog', order: 2 },
],
},
{
label: 'دانشنامه و آکادمی',
href: '/wiki',
order: 3,
icon: 'BookOpen',
children: [
{ label: 'دانشنامه علمی کنینا', href: '/wiki', order: 1 },
{ label: 'مجله سلامت پت (وبلاگ)', href: '/blog', order: 2 },
{ label: 'آکادمی ویدئویی و مشاوره دامپزشک', href: '/videos', order: 3 },
],
},
{
label: 'ابزارهای هوشمند',
href: '/profile',
order: 4,
icon: 'Sparkles',
children: [
{ label: 'شناسنامه و سوابق سلامت پت', href: '/profile', order: 1 },
{ label: 'پایش هوشمند مصرف مکمل‌ها', href: '/dashboard', order: 2 },
],
},
{
label: 'درباره کنینا و تماس',
href: '/about',
order: 5,
icon: 'Building2',
children: [
{ label: 'درباره کمپانی کنینا آلمان', href: '/about', order: 1 },
{ label: 'نمادهای اعتماد و مجوزهای رسمی', href: '/trust-seals', order: 2 },
{ label: 'تماس با مرکز پشتیبانی', href: '/contact', order: 3 },
],
},
];
const footerCol1 = [
@ -48,20 +96,48 @@ export class MenuService implements OnModuleInit {
];
const footerCol2 = [
{ label: 'فروشگاه تخصصی', href: '/shop', order: 1 },
{ label: 'دستهٔ‌بندی درمانی', href: '/shop?view=symptoms', order: 2 },
{ label: 'محصولات تخصصی', href: '/shop', order: 1 },
{ label: 'کاتالوگ دیجیتال', href: '/catalog', order: 2 },
{ label: 'دانشنامه علمی', href: '/wiki', order: 3 },
{ label: 'مجله سلامت پت', href: '/blog', order: 4 },
{ label: 'مجله سلامت پت (وبلاگ)', href: '/blog', order: 4 },
];
const footerCol3 = [
{ label: 'شناسنامه پت‌ها', href: '/dashboard/pets', order: 1 },
{ label: 'شناسنامه پت‌ها', href: '/profile', order: 1 },
{ label: 'سفارشات من', href: '/dashboard/orders', order: 2 },
{ label: 'ردیابی سفارش', href: '/track', order: 3 },
{ label: 'ورود / ثبت‌نام', href: '/?login=1', order: 4 },
];
const createItems = (items: typeof headerItems, menuType: MenuType) =>
for (const group of headerGroups) {
const parent = await this.prisma.menuItem.create({
data: {
label: group.label,
href: group.href,
order: group.order,
icon: group.icon,
menuType: 'HEADER',
isActive: true,
isExternal: false,
},
});
if (group.children && group.children.length > 0) {
await this.prisma.menuItem.createMany({
data: group.children.map((child) => ({
label: child.label,
href: child.href,
order: child.order,
parentId: parent.id,
menuType: 'HEADER',
isActive: true,
isExternal: false,
})),
});
}
}
const createFooterItems = (items: typeof footerCol1, menuType: MenuType) =>
items.map((item) => ({
...item,
menuType,
@ -71,14 +147,14 @@ export class MenuService implements OnModuleInit {
await this.prisma.menuItem.createMany({
data: [
...createItems(headerItems, 'HEADER'),
...createItems(footerCol1, 'FOOTER_COL1'),
...createItems(footerCol2, 'FOOTER_COL2'),
...createItems(footerCol3, 'FOOTER_COL3'),
...createFooterItems(footerCol1, 'FOOTER_COL1'),
...createFooterItems(footerCol2, 'FOOTER_COL2'),
...createFooterItems(footerCol3, 'FOOTER_COL3'),
],
});
}
async findByType(menuType: string) {
const items = await this.prisma.menuItem.findMany({
where: { menuType, isActive: true, parentId: null },

View File

@ -43,11 +43,14 @@ export interface CouponFormData {
targets: CouponTarget[];
}
import { useSearchParams } from 'react-router-dom';
export default function Coupons() {
const [searchParams, setSearchParams] = useSearchParams();
const [coupons, setCoupons] = useState<Coupon[]>([]);
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;
@ -59,20 +62,40 @@ export default function Coupons() {
targets: []
});
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 fetchData = useCallback(async () => {
try {
setIsLoading(true);
const res = await api.get('/admin/coupons', { params: { page, limit, search } });
if (res.data?.data) {
setCoupons(res.data.data);
const list: Coupon[] = res.data.data;
setCoupons(list);
setTotalPages(res.data.meta?.lastPage || 1);
const modalParam = searchParams.get('modal');
const couponId = searchParams.get('couponId');
if (modalParam === 'create') {
openModal(null);
} else if (modalParam === 'edit' && couponId) {
const found = list.find((c) => c.id === couponId);
if (found) openModal(found);
}
}
} catch (err) {
console.error(err);
} finally {
setIsLoading(false);
}
}, [page, search]);
}, [page, search, searchParams]);
useEffect(() => {
const timer = setTimeout(() => fetchData(), 500);
@ -82,6 +105,7 @@ export default function Coupons() {
const openModal = (coupon: Coupon | null = null) => {
if (coupon) {
setEditingCoupon(coupon);
updateUrlParams({ modal: 'edit', couponId: coupon.id });
setFormData({
code: coupon.code,
type: coupon.type,
@ -95,6 +119,7 @@ export default function Coupons() {
});
} else {
setEditingCoupon(null);
updateUrlParams({ modal: 'create', couponId: undefined });
setFormData({
code: '', type: 'percent', value: 0, minCartValue: '', maxCartValue: '', maxUses: '', expiresAt: '', isActive: true, targets: []
});
@ -102,6 +127,12 @@ export default function Coupons() {
setIsModalOpen(true);
};
const closeModal = () => {
setIsModalOpen(false);
updateUrlParams({ modal: undefined, couponId: undefined });
};
const handleSave = async (e: React.FormEvent) => {
e.preventDefault();
try {
@ -267,11 +298,12 @@ export default function Coupons() {
onCancel={() => setDeleteTargetId(null)}
/>
{isModalOpen && <CouponModal formData={formData} setFormData={setFormData} onSave={handleSave} onClose={() => setIsModalOpen(false)} isEditing={!!editingCoupon} />}
{isModalOpen && <CouponModal formData={formData} setFormData={setFormData} onSave={handleSave} onClose={closeModal} isEditing={!!editingCoupon} />}
</div>
);
}
interface CouponModalProps {
formData: CouponFormData;
setFormData: React.Dispatch<React.SetStateAction<CouponFormData>>;

View File

@ -35,8 +35,12 @@ const MENU_TABS: { key: MenuType; label: string; color: string }[] = [
{ key: 'FOOTER_COL3', label: '🔗 فوتر — ستون ۳', color: 'violet' },
];
import { useSearchParams } from 'react-router-dom';
export default function MenuManager() {
const [activeTab, setActiveTab] = useState<MenuType>('HEADER');
const [searchParams, setSearchParams] = useSearchParams();
const tabFromUrl = (searchParams.get('tab') as MenuType) || 'HEADER';
const [activeTab, setActiveTabState] = useState<MenuType>(tabFromUrl);
const [allItems, setAllItems] = useState<MenuItem[]>([]);
const [isLoading, setIsLoading] = useState(true);
const [isModalOpen, setIsModalOpen] = useState(false);
@ -44,6 +48,21 @@ export default function MenuManager() {
const [deleteTargetId, setDeleteTargetId] = useState<string | null>(null);
const [isSaving, setIsSaving] = useState(false);
const updateUrlState = (updates: Record<string, string | null>) => {
const current = Object.fromEntries(searchParams.entries());
const merged = { ...current, ...updates };
const cleaned: Record<string, string> = {};
Object.entries(merged).forEach(([k, v]) => {
if (v !== null && v !== undefined && v !== '') cleaned[k] = v;
});
setSearchParams(cleaned, { replace: true });
};
const setActiveTab = (t: MenuType) => {
setActiveTabState(t);
updateUrlState({ tab: t === 'HEADER' ? null : t });
};
const [formData, setFormData] = useState({
menuType: 'HEADER' as MenuType,
label: '',
@ -60,15 +79,24 @@ export default function MenuManager() {
try {
setIsLoading(true);
const res = await api.get('/menu/admin');
const data = Array.isArray(res.data) ? res.data : (res.data?.data || []);
const data: MenuItem[] = Array.isArray(res.data) ? res.data : (res.data?.data || []);
setAllItems(data);
const modalParam = searchParams.get('modal');
const itemIdParam = searchParams.get('itemId');
if (modalParam === 'create') {
openModal(null);
} else if (modalParam === 'edit' && itemIdParam) {
const found = data.find((it) => it.id === itemIdParam);
if (found) openModal(found);
}
} catch (err) {
console.error('Failed to fetch menu items:', err);
toast.error('خطا در دریافت آیتم‌های منو');
} finally {
setIsLoading(false);
}
}, []);
}, [searchParams]);
useEffect(() => {
fetchAll();
@ -81,6 +109,7 @@ export default function MenuManager() {
const openModal = (item: MenuItem | null = null, parentId?: string) => {
if (item) {
setEditingItem(item);
updateUrlState({ modal: 'edit', itemId: item.id });
setFormData({
menuType: item.menuType as MenuType,
label: item.label,
@ -94,6 +123,7 @@ export default function MenuManager() {
});
} else {
setEditingItem(null);
updateUrlState({ modal: 'create', itemId: null });
setFormData({
menuType: activeTab,
label: '',
@ -109,6 +139,12 @@ export default function MenuManager() {
setIsModalOpen(true);
};
const closeModal = () => {
setIsModalOpen(false);
updateUrlState({ modal: null, itemId: null });
};
const handleSave = async (e: React.FormEvent) => {
e.preventDefault();
if (!formData.label.trim() || !formData.href.trim()) {
@ -311,7 +347,7 @@ export default function MenuManager() {
{isModalOpen && (
<Modal
isOpen={isModalOpen}
onClose={() => setIsModalOpen(false)}
onClose={closeModal}
title={editingItem ? 'ویرایش آیتم منو' : 'افزودن آیتم جدید'}
icon={Menu}
maxWidth="xl"
@ -321,7 +357,7 @@ export default function MenuManager() {
variant="secondary"
size="sm"
type="button"
onClick={() => setIsModalOpen(false)}
onClick={closeModal}
>
انصراف
</Button>
@ -337,6 +373,7 @@ export default function MenuManager() {
</div>
}
>
<div className="space-y-6 text-right font-vazir">
<form id="menuForm" onSubmit={handleSave} className="space-y-4">
<div className="grid grid-cols-2 gap-4">

View File

@ -224,21 +224,42 @@ export default function Orders() {
const response = await api.get(`/admin/orders?${params.toString()}`);
if (response.data?.success) {
setOrders(response.data.data);
const orderList = response.data.data || [];
setOrders(orderList);
setTotalPages(response.data.meta?.lastPage || 1);
setSelectedOrder((currentSelected) => {
if (!currentSelected) return null;
const updated = response.data.data.find((o: Order) => o.id === currentSelected.id);
const updated = orderList.find((o: Order) => o.id === currentSelected.id);
return updated || currentSelected;
});
// Auto-open modal if URL specifies modal and orderId
const modalType = searchParams.get('modal');
const orderIdParam = searchParams.get('orderId');
if (modalType && orderIdParam) {
const foundOrder = orderList.find((o: Order) => o.id === orderIdParam);
if (foundOrder) {
if (modalType === 'details') {
setSelectedOrder(foundOrder);
setModalTrackingCode(foundOrder.trackingNumber || '');
setModalStatus(foundOrder.status || 'processing');
} else if (modalType === 'refund') {
setRefundModalOrder(foundOrder);
const amt = Number(foundOrder.totalAmount || foundOrder.total || 0);
setRefundAmount(String(amt));
setRefundTarget(foundOrder.paymentMethod === 'wallet' ? 'wallet' : 'zibal');
setRefundReason(`استرداد سفارش #${foundOrder.trackingNumber || foundOrder.id.slice(0, 8)}`);
}
}
}
}
} catch (err) {
console.error('Failed to fetch orders', err);
} finally {
setIsLoading(false);
}
}, [page, search, status, sortBy, sortOrder]);
}, [page, search, status, sortBy, sortOrder, searchParams]);
useEffect(() => {
const delayDebounceFn = setTimeout(() => {
@ -267,6 +288,12 @@ export default function Orders() {
setSelectedOrder(order);
setModalTrackingCode(order.trackingNumber || '');
setModalStatus(order.status || 'processing');
updateUrlParams({ modal: 'details', orderId: order.id });
};
const closeOrderModal = () => {
setSelectedOrder(null);
updateUrlParams({ modal: undefined, orderId: undefined });
};
const handleSaveModalChanges = async () => {
@ -301,8 +328,15 @@ export default function Orders() {
// If order was paid by wallet, default to wallet. If online, let admin choose.
setRefundTarget(order.paymentMethod === 'wallet' ? 'wallet' : 'zibal');
setRefundReason(`استرداد سفارش #${order.trackingNumber || order.id.slice(0, 8)}`);
updateUrlParams({ modal: 'refund', orderId: order.id });
};
const closeOrderRefundModal = () => {
setRefundModalOrder(null);
updateUrlParams({ modal: undefined, orderId: undefined });
};
const handleExecuteOrderRefund = async () => {
if (!refundModalOrder) return;
try {
@ -768,7 +802,7 @@ export default function Orders() {
{selectedOrder && (
<Modal
isOpen={!!selectedOrder}
onClose={() => setSelectedOrder(null)}
onClose={closeOrderModal}
title={`جزئیات سفارش #${selectedOrder.trackingNumber || selectedOrder.id.slice(0, 8)}`}
icon={ShoppingCart}
maxWidth="4xl"
@ -789,20 +823,21 @@ export default function Orders() {
startIcon={Undo2}
onClick={() => {
const ord = selectedOrder;
setSelectedOrder(null);
closeOrderModal();
openOrderRefundModal(ord);
}}
>
استرداد وجه سفارش
</Button>
)}
<Button variant="secondary" onClick={() => setSelectedOrder(null)}>
<Button variant="secondary" onClick={closeOrderModal}>
بستن
</Button>
</div>
</div>
}
>
<div className="space-y-6">
{/* Status & Timing Banner */}
<div className="flex flex-wrap items-center justify-between gap-4 p-4 bg-purple-50/50 rounded-2xl border border-purple-100">
@ -987,7 +1022,7 @@ export default function Orders() {
{refundModalOrder && (
<Modal
isOpen={!!refundModalOrder}
onClose={() => setRefundModalOrder(null)}
onClose={closeOrderRefundModal}
title={`استرداد وجه سفارش #${refundModalOrder.trackingNumber || refundModalOrder.id.slice(0, 8)}`}
icon={Undo2}
maxWidth="lg"
@ -996,7 +1031,7 @@ export default function Orders() {
<Button
variant="outline"
size="sm"
onClick={() => setRefundModalOrder(null)}
onClick={closeOrderRefundModal}
>
انصراف
</Button>
@ -1011,6 +1046,7 @@ export default function Orders() {
</div>
}
>
<div className="space-y-4 text-xs font-vazir">
{/* Destination selector */}
<div>

View File

@ -211,8 +211,24 @@ export default function Products() {
}
});
if (prodRes.data?.data) {
setProducts(prodRes.data.data);
const prodList = prodRes.data.data;
setProducts(prodList);
setTotalPages(prodRes.data.meta?.lastPage || 1);
// Auto-open modal if URL specifies modal
const modalParam = searchParams.get('modal');
const prodIdParam = searchParams.get('productId');
const tabParam = searchParams.get('tab');
if (modalParam === 'create') {
openModal(null);
if (tabParam) setActiveTab(tabParam);
} else if (modalParam === 'edit' && prodIdParam) {
const found = prodList.find((p: Product) => p.id === prodIdParam);
if (found) {
openModal(found);
if (tabParam) setActiveTab(tabParam);
}
}
}
// Fetch categories separately
@ -232,7 +248,7 @@ export default function Products() {
} finally {
setIsLoading(false);
}
}, [page, search, categoryFilter, suitableForFilter, sortBy, sortOrder]);
}, [page, search, categoryFilter, suitableForFilter, sortBy, sortOrder, searchParams]);
useEffect(() => {
const timer = setTimeout(() => {
@ -245,6 +261,7 @@ export default function Products() {
setMediaImageError(false);
if (product) {
setEditingProduct(product);
updateUrlParams({ modal: 'edit', productId: product.id, tab: activeTab || 'general' });
const bPrice = product.buyPrice ? Number(product.buyPrice) : '';
const pPrice = Number(product.priceValue || 0);
const wPrice = product.wholesalePrice ? Number(product.wholesalePrice) : '';
@ -295,6 +312,7 @@ export default function Products() {
});
} else {
setEditingProduct(null);
updateUrlParams({ modal: 'create', productId: undefined, tab: activeTab || 'general' });
setFormData({
artNo: '', nameFa: '', nameEn: '', scientificTagline: '', description: '', shortDescription: '', categoryId: '',
buyPrice: '', priceValue: 0, wholesalePrice: '', priceValueMarginPercent: '', wholesaleMarginPercent: '',
@ -307,10 +325,20 @@ export default function Products() {
symptoms: [], isPreorder: false, preorderDeposit: ''
});
}
setActiveTab('general');
setIsModalOpen(true);
};
const closeModal = () => {
setIsModalOpen(false);
updateUrlParams({ modal: undefined, productId: undefined, tab: undefined });
};
const handleTabChange = (newTab: string) => {
setActiveTab(newTab);
updateUrlParams({ tab: newTab });
};
const handleSave = async (e: React.FormEvent) => {
e.preventDefault();
@ -653,7 +681,7 @@ export default function Products() {
</div>
<button
type="button"
onClick={() => setIsModalOpen(false)}
onClick={closeModal}
className="w-8 h-8 rounded-xl bg-gray-50 text-gray-400 hover:text-gray-700 hover:bg-gray-100 flex items-center justify-center transition-colors cursor-pointer"
>
<X className="w-4 h-4" />
@ -670,7 +698,7 @@ export default function Products() {
<button
key={tab.id}
type="button"
onClick={() => setActiveTab(tab.id)}
onClick={() => handleTabChange(tab.id)}
className={`pb-3 px-2 text-xs sm:text-sm font-bold border-b-2 whitespace-nowrap transition-colors cursor-pointer ${activeTab === tab.id ? 'border-purple-600 text-purple-700' : 'border-transparent text-gray-500 hover:text-gray-700'}`}
>
{tab.label}
@ -678,6 +706,7 @@ export default function Products() {
))}
</div>
<div className="p-4 sm:p-6 overflow-y-auto flex-1 overscroll-contain bg-gray-50/30">
<form id="productForm" onSubmit={handleSave} className="space-y-6">

View File

@ -24,8 +24,25 @@ import Button from '../components/ui/Button';
import { useSearchParams } from 'react-router-dom';
export default function Settings() {
const [activeTab, setActiveTab] = useState<'brand' | 'contact' | 'links'>('brand');
const [searchParams, setSearchParams] = useSearchParams();
const activeTabParam = (searchParams.get('tab') as 'brand' | 'contact' | 'links') || 'brand';
const [activeTab, setActiveTabState] = useState<'brand' | 'contact' | 'links'>(activeTabParam);
const setActiveTab = (tab: 'brand' | 'contact' | 'links') => {
setActiveTabState(tab);
setSearchParams(tab === 'brand' ? {} : { tab }, { replace: true });
};
useEffect(() => {
const tabFromUrl = searchParams.get('tab') as 'brand' | 'contact' | 'links';
if (tabFromUrl && tabFromUrl !== activeTab) {
setActiveTabState(tabFromUrl);
}
}, [searchParams]);
const [settings, setSettings] = useState({
BRAND_LOGO_URL: '',

View File

@ -123,7 +123,9 @@ export default function Transactions() {
const [stats, setStats] = useState<Stats | null>(null);
const [health, setHealth] = useState<GatewayHealth | null>(null);
const [healthLoading, setHealthLoading] = useState(false);
const [isReconciling, setIsReconciling] = useState(false);
const [isLoading, setIsLoading] = useState(true);
const [totalPages, setTotalPages] = useState(1);
const [totalCount, setTotalCount] = useState(0);
@ -146,6 +148,8 @@ export default function Transactions() {
const [isSubmittingRefund, setIsSubmittingRefund] = useState(false);
const openReceiptModal = (tx: Transaction) => {
setParam('modal', 'receipt');
setParam('txId', tx.id);
setReceiptData({
trackId: tx.trackId || '-',
refNumber: tx.refNumber || '-',
@ -171,12 +175,27 @@ export default function Transactions() {
setReceiptModalOpen(true);
};
const closeReceiptModal = () => {
setReceiptModalOpen(false);
setReceiptData(null);
setMultipleParams({ modal: null, txId: null }, false);
};
const openDetailsModal = (tx: Transaction) => {
setParam('modal', 'details');
setParam('txId', tx.id);
setSelectedTx(tx);
setLiveInquiryData(null);
setShowRawLogs(false);
};
const closeDetailsModal = () => {
setSelectedTx(null);
setLiveInquiryData(null);
setShowRawLogs(false);
setMultipleParams({ modal: null, txId: null }, false);
};
const handleCopy = (text: string, label: string) => {
navigator.clipboard.writeText(text);
toast.success(`${label} با موفقیت کپی شد`);
@ -197,8 +216,9 @@ export default function Transactions() {
}
};
const openRefundModal = (tx: Transaction) => {
setParam('modal', 'refund');
setParam('txId', tx.id);
setRefundModalTx(tx);
setRefundTarget(tx.type === 'WALLET_TOPUP' || !tx.trackId ? 'wallet' : 'wallet');
setRefundAmount(String(tx.amount || ''));
@ -206,6 +226,12 @@ export default function Transactions() {
setTryReverse(true);
};
const closeRefundModal = () => {
setRefundModalTx(null);
setMultipleParams({ modal: null, txId: null }, false);
};
const handleExecuteRefund = async () => {
if (!refundModalTx) return;
@ -296,9 +322,22 @@ export default function Transactions() {
const res = await api.get(`/payment/admin/transactions?${params.toString()}`);
if (res.data) {
setTransactions(res.data.transactions || res.data.data || []);
const txList = res.data.transactions || res.data.data || [];
setTransactions(txList);
setTotalPages(res.data.totalPages || res.data.meta?.lastPage || 1);
setTotalCount(res.data.totalCount || res.data.meta?.total || 0);
// Auto-open modal if URL specifies modal and txId
const modalType = searchParams.get('modal');
const txIdParam = searchParams.get('txId');
if (modalType && txIdParam) {
const foundTx = txList.find((t: Transaction) => t.id === txIdParam);
if (foundTx) {
if (modalType === 'details') openDetailsModal(foundTx);
else if (modalType === 'receipt') openReceiptModal(foundTx);
else if (modalType === 'refund') openRefundModal(foundTx);
}
}
}
} catch (e) {
console.error('Failed to fetch transactions', e);
@ -306,7 +345,8 @@ export default function Transactions() {
} finally {
setIsLoading(false);
}
}, [page, search, statusFilter, gatewayFilter, typeFilter, sortBy, sortOrder]);
}, [page, search, statusFilter, gatewayFilter, typeFilter, sortBy, sortOrder, searchParams]);
useEffect(() => {
@ -762,7 +802,7 @@ export default function Transactions() {
{selectedTx && (
<Modal
isOpen={!!selectedTx}
onClose={() => setSelectedTx(null)}
onClose={closeDetailsModal}
title={`جزئیات تراکنش #${selectedTx.trackId || selectedTx.id.slice(0, 8)}`}
icon={Receipt}
maxWidth="3xl"
@ -774,7 +814,7 @@ export default function Transactions() {
startIcon={Printer}
onClick={() => {
const tx = selectedTx;
setSelectedTx(null);
closeDetailsModal();
openReceiptModal(tx);
}}
>
@ -789,7 +829,7 @@ export default function Transactions() {
startIcon={Undo2}
onClick={() => {
const tx = selectedTx;
setSelectedTx(null);
closeDetailsModal();
openRefundModal(tx);
}}
>
@ -814,13 +854,14 @@ export default function Transactions() {
</Button>
</>
)}
<Button variant="secondary" size="sm" onClick={() => setSelectedTx(null)}>
<Button variant="secondary" size="sm" onClick={closeDetailsModal}>
بستن
</Button>
</div>
</div>
}
>
<div className="space-y-4">
{/* Quick Overview Badges */}
@ -981,7 +1022,7 @@ export default function Transactions() {
value={refundAmount}
onChange={(e) => setRefundAmount(e.target.value)}
placeholder="مبلغ استرداد"
className="w-full border border-gray-200 rounded-xl p-3 font-mono outline-none focus:ring-2 focus:ring-purple-500 font-bold"
className="w-full border border-gray-200 rounded-xl p-3 font-mono outline-none focus:ring-2 focus:ring-purple-500 font-bold font-vazir"
dir="ltr"
/>
</div>
@ -993,7 +1034,7 @@ export default function Transactions() {
value={refundReason}
onChange={(e) => setRefundReason(e.target.value)}
placeholder="علت لغو سفارش یا مرجوعی کالا..."
className="w-full border border-gray-200 rounded-xl p-3 outline-none focus:ring-2 focus:ring-purple-500"
className="w-full border border-gray-200 rounded-xl p-3 outline-none focus:ring-2 focus:ring-purple-500 font-vazir"
/>
</div>
@ -1019,10 +1060,11 @@ export default function Transactions() {
{receiptModalOpen && receiptData && (
<TransactionReceiptModal
isOpen={receiptModalOpen}
onClose={() => setReceiptModalOpen(false)}
onClose={closeReceiptModal}
data={receiptData}
/>
)}
</div>
);
}

View File

@ -673,13 +673,47 @@ const SHARED_FOOTER_FIELDS: SectionField[] = [
{ key: 'footer_auth_badge', label: 'متن بج اصالت آلمان', type: 'text' },
];
import { useSearchParams } from 'react-router-dom';
export default function UITexts() {
const [versionTab, setVersionTab] = useState<'v2' | 'v1'>('v2');
const [v2SubTab, setV2SubTab] = useState<'header' | 'footer' | 'pages'>('pages');
const [openPageId, setOpenPageId] = useState<string>('home');
const [openSectionId, setOpenSectionId] = useState<string>('seo');
const [searchParams, setSearchParams] = useSearchParams();
const [versionTab, setVersionTabState] = useState<'v2' | 'v1'>(() => (searchParams.get('version') as 'v2' | 'v1') || 'v2');
const [v2SubTab, setV2SubTabState] = useState<'header' | 'footer' | 'pages'>(() => (searchParams.get('subTab') as 'header' | 'footer' | 'pages') || 'pages');
const [openPageId, setOpenPageIdState] = useState<string>(() => searchParams.get('pageId') || 'home');
const [openSectionId, setOpenSectionIdState] = useState<string>(() => searchParams.get('sectionId') || 'seo');
const [previewDevice, setPreviewDevice] = useState<'desktop' | 'mobile'>('desktop');
const updateUrlState = (updates: Record<string, string | null>) => {
const current = Object.fromEntries(searchParams.entries());
const merged = { ...current, ...updates };
const cleaned: Record<string, string> = {};
Object.entries(merged).forEach(([k, v]) => {
if (v !== null && v !== undefined && v !== '') cleaned[k] = v;
});
setSearchParams(cleaned, { replace: true });
};
const setVersionTab = (v: 'v2' | 'v1') => {
setVersionTabState(v);
updateUrlState({ version: v === 'v2' ? null : v });
};
const setV2SubTab = (t: 'header' | 'footer' | 'pages') => {
setV2SubTabState(t);
updateUrlState({ subTab: t === 'pages' ? null : t });
};
const setOpenPageId = (pid: string) => {
setOpenPageIdState(pid);
updateUrlState({ pageId: pid === 'home' ? null : pid });
};
const setOpenSectionId = (sid: string) => {
setOpenSectionIdState(sid);
updateUrlState({ sectionId: sid === 'seo' ? null : sid });
};
// State
const [texts, setTexts] = useState<Record<string, string>>({});
const [edits, setEdits] = useState<Record<string, string>>({});
@ -690,12 +724,27 @@ export default function UITexts() {
const [isSavingAll, setIsSavingAll] = useState(false);
// Modals
const [previewModalUrl, setPreviewModalUrl] = useState<string | null>(null);
const [mediaSelectorKey, setMediaSelectorKey] = useState<string | null>(null);
const [iconPickerKey, setIconPickerKey] = useState<string | null>(null);
const [previewModalUrl, setPreviewModalUrl] = useState<string | null>(() => searchParams.get('previewModal') || null);
const [mediaSelectorKey, setMediaSelectorKeyState] = useState<string | null>(() => searchParams.get('mediaKey') || null);
const [iconPickerKey, setIconPickerKeyState] = useState<string | null>(() => searchParams.get('iconKey') || null);
const setMediaSelectorKey = (k: string | null) => {
setMediaSelectorKeyState(k);
updateUrlState({ mediaKey: k });
};
const setIconPickerKey = (k: string | null) => {
setIconPickerKeyState(k);
updateUrlState({ iconKey: k });
};
// Legacy v1 State
const [v1ActiveTab, setV1ActiveTab] = useState('all');
const [v1ActiveTab, setV1ActiveTabState] = useState(() => searchParams.get('v1Tab') || 'all');
const setV1ActiveTab = (tab: string) => {
setV1ActiveTabState(tab);
updateUrlState({ v1Tab: tab === 'all' ? null : tab });
};
useEffect(() => {
let isSubscribed = true;

View File

@ -79,6 +79,61 @@ export default function Header({
}
]);
interface HeaderMenuItem {
id: string;
label: string;
href: string;
icon?: string;
children?: { id: string; label: string; href: string }[];
}
const [headerNavItems, setHeaderNavItems] = useState<HeaderMenuItem[]>([
{
id: 'category-mega',
label: 'دسته‌بندی درمانی محصولات',
href: '/shop?view=symptoms',
icon: 'Pill',
},
{
id: 'products',
label: 'فروشگاه و محصولات',
href: '/shop',
children: [
{ id: 'shop-all', label: 'فروشگاه تخصصی محصولات کنینا', href: '/shop' },
{ id: 'catalog', label: 'کاتالوگ دیجیتال و راهنمای بالینی', href: '/catalog' },
],
},
{
id: 'science',
label: 'دانشنامه و آکادمی',
href: '/wiki',
children: [
{ id: 'wiki', label: 'دانشنامه علمی کنینا', href: '/wiki' },
{ id: 'blog', label: 'مجله سلامت پت (وبلاگ)', href: '/blog' },
{ id: 'videos', label: 'آکادمی ویدئویی و مشاوره دامپزشک', href: '/videos' },
],
},
{
id: 'tools',
label: 'ابزارهای هوشمند',
href: '/profile',
children: [
{ id: 'profile', label: 'شناسنامه و سوابق سلامت پت', href: '/profile' },
{ id: 'dashboard', label: 'پایش هوشمند مصرف مکمل‌ها', href: '/dashboard' },
],
},
{
id: 'company',
label: 'درباره کنینا و تماس',
href: '/about',
children: [
{ id: 'about', label: 'درباره کمپانی کنینا آلمان', href: '/about' },
{ id: 'trust-seals', label: 'نمادهای اعتماد و مجوزهای رسمی', href: '/trust-seals' },
{ id: 'contact', label: 'تماس با مرکز پشتیبانی', href: '/contact' },
],
},
]);
const [isMounted, setIsMounted] = useState(false);
useEffect(() => {
Promise.resolve().then(() => setIsMounted(true));
@ -92,24 +147,35 @@ export default function Header({
const isCartDisabled = isCatalogMode || isCatalogDisableCart;
useEffect(() => {
const loadNavFilters = async () => {
const loadDynamicNav = async () => {
try {
const data = await productService.getNavigationFilters();
if (data && data.length > 0) {
setMenuItems(data.map(item => ({
const [filtersRes, menuRes] = await Promise.allSettled([
productService.getNavigationFilters(),
fetch(`${process.env.NEXT_PUBLIC_API_URL || '/api'}/menu/type/HEADER`).then((r) => r.json()),
]);
if (filtersRes.status === 'fulfilled' && filtersRes.value && filtersRes.value.length > 0) {
setMenuItems(
filtersRes.value.map((item) => ({
id: item.slug,
title: item.name,
icon: MENU_ICONS[item.slug] || <Pill className="w-5 h-5" />,
solutions: item.symptoms || []
})));
solutions: item.symptoms || [],
}))
);
}
if (menuRes.status === 'fulfilled' && Array.isArray(menuRes.value) && menuRes.value.length > 0) {
setHeaderNavItems(menuRes.value);
}
} catch (err) {
console.error('Failed to load navigation filters:', err);
console.error('Failed to load navigation data:', err);
}
};
loadNavFilters();
loadDynamicNav();
}, []);
useEffect(() => {
function handleClickOutside(event: MouseEvent) {
if (userProfileRef.current && !userProfileRef.current.contains(event.target as Node)) {