feat: complete url modal/tab deeplinking, standardized inputs, and sync dynamic menu
All checks were successful
Deploy Canina / deploy (push) Successful in 1m44s
All checks were successful
Deploy Canina / deploy (push) Successful in 1m44s
This commit is contained in:
parent
33c1bee483
commit
5029edaaeb
@ -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 },
|
||||
|
||||
@ -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>>;
|
||||
|
||||
@ -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">
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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">
|
||||
|
||||
|
||||
@ -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: '',
|
||||
|
||||
@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@ -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;
|
||||
|
||||
@ -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)) {
|
||||
|
||||
Loading…
Reference in New Issue
Block a user