From 62f3b49ed4445f866c2d0d515aea82d6d992be21 Mon Sep 17 00:00:00 2001 From: AmirReza Jamali Date: Sat, 15 Nov 2025 13:28:30 +0330 Subject: [PATCH 1/5] feat: add HTML sanitization for notification messages Implement sanitizeHtmlWithStyles utility to safely render HTML content in notification messages while removing dangerous tags and attributes. Update NotificationDetailsCard to use dangerouslySetInnerHTML with sanitization for message display. --- .../ui/notification-details-card.tsx | 8 +- src/lib/utils.ts | 81 ++++++++++++++++++- 2 files changed, 85 insertions(+), 4 deletions(-) diff --git a/src/components/ui/notification-details-card.tsx b/src/components/ui/notification-details-card.tsx index 54332f7..fb05bbf 100644 --- a/src/components/ui/notification-details-card.tsx +++ b/src/components/ui/notification-details-card.tsx @@ -1,6 +1,7 @@ "use client"; import { TNotificationDetailsResponse } from "@/lib/api/types/NotificationHistory"; import { capitalize, unixToDate } from "@/utils/shared"; +import { sanitizeHtmlWithStyles } from "@/lib/utils"; import { FC } from "react"; import IsVisible from "./IsVisible"; import Status from "./Status"; @@ -43,7 +44,12 @@ export const NotificationDetailsCard: FC = ({ data }) => {
-

{data.message}

+
diff --git a/src/lib/utils.ts b/src/lib/utils.ts index bd0c391..1a0bb60 100644 --- a/src/lib/utils.ts +++ b/src/lib/utils.ts @@ -1,6 +1,81 @@ -import { clsx, type ClassValue } from "clsx" -import { twMerge } from "tailwind-merge" +import { clsx, type ClassValue } from "clsx"; +import { twMerge } from "tailwind-merge"; export function cn(...inputs: ClassValue[]) { - return twMerge(clsx(inputs)) + return twMerge(clsx(inputs)); +} + +export function sanitizeHtmlWithStyles(htmlString: string): string { + if (!htmlString) return ""; + + if (typeof window !== "undefined") { + const tempDiv = document.createElement("div"); + tempDiv.innerHTML = htmlString; + + const dangerousTags = [ + "script", + "iframe", + "object", + "embed", + "form", + "input", + "button", + ]; + const dangerousAttributes = [ + "onclick", + "onload", + "onerror", + "onmouseover", + "onfocus", + "onblur", + ]; + + dangerousTags.forEach((tag) => { + const elements = tempDiv.getElementsByTagName(tag); + for (let i = elements.length - 1; i >= 0; i--) { + elements[i].remove(); + } + }); + + const allElements = tempDiv.getElementsByTagName("*"); + for (let i = 0; i < allElements.length; i++) { + const element = allElements[i]; + dangerousAttributes.forEach((attr) => { + if (element.hasAttribute(attr)) { + element.removeAttribute(attr); + } + }); + + Array.from(element.attributes).forEach((attr) => { + if (attr.name.toLowerCase().startsWith("on")) { + element.removeAttribute(attr.name); + } + }); + } + + return tempDiv.innerHTML; + } + + return htmlString + .replace(/]*>[\s\S]*?<\/script>/gi, "") + .replace(/]*>[\s\S]*?<\/iframe>/gi, "") + .replace(/]*>[\s\S]*?<\/object>/gi, "") + .replace(/]*>/gi, "") + .replace(/on\w+="[^"]*"/gi, "") + .replace(/on\w+='[^']*'/gi, ""); +} + +export function stripHtmlTags(htmlString: string): string { + if (!htmlString) return ""; + + if (typeof window !== "undefined") { + const tempDiv = document.createElement("div"); + tempDiv.innerHTML = htmlString; + return tempDiv.textContent || tempDiv.innerText || ""; + } + + return htmlString + .replace(/<[^>]*>/g, "") + .replace(/ /g, " ") + .trim(); } From 1407d93704d8311251c1f24f6fd9d3e2d1ed33d6 Mon Sep 17 00:00:00 2001 From: AmirReza Jamali Date: Sat, 22 Nov 2025 15:14:19 +0330 Subject: [PATCH 2/5] feat(tenders): Add filtering functionality to tender list table - Create new TenderListFilters component with title, status, and country code filter fields - Add filter modal to TendersTable component with open/close state management - Implement useForm hook in useTenderListPresenter for filter form handling - Add ListHeader component with filter button to tender table - Replace debounced search with URL search params-based filtering - Integrate filter form submission with tender query parameters - Add isMutating state tracking for loading indicator during filter operations - Remove deprecated useDebounce hook and search state from presenter - Improve filter UX with modal dialog and clear button functionality --- .../Tables/tenders/TenderListFilters.tsx | 89 +++++++++++++++++++ src/components/Tables/tenders/index.tsx | 41 ++++++++- .../Tables/tenders/useTenderListPresenter.ts | 61 +++++++++---- 3 files changed, 170 insertions(+), 21 deletions(-) create mode 100644 src/components/Tables/tenders/TenderListFilters.tsx diff --git a/src/components/Tables/tenders/TenderListFilters.tsx b/src/components/Tables/tenders/TenderListFilters.tsx new file mode 100644 index 0000000..f3620fe --- /dev/null +++ b/src/components/Tables/tenders/TenderListFilters.tsx @@ -0,0 +1,89 @@ +import InputGroup from "@/components/FormElements/InputGroup"; +import { Select } from "@/components/FormElements/select"; +import { Countries } from "@/constants/countries"; +import { TenderStatus } from "@/constants/enums"; +import { getEnumAsArray } from "@/utils/shared"; +import { + FieldValues, + UseFormHandleSubmit, + UseFormRegister, + UseFormSetValue, + UseFormWatch, +} from "react-hook-form"; + +interface TenderListFiltersProps { + filterRegister: UseFormRegister; + setIsFilterModalOpen: (isOpen: boolean) => void; + isMutating: number; + handleFilterSubmit: UseFormHandleSubmit; + search: (data: any) => void; + watch: UseFormWatch; + setValue: UseFormSetValue; +} + +const TenderListFilters = ({ + filterRegister, + setIsFilterModalOpen, + isMutating, + handleFilterSubmit, + search, + watch, + setValue, +}: TenderListFiltersProps) => { + return ( +
+ + setValue("country_code", undefined)} + /> + +
+ + +
+ + ); +}; + +export default TenderListFilters; diff --git a/src/components/Tables/tenders/index.tsx b/src/components/Tables/tenders/index.tsx index a89295b..d0e15b3 100644 --- a/src/components/Tables/tenders/index.tsx +++ b/src/components/Tables/tenders/index.tsx @@ -13,17 +13,38 @@ import { Tooltip } from "react-tooltip"; import useTenderListPresenter from "./useTenderListPresenter"; import { ExclamationIcon, EyeIcon } from "@/assets/icons"; +import ListHeader from "@/components/ui/ListHeader"; +import Modal from "@/components/ui/modal"; import Pagination from "@/components/ui/pagination"; import TableSkeleton from "@/components/ui/TableSkeleton"; import { _TooltipDefaultParams } from "@/constants/tooltip"; import { cn } from "@/lib/utils"; import Link from "next/link"; +import TenderListFilters from "./TenderListFilters"; const TendersTable = () => { - const { allTenders, isPending, router, pathName, setParams, columns } = - useTenderListPresenter(); + const { + allTenders, + isPending, + router, + pathName, + setParams, + columns, + isFilterModalOpen, + setIsFilterModalOpen, + filterRegister, + handleFilterSubmit, + search, + watch, + setFilterValue, + isMutating, + } = useTenderListPresenter(); return (
+ setIsFilterModalOpen(true)} + createButtonText="Create Tender" + /> @@ -104,6 +125,22 @@ const TendersTable = () => { )}
+ setIsFilterModalOpen(false)} + classNames="w-full xl:w-1/2" + showButtons={false} + > + + { const [isModalOpen, setIsModalOpen] = useState(false); + const [isFilterModalOpen, setIsFilterModalOpen] = useState(false); const [currentTender, setCurrentTender] = useState(null); - const [search, setSearch] = useState(""); const [modalConfig, setModalConfig] = useState< Partial >({}); - const [params, setParams] = useState>({ - ...apiDefaultParams, - }); + const [params, setParams] = useState>({}); const router = useRouter(); - const debouncedSearch = useDebounce(search, 1000); const pathName = usePathname(); - const { data, error, isPending } = useTendersQuery(params); + const searchParams = useSearchParams(); + const isMutating = useIsMutating(); + + const { + register: filterRegister, + handleSubmit: handleFilterSubmit, + reset: filterFormReset, + watch, + setValue: setFilterValue, + } = useForm(); useEffect(() => { - const result = z.string().safeParse(debouncedSearch); - if (result.success) { - setParams((prevParams) => ({ - ...prevParams, - })); + const newParams: Record = { + ...apiDefaultParams, + offset: 0, + limit: 10, + }; + for (const [key, value] of searchParams.entries()) { + newParams[key] = value; } - }, [debouncedSearch]); + setParams(newParams); + filterFormReset(newParams); + }, [searchParams, filterFormReset]); - const handleFilterChange = (e: React.ChangeEvent) => { - setSearch(e.target.value); + const { data, error, isPending } = useTendersQuery(params); + + const search = (data: any) => { + const newParams = { ...params, ...data, offset: 0 }; + const cleanedParams = deleteEmptyKeys(newParams); + const queryString = new URLSearchParams(cleanedParams).toString(); + router.push(`${pathName}?${queryString}`); + setIsFilterModalOpen(false); }; const columns = [ "row", @@ -46,8 +63,6 @@ const useTenderListPresenter = () => { currentTender, columns, setCurrentTender, - search, - handleFilterChange, router, allTenders: data, error, @@ -59,6 +74,14 @@ const useTenderListPresenter = () => { setIsModalOpen, modalConfig, setModalConfig, + isFilterModalOpen, + setIsFilterModalOpen, + filterRegister, + handleFilterSubmit, + search, + watch, + setFilterValue, + isMutating, }; }; From 7005ab67b71dcb79056c2d8a440330b5998d6a2a Mon Sep 17 00:00:00 2001 From: AmirReza Jamali Date: Sat, 22 Nov 2025 15:22:44 +0330 Subject: [PATCH 3/5] feat(customers): Add filtering functionality to customer list table - Create new CustomerListFilters component with status, role, and type filter options - Integrate filter modal into customer list table with open/close functionality - Add filter form handling with react-hook-form for managing filter state - Implement search functionality triggered by filter form submission - Add EmptyListWrapper component to display message when no customers found - Replace inline create button with reusable ListHeader component - Add clearable select fields for better filter UX - Update customer table to safely handle optional customer properties - Add new CustomerStatus and CustomerType enums to support filtering options - Improve table organization with proper imports and component structure --- .../Tables/customers/CustomerListFilters.tsx | 90 +++++++ src/components/Tables/customers/index.tsx | 232 ++++++++++-------- .../customers/useCustomerListPresenter.ts | 51 +++- src/constants/enums.ts | 10 + 4 files changed, 278 insertions(+), 105 deletions(-) create mode 100644 src/components/Tables/customers/CustomerListFilters.tsx diff --git a/src/components/Tables/customers/CustomerListFilters.tsx b/src/components/Tables/customers/CustomerListFilters.tsx new file mode 100644 index 0000000..1f75d5b --- /dev/null +++ b/src/components/Tables/customers/CustomerListFilters.tsx @@ -0,0 +1,90 @@ +import { Select } from "@/components/FormElements/select"; +import { AdminRoles, CustomerStatus, CustomerType } from "@/constants/enums"; +import { getEnumAsArray } from "@/utils/shared"; +import { + FieldValues, + UseFormHandleSubmit, + UseFormRegister, + UseFormSetValue, + UseFormWatch, +} from "react-hook-form"; + +interface CustomerListFiltersProps { + filterRegister: UseFormRegister; + setIsFilterModalOpen: (isOpen: boolean) => void; + isMutating: number; + handleFilterSubmit: UseFormHandleSubmit; + search: (data: any) => void; + watch: UseFormWatch; + setValue: UseFormSetValue; +} + +const CustomerListFilters = ({ + filterRegister, + setIsFilterModalOpen, + isMutating, + handleFilterSubmit, + search, + watch, + setValue, +}: CustomerListFiltersProps) => { + return ( +
+ setValue("role", undefined)} + /> + setValue("currency", undefined)} + /> + + +
+ + +
+ + ); +}; + +export default CompanyListFilters; diff --git a/src/components/Tables/companies/index.tsx b/src/components/Tables/companies/index.tsx index 3c10765..665cad9 100644 --- a/src/components/Tables/companies/index.tsx +++ b/src/components/Tables/companies/index.tsx @@ -17,9 +17,11 @@ import { import TableSkeleton from "@/components/ui/TableSkeleton"; import { _TooltipDefaultParams } from "@/constants/tooltip"; import { formatPhoneNumber } from "@/utils/shared"; -import Link from "next/link"; import { Tooltip } from "react-tooltip"; import { useCompanyListPresenter } from "./useCompanyListPresenter"; +import ListHeader from "@/components/ui/ListHeader"; +import Modal from "@/components/ui/modal"; +import CompanyListFilters from "./CompanyListFilters"; const CompaniesTable = () => { const { @@ -33,12 +35,21 @@ const CompaniesTable = () => { setCurrentCompany, onConfirmDelete, modalConfig, + isFilterModalOpen, + setIsFilterModalOpen, + filterRegister, + handleFilterSubmit, + search, + watch, + setFilterValue, + isMutating, } = useCompanyListPresenter(); return (
- + setIsFilterModalOpen(true)} + createButtonText="Create Company" + /> @@ -149,6 +160,22 @@ const CompaniesTable = () => { )}
+ setIsFilterModalOpen(false)} + classNames="w-full xl:w-1/2" + showButtons={false} + > + + {isModalOpen && ( { const deleteModal = useRef(null); const { inView } = useInView({ threshold: 0.5 }); const [currentCompany, setCurrentCompany] = useState(null); - const [search, setSearch] = useState(""); + const [isFilterModalOpen, setIsFilterModalOpen] = useState(false); const [modalConfig, setModalConfig] = useState< Partial >({}); - const [params, setParams] = useState>({ - sort: "created_at", - }); + const [params, setParams] = useState>({}); const [isModalOpen, setIsModalOpen] = useState(false); const router = useRouter(); + const pathName = usePathname(); + const searchParams = useSearchParams(); + const isMutating = useIsMutating(); + + const { + register: filterRegister, + handleSubmit: handleFilterSubmit, + reset: filterFormReset, + watch, + setValue: setFilterValue, + } = useForm(); + const columns = [ "row", "name", @@ -31,14 +42,23 @@ export const useCompanyListPresenter = () => { "phone", "country", "state", - "language", "currency", "employee count", "actions", ]; - const pathName = usePathname(); - const debouncedSearch = useDebounce(search, 1000); + + useEffect(() => { + const newParams: Record = { + sort: "created_at", + }; + for (const [key, value] of searchParams.entries()) { + newParams[key] = value; + } + setParams(newParams); + filterFormReset(newParams); + }, [searchParams, filterFormReset]); + const { data, error, @@ -48,24 +68,23 @@ export const useCompanyListPresenter = () => { hasNextPage, isFetchingNextPage, } = useCompaniesInfiniteQuery(params); + useEffect(() => { if (inView && hasNextPage && data && data.pages.length < 5) { fetchNextPage(); } }, [inView, hasNextPage, data, fetchNextPage]); + const { mutate: deleteCompany } = useDeleteCompanyQuery(() => setIsModalOpen(false), ); - useEffect(() => { - const result = z.string().safeParse(debouncedSearch); - if (result.success) { - setParams((prevParams) => ({ - ...prevParams, - })); - } - }, [debouncedSearch]); - const handleFilterChange = (e: ChangeEvent) => { - setSearch(e.target.value); + + const search = (data: any) => { + const newParams = { ...params, ...data }; + const cleanedParams = deleteEmptyKeys(newParams); + const queryString = new URLSearchParams(cleanedParams).toString(); + router.push(`${pathName}?${queryString}`); + setIsFilterModalOpen(false); }; const onConfirmDelete = () => { if (currentCompany) { @@ -75,7 +94,6 @@ export const useCompanyListPresenter = () => { const allCompanies = data?.pages.flatMap((page) => page.data.companies) ?? []; return { allCompanies, - handleFilterChange, onConfirmDelete, deleteCompany, isFetchingNextPage, @@ -95,6 +113,13 @@ export const useCompanyListPresenter = () => { router, isPending, isError, + isFilterModalOpen, + setIsFilterModalOpen, + filterRegister, + handleFilterSubmit, search, + watch, + setFilterValue, + isMutating, }; }; diff --git a/src/constants/enums.ts b/src/constants/enums.ts index 2f30f33..e0f1d33 100644 --- a/src/constants/enums.ts +++ b/src/constants/enums.ts @@ -49,3 +49,15 @@ export enum CustomerType { COMPANY = "company", GOVERNMENT = "government", } +export const Currencies = [ + { label: "USD", value: "USD" }, + { label: "EUR", value: "EUR" }, + { label: "GBP", value: "GBP" }, + { label: "JPY", value: "JPY" }, + { label: "CAD", value: "CAD" }, + { label: "AUD", value: "AUD" }, + { label: "CHF", value: "CHF" }, + { label: "CNY", value: "CNY" }, + { label: "INR", value: "INR" }, + { label: "BRL", value: "BRL" }, +]; From e13527fe816cdc65b9d1c34d887b96f581ff933d Mon Sep 17 00:00:00 2001 From: AmirReza Jamali Date: Sat, 22 Nov 2025 15:54:31 +0330 Subject: [PATCH 5/5] feat(company-categories): Add filtering functionality to category list table - Create CompanyCategoryListFilters component with search and published status filters - Integrate ListHeader component to replace inline create button - Add filter modal with form handling and submission logic - Implement useForm hook for filter form state management - Add search parameter synchronization with URL query params - Support clearing individual filter fields and resetting filters - Add loading state indicator during filter search operations - Maintain consistency with existing filtering patterns used in companies and tenders tables --- .../CompanyCategoryListFilters.tsx | 79 +++++++++++++++++++ .../companies/company-categories/index.tsx | 35 +++++++- .../useCompanyCategoriesPresenter.ts | 54 +++++++++++-- 3 files changed, 158 insertions(+), 10 deletions(-) create mode 100644 src/components/Tables/companies/company-categories/CompanyCategoryListFilters.tsx diff --git a/src/components/Tables/companies/company-categories/CompanyCategoryListFilters.tsx b/src/components/Tables/companies/company-categories/CompanyCategoryListFilters.tsx new file mode 100644 index 0000000..2292d9c --- /dev/null +++ b/src/components/Tables/companies/company-categories/CompanyCategoryListFilters.tsx @@ -0,0 +1,79 @@ +import InputGroup from "@/components/FormElements/InputGroup"; +import { Select } from "@/components/FormElements/select"; +import { + FieldValues, + UseFormHandleSubmit, + UseFormRegister, + UseFormSetValue, + UseFormWatch, +} from "react-hook-form"; + +interface CompanyCategoryListFiltersProps { + filterRegister: UseFormRegister; + setIsFilterModalOpen: (isOpen: boolean) => void; + isMutating: number; + handleFilterSubmit: UseFormHandleSubmit; + search: (data: any) => void; + watch: UseFormWatch; + setValue: UseFormSetValue; +} + +const CompanyCategoryListFilters = ({ + filterRegister, + setIsFilterModalOpen, + isMutating, + handleFilterSubmit, + search, + watch, + setValue, +}: CompanyCategoryListFiltersProps) => { + return ( +
+ +