feat(dependencies): add Lottie animation libraries and refactor NotFoundPage

- Added `@lottiefiles/dotlottie-react` and `lottie-react` dependencies for enhanced animation capabilities.
- Refactored the NotFoundPage component to utilize a dedicated NotFoundContent component, improving code organization and readability.
- Updated package.json and pnpm-lock.yaml to reflect new dependencies and their versions.
This commit is contained in:
AmirReza Jamali
2026-05-13 17:07:47 +03:30
parent d1bc56dc60
commit 25f8bde229
49 changed files with 3511 additions and 2197 deletions
@@ -72,6 +72,24 @@ describe("tender details - translation and summary", () => {
});
it("switches language from selector and reloads details with new lang query", () => {
cy.intercept(
"POST",
`**/api/proxy/tenders/${tenderId}/ai-translate`,
(req) => {
req.reply({
statusCode: 200,
body: {
success: true,
message: "ok",
data: {
notice_id: "NP-123",
language: String(req.body?.language ?? ""),
},
},
});
},
).as("translateOnLangChange");
cy.intercept("GET", `**/api/proxy/tenders/${tenderId}**`, (req) => {
const lang = String(req.query.lang ?? "en");
req.alias = lang === "fr" ? "getTenderDetailsFr" : "getTenderDetailsEn";
@@ -91,18 +109,19 @@ describe("tender details - translation and summary", () => {
cy.get('select[name="lang"]').select("fr");
cy.location("search").should("include", "lang=fr");
cy.wait("@translateOnLangChange");
cy.wait("@getTenderDetailsFr");
tenderTitleHeading().should("contain.text", "Titre FR");
cy.contains("Resume FR").should("be.visible");
});
it("triggers translate endpoint with selected language", () => {
it("triggers translate endpoint when language changes", () => {
cy.intercept("GET", `**/api/proxy/tenders/${tenderId}**`, {
statusCode: 200,
body: makeTenderDetailsResponse({
title: "Before translate",
description: "Before translate description",
language: "de",
language: "en",
}),
}).as("getTenderDetails");
@@ -127,9 +146,9 @@ describe("tender details - translation and summary", () => {
},
).as("translateTender");
cy.visit(`/tenders/${tenderId}?lang=de`);
cy.visit(`/tenders/${tenderId}?lang=en`);
cy.wait("@getTenderDetails");
cy.contains("button", "Translate metadata").click();
cy.get('select[name="lang"]').select("de");
cy.wait("@translateTender");
});
+2
View File
@@ -14,6 +14,7 @@
"test:e2e": "start-server-and-test dev:e2e http://localhost:3000 \"CYPRESS_BASE_URL=http://localhost:3000 cypress run\""
},
"dependencies": {
"@lottiefiles/dotlottie-react": "^0.19.2",
"@tanstack/react-query": "^5.85.6",
"@types/js-cookie": "^3.0.6",
"@types/react-paginate": "^7.1.4",
@@ -28,6 +29,7 @@
"gsap": "^3.15.0",
"js-cookie": "^3.0.5",
"jsvectormap": "^1.6.0",
"lottie-react": "^2.4.1",
"moment": "^2.30.1",
"next": "15.1.9",
"next-themes": "^0.4.4",
+38
View File
@@ -8,6 +8,9 @@ importers:
.:
dependencies:
'@lottiefiles/dotlottie-react':
specifier: ^0.19.2
version: 0.19.2(react@19.0.0)
'@tanstack/react-query':
specifier: ^5.85.6
version: 5.100.2(react@19.0.0)
@@ -50,6 +53,9 @@ importers:
jsvectormap:
specifier: ^1.6.0
version: 1.7.0
lottie-react:
specifier: ^2.4.1
version: 2.4.1(react-dom@19.0.0(react@19.0.0))(react@19.0.0)
moment:
specifier: ^2.30.1
version: 2.30.1
@@ -597,6 +603,14 @@ packages:
'@jridgewell/trace-mapping@0.3.31':
resolution: {integrity: sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==}
'@lottiefiles/dotlottie-react@0.19.2':
resolution: {integrity: sha512-qXvCU5Wm/BNYfoS/+MzeVqn03yu2v3XTJ+ik8kKgyHlbf6Vcgz3byOQk9H/mOciedjhGr+YmZXZ+ItqFUmA3Iw==}
peerDependencies:
react: ^17 || ^18 || ^19
'@lottiefiles/dotlottie-web@0.72.1':
resolution: {integrity: sha512-TPHCTQHw4oZBiIi/JPd6CtjPzsazpyRVUloO7kA5khF6IX/sCIfBa8l/Tav333pskO6gFMtlnfRPwEOatMZS/A==}
'@napi-rs/wasm-runtime@0.2.12':
resolution: {integrity: sha512-ZVWUcfwY4E/yPitQJl481FjFo3K22D6qF0DuFH6Y/nbnE11GY5uguDxZMGXPQ8WQ0128MXQD7TnfHyK4oWoIJQ==}
@@ -2103,6 +2117,15 @@ packages:
resolution: {integrity: sha512-lyuxPGr/Wfhrlem2CL/UcnUc1zcqKAImBDzukY7Y5F/yQiNdko6+fRLevlw1HgMySw7f611UIY408EtxRSoK3Q==}
hasBin: true
lottie-react@2.4.1:
resolution: {integrity: sha512-LQrH7jlkigIIv++wIyrOYFLHSKQpEY4zehPicL9bQsrt1rnoKRYCYgpCUe5maqylNtacy58/sQDZTkwMcTRxZw==}
peerDependencies:
react: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0
react-dom: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0
lottie-web@5.13.0:
resolution: {integrity: sha512-+gfBXl6sxXMPe8tKQm7qzLnUy5DUPJPKIyRHwtpCpyUEYjHYRJC/5gjUvdkuO2c3JllrPtHXH5UJJK8LRYl5yQ==}
map-stream@0.1.0:
resolution: {integrity: sha512-CkYQrPYZfWnu/DAmVCpTSX/xHpKZ80eKh2lAkyA6AJTef6bW+6JpbQZN5rofum7da+SyN1bi5ctTm+lTfcCW3g==}
@@ -3588,6 +3611,13 @@ snapshots:
'@jridgewell/resolve-uri': 3.1.2
'@jridgewell/sourcemap-codec': 1.5.5
'@lottiefiles/dotlottie-react@0.19.2(react@19.0.0)':
dependencies:
'@lottiefiles/dotlottie-web': 0.72.1
react: 19.0.0
'@lottiefiles/dotlottie-web@0.72.1': {}
'@napi-rs/wasm-runtime@0.2.12':
dependencies:
'@emnapi/core': 1.10.0
@@ -5280,6 +5310,14 @@ snapshots:
dependencies:
js-tokens: 4.0.0
lottie-react@2.4.1(react-dom@19.0.0(react@19.0.0))(react@19.0.0):
dependencies:
lottie-web: 5.13.0
react: 19.0.0
react-dom: 19.0.0(react@19.0.0)
lottie-web@5.13.0: {}
map-stream@0.1.0: {}
math-intrinsics@1.1.0: {}
Binary file not shown.
Binary file not shown.
+4 -36
View File
@@ -1,37 +1,5 @@
import Image from "next/image";
import Link from "next/link";
import NotFoundContent from "@/components/not-found-content";
const NotFoundPage = () => {
return (
<div className="mx-auto flex w-6/12 flex-col gap-12">
<section className="flex w-full items-center justify-center">
<Image src={"/404.svg"} alt="404" width={700} height={1000} />
</section>
<section>
<div>
<Image
src={"/cable.svg"}
width={2000}
height={3000}
alt="not connected cable"
/>
</div>
</section>
<section className="mt-10 flex flex-col items-center justify-center gap-10">
<h1 className="text-2xl font-bold">Page not found</h1>
<p className="text-[16px] font-semibold text-[#77707f]">
Sorry, the page you&apos;re looking for does not exist or has been
removed.
<br />
You can navigate to Home using
<Link href="/" className="mx-1 text-primary">
This
</Link>
link.
</p>
</section>
</div>
);
};
export default NotFoundPage;
export default function NotFoundPage() {
return <NotFoundContent />;
}
@@ -1,15 +1,13 @@
"use client";
import { GlobeIcon } from "@/assets/icons";
import { Select } from "@/components/FormElements/select";
import { Button } from "@/components/ui-elements/button";
import Status from "@/components/ui/Status";
import { Languages } from "@/constants/languages";
import { cn } from "@/lib/utils";
import type { ComponentProps } from "react";
import { useRef } from "react";
import type { FieldValues } from "react-hook-form";
import { TENDER_HEADER_TITLE_CLASS } from "../constants";
import { useStackTenderHeaderActions } from "../hooks/use-stack-tender-header-actions";
type LanguageSelectOnChange = NonNullable<
ComponentProps<typeof Select<FieldValues>>["onChange"]
@@ -19,7 +17,6 @@ type TenderDetailsHeaderProps = {
title: string;
selectedLanguage: string;
onLanguageChange: (event: React.ChangeEvent<HTMLSelectElement>) => void;
onTranslate: () => void;
isTranslating: boolean;
status: string | undefined;
};
@@ -28,94 +25,69 @@ export function TenderDetailsHeader({
title,
selectedLanguage,
onLanguageChange,
onTranslate,
isTranslating,
status,
}: TenderDetailsHeaderProps) {
const headerLayoutContainerRef = useRef<HTMLDivElement>(null);
const headerTitleProbeSizerRef = useRef<HTMLDivElement>(null);
const stackActionsBelow = useStackTenderHeaderActions(
title,
headerLayoutContainerRef,
headerTitleProbeSizerRef,
);
return (
<div ref={headerLayoutContainerRef} className="relative">
<div
className="pointer-events-none absolute inset-x-0 top-0 -z-10 max-h-0 overflow-hidden opacity-0"
aria-hidden
>
<div ref={headerTitleProbeSizerRef}>
<div className="relative w-full">
<div className="flex w-full min-w-0 flex-col gap-5">
<div className="w-full min-w-0 space-y-2">
<div className="flex flex-wrap items-center gap-x-3 gap-y-2">
<p className="text-[11px] font-semibold uppercase tracking-[0.18em] text-primary">
Tender
</p>
<Status status={status ?? "Unknown"}>{status}</Status>
</div>
<h1 className={TENDER_HEADER_TITLE_CLASS}>{title}</h1>
</div>
</div>
<div
className={cn(
"flex flex-col gap-5",
!stackActionsBelow &&
"lg:flex-row lg:items-start lg:justify-between lg:gap-8",
)}
>
<div
className={cn(
"min-w-0 space-y-2",
!stackActionsBelow && "lg:flex-1",
stackActionsBelow && "lg:w-full",
)}
>
<p className="text-[11px] font-semibold uppercase tracking-[0.18em] text-primary">
Tender
</p>
<h1 className={TENDER_HEADER_TITLE_CLASS}>{title}</h1>
</div>
<div
className={cn(
"flex w-full min-w-0 shrink-0 flex-col gap-3",
!stackActionsBelow && "lg:max-w-2xl lg:items-end",
stackActionsBelow && "lg:w-full lg:items-stretch",
)}
>
<p
className={cn(
"text-[11px] font-semibold uppercase tracking-[0.18em] text-dark-5 dark:text-dark-6 lg:w-full",
stackActionsBelow ? "lg:text-start" : "lg:text-end",
)}
>
Language & translation
</p>
<div className="flex w-full min-w-0 shrink-0 flex-col">
<div
className={cn(
"flex w-full flex-col gap-3 sm:flex-row sm:flex-wrap sm:items-center",
stackActionsBelow
? "sm:justify-start lg:justify-start"
: "sm:justify-end lg:justify-end",
"relative overflow-hidden rounded-[1.35rem] border border-stroke/55 bg-white/75 p-4 shadow-[0_12px_42px_-14px_rgba(15,23,42,0.14),0_0_0_1px_rgba(87,80,241,0.05)] backdrop-blur-md dark:border-white/[0.09] dark:bg-gradient-to-br dark:from-dark-2/95 dark:via-dark-3/55 dark:to-violet-600/[0.12] dark:shadow-[0_18px_50px_-18px_rgba(0,0,0,0.5),inset_0_1px_0_0_rgba(255,255,255,0.04)] sm:p-5",
)}
>
<Select<FieldValues>
name="lang"
label="Display language"
items={Languages}
className={cn(
"w-full shrink-0 space-y-0 sm:w-[min(100%,11.5rem)]",
"[&_label]:sr-only",
)}
value={selectedLanguage}
onChange={onLanguageChange as LanguageSelectOnChange}
<div
className="pointer-events-none absolute -right-6 -top-10 h-28 w-28 rounded-full bg-gradient-to-br from-primary/25 to-violet-500/10 blur-2xl dark:from-violet-500/30 dark:to-primary/5"
aria-hidden
/>
<Button
type="button"
size="small"
shape="full"
className="w-full shrink-0 whitespace-nowrap sm:min-h-[3.375rem] sm:w-auto"
label={
isTranslating ? "Translating..." : "Translate metadata"
}
disabled={isTranslating}
onClick={onTranslate}
/>
<div className="flex shrink-0 items-center sm:min-h-[3.375rem]">
<Status status={status ?? "Unknown"}>{status}</Status>
<div className="relative space-y-4">
<div className="flex gap-3">
<div className="flex h-10 w-10 shrink-0 items-center justify-center rounded-2xl bg-gradient-to-br from-primary/20 to-violet-600/10 text-primary shadow-inner ring-1 ring-primary/15 dark:from-primary/30 dark:to-violet-500/15 dark:ring-white/10">
<GlobeIcon className="h-5 w-5 opacity-95" />
</div>
<div className="min-w-0 flex-1 pt-0.5">
<p className="text-[11px] font-semibold uppercase tracking-[0.2em] text-dark-5 dark:text-dark-6">
Language & translation
</p>
<p className="mt-1 text-xs leading-relaxed text-dark-5/90 dark:text-dark-6/95">
Changing language updates the URL, translates metadata, and
reloads this view.
</p>
</div>
</div>
<div className="flex flex-col gap-2">
<Select<FieldValues>
name="lang"
label="Display language"
items={Languages}
disabled={isTranslating}
prefixIcon={
<GlobeIcon className="h-[1.05rem] w-[1.05rem] text-primary dark:text-primary/90" />
}
className={cn(
"w-full shrink-0 space-y-0",
"[&_label]:sr-only",
"[&_select]:h-11 [&_select]:min-h-[2.75rem] [&_select]:rounded-full [&_select]:border-stroke/60 [&_select]:bg-white/95 [&_select]:py-0 [&_select]:text-sm [&_select]:shadow-sm dark:[&_select]:border-white/10 dark:[&_select]:bg-dark-2/92",
)}
value={selectedLanguage}
onChange={onLanguageChange as LanguageSelectOnChange}
/>
{isTranslating ? (
<p className="text-xs font-medium text-primary dark:text-primary/90">
Translating
</p>
) : null}
</div>
</div>
</div>
</div>
+1 -4
View File
@@ -1,10 +1,7 @@
/** Matches the tender title `<h1>` in the header — keep in sync for layout probe */
/** Matches the tender title `<h1>` in the header */
export const TENDER_HEADER_TITLE_CLASS =
"text-balance text-xl font-bold leading-tight text-dark dark:text-white sm:text-2xl lg:text-[1.65rem]";
/** Reserved width for the actions column when deciding sidebyside vs stacked (lg+) */
export const TENDER_HEADER_ACTIONS_RESERVE_PX = 360;
export const TENDER_SECTION_IDS = {
procurement: "tender-section-procurement",
lots: "tender-section-lots",
@@ -1,58 +0,0 @@
import { useLayoutEffect, useState, type RefObject } from "react";
import { TENDER_HEADER_ACTIONS_RESERVE_PX } from "../constants";
export function useStackTenderHeaderActions(
title: string,
containerRef: RefObject<HTMLDivElement | null>,
probeSizerRef: RefObject<HTMLDivElement | null>,
) {
const [stackBelow, setStackBelow] = useState(false);
useLayoutEffect(() => {
const container = containerRef.current;
const probeSizer = probeSizerRef.current;
if (!container || !probeSizer) return;
const run = () => {
if (!window.matchMedia("(min-width: 1024px)").matches) {
setStackBelow(false);
return;
}
const styles = window.getComputedStyle(container);
const gapRaw = parseFloat(styles.columnGap || styles.gap);
const gap = Number.isFinite(gapRaw) ? gapRaw : 32;
const containerW = container.getBoundingClientRect().width;
const titleColumnW = Math.max(
160,
containerW - TENDER_HEADER_ACTIONS_RESERVE_PX - gap,
);
probeSizer.style.width = `${titleColumnW}px`;
const probeTitle = probeSizer.querySelector("h1");
if (!probeTitle) return;
const titleStyles = window.getComputedStyle(probeTitle);
const lineHeightRaw = parseFloat(titleStyles.lineHeight);
const fontSize = parseFloat(titleStyles.fontSize);
const lineHeight =
Number.isFinite(lineHeightRaw) && lineHeightRaw > 0
? lineHeightRaw
: fontSize * 1.25;
const lines = probeTitle.scrollHeight / lineHeight;
const next = lines > 1.35;
setStackBelow((p) => (p === next ? p : next));
};
run();
const ro = new ResizeObserver(run);
ro.observe(container);
window.addEventListener("resize", run);
return () => {
ro.disconnect();
window.removeEventListener("resize", run);
};
}, [title, containerRef, probeSizerRef]);
return stackBelow;
}
+12 -21
View File
@@ -31,7 +31,7 @@ const TenderDetails = ({ params }: IProps) => {
const selectedLanguage = searchParams.get("lang") || "en";
const { mutate: translateTender, isPending: isTranslating } =
useTranslateTenderMutation();
const { data, isLoading, isError, refetch } = useTenderDetailQuery(details, {
const { data, isLoading, isError } = useTenderDetailQuery(details, {
lang: selectedLanguage,
});
@@ -65,29 +65,21 @@ const TenderDetails = ({ params }: IProps) => {
const activeSectionId = useTenderSectionScrollSpy(sectionIds);
const handleLanguageChange = useCallback(
(event: React.ChangeEvent<HTMLSelectElement>) => {
const nextParams = new URLSearchParams(searchParams.toString());
const nextLanguage = event.target.value;
if (nextLanguage) {
nextParams.set("lang", nextLanguage);
} else {
nextParams.delete("lang");
}
if (!nextLanguage) return;
const prevLanguage = searchParams.get("lang") || "en";
if (nextLanguage === prevLanguage) return;
const nextParams = new URLSearchParams(searchParams.toString());
nextParams.set("lang", nextLanguage);
const queryString = nextParams.toString();
router.replace(queryString ? `${pathname}?${queryString}` : pathname);
},
[pathname, router, searchParams],
);
const handleTranslate = useCallback(() => {
translateTender(
{ id: details, language: selectedLanguage },
{
onSuccess: async () => {
await refetch();
},
},
);
}, [details, refetch, selectedLanguage, translateTender]);
translateTender({ id: details, language: nextLanguage });
},
[details, pathname, router, searchParams, translateTender],
);
if (isLoading) return <Loading />;
if (isError) {
@@ -106,7 +98,6 @@ const TenderDetails = ({ params }: IProps) => {
title={tender.title}
selectedLanguage={selectedLanguage}
onLanguageChange={handleLanguageChange}
onTranslate={handleTranslate}
isTranslating={isTranslating}
status={tender.status}
/>
File diff suppressed because one or more lines are too long
@@ -139,7 +139,7 @@ const DatePickerRHFInput = <T extends FieldValues>({
}
onlyYearPicker={onlyYearPicker}
multiple={multiple}
{...(portal ? { portal: true } : {})}
{...(portal ? { portal: true, zIndex: 99999 } : {})}
value={pickerValue}
onChange={(date: DateObject | DateObject[] | null) => {
if (!date) {
+5
View File
@@ -26,6 +26,7 @@ type PropsType<T extends FieldValues> = {
onClear?: () => void;
value?: string;
dataCy?: string;
disabled?: boolean;
} & (
| { placeholder?: string; defaultValue?: string }
| { placeholder: string; defaultValue?: string }
@@ -50,6 +51,7 @@ export function Select<T extends FieldValues>({
onClear,
value: controlledValue,
dataCy,
disabled = false,
...props
}: PropsType<T>) {
const id = useId();
@@ -100,6 +102,7 @@ export function Select<T extends FieldValues>({
id={id}
name={name}
ref={ref}
disabled={disabled}
value={value}
onChange={(e) => {
if (controlledValue === undefined) {
@@ -113,6 +116,8 @@ export function Select<T extends FieldValues>({
className={cn(
"w-full appearance-none rounded-xl border border-stroke/70 bg-white/90 px-5.5 py-3 shadow-[0_1px_2px_rgba(16,24,40,0.04),0_8px_20px_rgba(16,24,40,0.04)] outline-none backdrop-blur-[1px] transition-all duration-200 focus:border-primary/70 focus:bg-white focus:shadow-[0_0_0_2px_rgba(59,130,246,0.14),0_10px_24px_rgba(59,130,246,0.08)] active:border-primary/70 dark:border-dark-3/80 dark:bg-dark-2/85 dark:focus:border-primary/80 dark:focus:shadow-[0_0_0_2px_rgba(59,130,246,0.2),0_10px_24px_rgba(15,23,42,0.45)] [&>option]:text-dark-5 dark:[&>option]:text-dark-6",
isOptionSelected && "text-dark dark:text-white",
disabled &&
"cursor-not-allowed opacity-60 hover:border-stroke/70 dark:hover:border-dark-3/80",
error &&
"border-red/80 focus:border-red focus:shadow-[0_0_0_2px_rgba(239,68,68,0.16),0_10px_24px_rgba(239,68,68,0.1)] dark:border-red/80 dark:focus:shadow-[0_0_0_2px_rgba(239,68,68,0.24),0_10px_24px_rgba(127,29,29,0.45)]",
prefixIcon && "pl-11.5",
+101 -85
View File
@@ -1,7 +1,13 @@
"use client";
import { SearchIcon } from "@/assets/icons";
import InputGroup from "@/components/FormElements/InputGroup";
import { Select } from "@/components/FormElements/select";
import { Button } from "@/components/ui-elements/button";
import InlineListFiltersPanel from "@/components/ui/InlineListFiltersPanel";
import { AdminStatus } from "@/constants/enums";
import { getEnumAsArray } from "@/utils/shared";
import { useMemo } from "react";
import {
FieldValues,
UseFormHandleSubmit,
@@ -12,103 +18,113 @@ import {
interface AdminListFiltersProps {
filterRegister: UseFormRegister<FieldValues>;
setIsFilterModalOpen: (isOpen: boolean) => void;
isMutating: number;
handleFilterSubmit: UseFormHandleSubmit<FieldValues>;
search: (data: any) => void;
watch: UseFormWatch<FieldValues>;
setValue: UseFormSetValue<any>;
onClearAll: () => void;
}
const AdminListFilters = ({
function countActiveAdminFilters(values: Record<string, unknown>): number {
let n = 0;
const searchVal = values.search;
if (typeof searchVal === "string" && searchVal.trim()) n++;
const statusVal = values.status;
if (
statusVal !== undefined &&
statusVal !== null &&
statusVal !== "" &&
String(statusVal).trim()
) {
n++;
}
return n;
}
export default function AdminListFilters({
filterRegister,
setIsFilterModalOpen,
isMutating,
handleFilterSubmit,
search,
watch,
setValue,
}: AdminListFiltersProps) => {
return (
<form
className="grid !min-w-full grid-cols-1 gap-4 xl:grid-cols-2"
onSubmit={handleFilterSubmit(search)}
data-cy="admins-filter-form"
>
<Select
{...filterRegister("status")}
items={getEnumAsArray(AdminStatus)}
label="status"
name="status"
placeholder="Status"
clearable
value={watch("status")}
onClear={() => setValue("status", undefined)}
dataCy="admins-filter-status-select"
/>
<InputGroup
{...filterRegister("search")}
name="search"
label="search"
placeholder="search"
type="search"
dataCy="admins-filter-search-input"
/>
{/* <InputGroup
{...filterRegister("email")}
name="email"
label="email"
placeholder="email"
type="email"
/> */}
{/* <Select
{...filterRegister("role")}
name="role"
items={getEnumAsArray(AdminRoles)}
label="Role"
placeholder="Role"
clearable
value={watch("role")}
onClear={() => setValue("role", undefined)}
/> */}
{/* <InputGroup
{...filterRegister("username")}
label="username"
name="username"
placeholder="username"
type="text"
/> */}
{/* <InputGroup
{...filterRegister("full_name")}
label="full name"
name="full_name"
placeholder="full name"
type="text"
/> */}
<div className="flex gap-4">
<button
type="submit"
data-cy="admins-filter-submit-button"
className="mt-6 flex w-fit justify-center rounded-lg bg-primary p-[13px] font-medium capitalize text-white hover:bg-opacity-90"
>
{isMutating ? (
<div className="h-5 w-5 animate-spin rounded-full border-b-2 border-gray-1"></div>
) : (
<span>Search</span>
)}
</button>
<button
type="button"
data-cy="admins-filter-close-button"
className="mt-6 flex w-fit justify-center rounded-lg bg-error p-[13px] font-medium capitalize text-white hover:bg-opacity-90"
onClick={() => setIsFilterModalOpen(false)}
>
Close
</button>
</div>
</form>
onClearAll,
}: AdminListFiltersProps) {
const watched = watch();
const activeCount = useMemo(
() => countActiveAdminFilters(watched as Record<string, unknown>),
[watched],
);
};
export default AdminListFilters;
return (
<InlineListFiltersPanel
title="Filter admins"
activeFilterCount={activeCount}
expandAriaLabel="Expand admin filters"
collapseAriaLabel="Collapse admin filters"
panelToggleDataCy="admins-filter-panel-toggle"
>
<form
className="space-y-5 px-4 pb-6 pt-1 sm:px-6"
onSubmit={handleFilterSubmit(search)}
data-cy="admins-filter-form"
>
<div className="flex flex-col gap-3 lg:flex-row lg:items-end">
<div className="min-h-[52px] flex-1">
<InputGroup
{...filterRegister("search")}
name="search"
label="Keyword search"
type="search"
placeholder="Name, email, username…"
icon={
<SearchIcon className="text-primary dark:text-primary/90" />
}
iconPosition="left"
dataCy="admins-filter-search-input"
className="[&_input]:rounded-2xl [&_input]:border-stroke/70 [&_input]:bg-white/90 [&_input]:py-3.5 [&_input]:text-sm dark:[&_input]:border-dark-3 dark:[&_input]:bg-dark-2/80"
/>
</div>
<div className="flex shrink-0 flex-col gap-2 sm:flex-row sm:items-center">
<Button
type="submit"
size="small"
className="min-h-[52px] min-w-[140px] rounded-2xl bg-gradient-to-r from-primary via-primary to-violet-600 px-8 text-sm font-semibold transition hover:opacity-[0.97]"
data-cy="admins-filter-submit-button"
label={
isMutating ? (
<span className="inline-flex h-5 w-5 animate-spin rounded-full border-2 border-white/30 border-t-white" />
) : (
<span>Apply filters</span>
)
}
/>
<button
type="button"
data-cy="admins-filter-reset-button"
onClick={onClearAll}
className="min-h-[52px] rounded-2xl border border-stroke/70 bg-white/80 px-6 text-sm font-semibold text-dark transition hover:border-primary/40 hover:text-primary dark:border-dark-3 dark:bg-dark-3/60 dark:text-white dark:hover:bg-white/[0.06]"
>
Reset all
</button>
</div>
</div>
<div className="max-w-xl">
<Select<FieldValues>
{...filterRegister("status")}
items={getEnumAsArray(AdminStatus)}
label="Status"
name="status"
placeholder="Any status"
clearable
value={watch("status")}
onClear={() => setValue("status", undefined)}
dataCy="admins-filter-status-select"
/>
</div>
</form>
</InlineListFiltersPanel>
);
}
+123 -134
View File
@@ -20,7 +20,6 @@ import {
import TableSkeleton from "@/components/ui/TableSkeleton";
import { AdminStatus } from "@/constants/enums";
import { _TooltipDefaultParams } from "@/constants/tooltip";
import { getPaginatedRowNumber } from "@/utils/shared";
import { Tooltip } from "react-tooltip";
import AdminListFilters from "./AdminListFilters";
import ChangeStatusModalContent from "./ChangeStatusModalContent";
@@ -28,8 +27,7 @@ import { useAdminsPresenter } from "./useAdminsPresenter";
const AdminsTable = () => {
const {
allUsers,
router,
usersList,
isPending,
onConfirmDelete,
isModalOpen,
@@ -37,9 +35,6 @@ const AdminsTable = () => {
isChangeStatusModalOpen,
setIsChangeStatusModalOpen,
columns,
setCurrentUser,
isFilterModalOpen,
setIsFilterModalOpen,
changeStatus,
currentUser,
setStatus,
@@ -50,120 +45,136 @@ const AdminsTable = () => {
filterRegister,
isMutating,
watch,
metadata,
setParams,
shouldShowPagination,
clearAllFilters,
tableSectionRef,
skeletonTbodyRef,
dataTbodyRef,
getRowNumber,
pagination,
handlePaginationChange,
navigateToEditAdmin,
openDeleteModalForUser,
openChangeStatusModalForUser,
} = useAdminsPresenter();
return (
<ListWrapper data-cy="admins-page">
<ListHeader
onFilter={() => setIsFilterModalOpen(true)}
hasFilter={false}
createButtonText="Create Admin"
dataCyPrefix="admins"
/>
<Table data-cy="admins-table">
<TableHeader>
<TableRow className="border-none uppercase [&>th]:text-start">
{columns.map((column, index) => (
<TableHead
key={column}
colSpan={100}
data-cy={`admins-table-header-${index}`}
>
{column}
</TableHead>
))}
</TableRow>
</TableHeader>
{isPending && <TableSkeleton column={columns.length} />}
<TableBody>
<EmptyListWrapper
list={allUsers}
columns={columns}
emptyMessage="No admins were found"
isLoading={isPending}
>
{allUsers.map((item, index) => (
<TableRow
key={item?.id || index}
className="odd:bg-gray-2 dark:odd:bg-gray-7"
data-cy={`admins-table-row-${item?.id ?? index}`}
>
<TableCell className="text-start" colSpan={100} data-cy="admins-row-number">
{getPaginatedRowNumber({
index,
page: metadata?.page,
limit: metadata?.limit,
})}
</TableCell>
<TableCell className="text-start" colSpan={100} data-cy="admins-row-full-name">
{item?.full_name}
</TableCell>
<TableCell className="text-start" colSpan={100} data-cy="admins-row-email">
{item?.email}
</TableCell>
<AdminListFilters
setValue={setFilterValue}
filterRegister={filterRegister}
handleFilterSubmit={handleFilterSubmit}
isMutating={isMutating as number}
search={search}
watch={watch}
onClearAll={clearAllFilters}
/>
<div ref={tableSectionRef}>
<Table data-cy="admins-table">
<TableHeader>
<TableRow className="border-none uppercase [&>th]:text-start">
{columns.map((column, index) => (
<TableHead
key={column}
colSpan={100}
data-cy={`admins-table-header-${index}`}
>
{column}
</TableHead>
))}
</TableRow>
</TableHeader>
<TableCell className="text-start" colSpan={100} data-cy="admins-row-username">
{item?.username}
</TableCell>
<TableCell className="text-start" colSpan={100} data-cy="admins-row-status">
<Status status={item?.status ?? ""}>{item?.status}</Status>
</TableCell>
{isPending ? (
<TableSkeleton ref={skeletonTbodyRef} column={columns.length} />
) : (
<TableBody ref={dataTbodyRef}>
<EmptyListWrapper
list={usersList}
columns={columns}
emptyMessage="No admins were found"
isLoading={false}
>
{usersList.map((item, index) => (
<TableRow
key={item?.id || index}
className="odd:bg-gray-2 dark:odd:bg-gray-7"
data-cy={`admins-table-row-${item?.id ?? index}`}
>
<TableCell className="text-start" colSpan={100} data-cy="admins-row-number">
{getRowNumber(index)}
</TableCell>
<TableCell className="text-start" colSpan={100} data-cy="admins-row-full-name">
{item?.full_name}
</TableCell>
<TableCell className="text-start" colSpan={100} data-cy="admins-row-email">
{item?.email}
</TableCell>
<TableCell className="text-start xl:pr-7.5" data-cy="admins-row-actions">
<div className="flex items-center justify-start gap-x-3.5">
<button
data-tooltip-id="delete"
data-tooltip-content="Delete"
data-tooltip-place="top"
className="hover:text-red-500"
data-cy={`admins-row-delete-${item?.id ?? index}`}
onClick={() => {
setCurrentUser(item);
setIsModalOpen(true);
}}
>
<Tooltip {..._TooltipDefaultParams({ id: "delete" })} />
<span className="sr-only">Delete Admin </span>
<TrashIcon />
</button>
<button
data-tooltip-id="change-status"
data-tooltip-content="Change status"
data-tooltip-place="top"
className="hover:text-yellow-dark"
data-cy={`admins-row-manage-status-${item?.id ?? index}`}
onClick={() => {
setCurrentUser(item);
setIsChangeStatusModalOpen(true);
}}
>
<Tooltip
{..._TooltipDefaultParams({ id: "change-status" })}
/>
<span className="sr-only">Change Admin Status</span>
<UserSettingIcon />
</button>
<button
data-tooltip-id="edit"
data-tooltip-content="Edit"
data-tooltip-place="top"
className="hover:text-primary"
data-cy={`admins-row-edit-${item?.id ?? index}`}
onClick={() => router.push(`/admins/edit/${item.id}`)}
>
<Tooltip {..._TooltipDefaultParams({ id: "edit" })} />
<span className="sr-only">Edit Admin </span>
<PencilSquareIcon />
</button>
</div>
</TableCell>
</TableRow>
))}
</EmptyListWrapper>
</TableBody>
</Table>
<TableCell className="text-start" colSpan={100} data-cy="admins-row-username">
{item?.username}
</TableCell>
<TableCell className="text-start" colSpan={100} data-cy="admins-row-status">
<Status status={item?.status ?? ""}>{item?.status}</Status>
</TableCell>
<TableCell className="text-start xl:pr-7.5" data-cy="admins-row-actions">
<div className="flex items-center justify-start gap-x-3.5">
<button
type="button"
data-tooltip-id="delete"
data-tooltip-content="Delete"
data-tooltip-place="top"
className="hover:text-red-500"
data-cy={`admins-row-delete-${item?.id ?? index}`}
onClick={() => openDeleteModalForUser(item)}
>
<Tooltip {..._TooltipDefaultParams({ id: "delete" })} />
<span className="sr-only">Delete Admin </span>
<TrashIcon />
</button>
<button
type="button"
data-tooltip-id="change-status"
data-tooltip-content="Change status"
data-tooltip-place="top"
className="hover:text-yellow-dark"
data-cy={`admins-row-manage-status-${item?.id ?? index}`}
onClick={() => openChangeStatusModalForUser(item)}
>
<Tooltip
{..._TooltipDefaultParams({ id: "change-status" })}
/>
<span className="sr-only">Change Admin Status</span>
<UserSettingIcon />
</button>
<button
type="button"
data-tooltip-id="edit"
data-tooltip-content="Edit"
data-tooltip-place="top"
className="hover:text-primary"
data-cy={`admins-row-edit-${item?.id ?? index}`}
onClick={() => navigateToEditAdmin(item.id)}
>
<Tooltip {..._TooltipDefaultParams({ id: "edit" })} />
<span className="sr-only">Edit Admin </span>
<PencilSquareIcon />
</button>
</div>
</TableCell>
</TableRow>
))}
</EmptyListWrapper>
</TableBody>
)}
</Table>
</div>
<Modal
isOpen={isChangeStatusModalOpen}
onClose={() => setIsChangeStatusModalOpen(false)}
@@ -179,22 +190,6 @@ const AdminsTable = () => {
/>
</Modal>
<Modal
isOpen={isFilterModalOpen}
onClose={() => setIsFilterModalOpen(false)}
classNames="w-full xl:w-1/2"
showButtons={false}
>
<AdminListFilters
setValue={setFilterValue}
filterRegister={filterRegister}
handleFilterSubmit={handleFilterSubmit}
isMutating={isMutating as number}
search={search}
setIsFilterModalOpen={setIsFilterModalOpen}
watch={watch}
/>
</Modal>
{isModalOpen && (
<ConfirmationModal
isOpen={isModalOpen}
@@ -212,15 +207,9 @@ const AdminsTable = () => {
)}
<IsVisible condition={shouldShowPagination}>
<Pagination
currentPage={metadata?.page ? metadata.page - 1 : 0}
totalPages={metadata?.pages ?? 1}
onPageChange={(e) => {
setParams((currentParams) => ({
...currentParams,
offset: e.selected * (metadata?.limit ?? 10),
limit: metadata?.limit ?? 10,
}));
}}
currentPage={pagination.currentPage}
totalPages={pagination.totalPages}
onPageChange={handlePaginationChange}
/>
</IsVisible>
</ListWrapper>
@@ -8,15 +8,22 @@ import {
useGetAdminListQuery,
} from "@/hooks/queries";
import { IUser, TChangeAdminStatusCredentials } from "@/lib/api";
import { deleteEmptyKeys } from "@/utils/shared";
import { deleteEmptyKeys, getPaginatedRowNumber } from "@/utils/shared";
import gsap from "gsap";
import { useIsMutating } from "@tanstack/react-query";
import { usePathname, useRouter, useSearchParams } from "next/navigation";
import { useEffect, useState } from "react";
import {
useCallback,
useEffect,
useLayoutEffect,
useMemo,
useRef,
useState,
} from "react";
import { useForm } from "react-hook-form";
export const useAdminsPresenter = () => {
const [isChangeStatusModalOpen, setIsChangeStatusModalOpen] = useState(false);
const [isFilterModalOpen, setIsFilterModalOpen] = useState(false);
const [currentUser, setCurrentUser] = useState<IUser | null>(null);
const [isModalOpen, setIsModalOpen] = useState(false);
const [status, setStatus] = useState<string>(
@@ -46,8 +53,12 @@ export const useAdminsPresenter = () => {
const [params, setParams] = useState<Record<string, any>>({});
const pathName = usePathname();
const tableSectionRef = useRef<HTMLDivElement>(null);
const skeletonTbodyRef = useRef<HTMLTableSectionElement>(null);
const dataTbodyRef = useRef<HTMLTableSectionElement>(null);
useEffect(() => {
const newParams: Record<string, any> = {
const newParams: Record<string, unknown> = {
...apiDefaultParams,
offset: 0,
limit: 10,
@@ -58,12 +69,112 @@ export const useAdminsPresenter = () => {
setParams(newParams);
filterFormReset(newParams);
}, [searchParams, filterFormReset]);
const { data, isPending } = useGetAdminListQuery({
...apiDefaultParams,
...params,
});
const shouldShowPagination =
!isPending && (data?.meta?.pages ?? 1) * (data?.meta?.limit ?? 10) > 10;
const usersList = data?.data?.users ?? [];
const metadata = data?.meta;
const pagination = useMemo(
() => ({
currentPage: data?.meta?.page ? data.meta.page - 1 : 0,
totalPages: data?.meta?.pages ?? 1,
}),
[data?.meta?.page, data?.meta?.pages],
);
const getRowNumber = useCallback(
(index: number) =>
getPaginatedRowNumber({
index,
page: data?.meta?.page,
limit: data?.meta?.limit,
}),
[data?.meta?.limit, data?.meta?.page],
);
const handlePaginationChange = useCallback(
(e: { selected: number }) => {
const limit = data?.meta?.limit ?? 10;
setParams((currentParams) => ({
...currentParams,
offset: e.selected * limit,
limit,
}));
},
[data?.meta?.limit, setParams],
);
const navigateToEditAdmin = useCallback(
(userId: string) => {
router.push(`/admins/edit/${userId}`);
},
[router],
);
const openDeleteModalForUser = useCallback((user: IUser) => {
setCurrentUser(user);
setIsModalOpen(true);
}, []);
const openChangeStatusModalForUser = useCallback((user: IUser) => {
setCurrentUser(user);
setIsChangeStatusModalOpen(true);
}, []);
useLayoutEffect(() => {
const reduced =
typeof window !== "undefined" &&
window.matchMedia("(prefers-reduced-motion: reduce)").matches;
const ctx = gsap.context(() => {
if (reduced) return;
if (isPending) {
const tbody = skeletonTbodyRef.current;
if (!tbody) return;
const rows = tbody.querySelectorAll("tr");
gsap.fromTo(
rows,
{ opacity: 0.35, y: 14 },
{
opacity: 1,
y: 0,
duration: 0.42,
stagger: 0.032,
ease: "power2.out",
},
);
return;
}
const tbody = dataTbodyRef.current;
if (!tbody) return;
const rows = tbody.querySelectorAll("tr");
if (!rows.length) return;
gsap.fromTo(
rows,
{ opacity: 0, y: 18 },
{
opacity: 1,
y: 0,
duration: 0.46,
stagger: 0.05,
ease: "power3.out",
clearProps: "opacity,transform",
},
);
}, tableSectionRef);
return () => ctx.revert();
}, [isPending, data]);
const { mutate: deleteAdmin } = useDeleteAdmin(() => {
setIsModalOpen(false);
});
@@ -73,6 +184,7 @@ export const useAdminsPresenter = () => {
deleteAdmin(currentUser.id);
}
};
const { mutate: changeStatus } = useChangeAdminStatus({
id: currentUser?.id ?? "",
successCallback: () => {
@@ -80,15 +192,17 @@ export const useAdminsPresenter = () => {
},
});
const search = (data: any) => {
const newParams = { ...params, ...data, offset: 0 };
const search = (formData: any) => {
const newParams = { ...params, ...formData, offset: 0 };
const cleanedParams = deleteEmptyKeys(newParams);
const queryString = new URLSearchParams(cleanedParams).toString();
router.push(`${pathName}?${queryString}`);
setIsFilterModalOpen(false);
};
const allUsers = data?.data?.users ?? [];
const metadata = data?.meta;
const clearAllFilters = useCallback(() => {
router.push(pathName);
}, [pathName, router]);
const columns = [
"row",
"full name",
@@ -97,8 +211,10 @@ export const useAdminsPresenter = () => {
"status",
"actions",
];
return {
allUsers,
allUsers: usersList,
usersList,
setFilterValue,
metadata,
columns,
@@ -107,7 +223,6 @@ export const useAdminsPresenter = () => {
isModalOpen,
setIsModalOpen,
isMutating,
router,
setCurrentUser,
pathName,
status,
@@ -119,13 +234,21 @@ export const useAdminsPresenter = () => {
register,
errors,
handleSubmit,
isFilterModalOpen,
setIsFilterModalOpen,
setParams,
filterRegister,
handleFilterSubmit,
search,
watch,
shouldShowPagination,
clearAllFilters,
tableSectionRef,
skeletonTbodyRef,
dataTbodyRef,
getRowNumber,
pagination,
handlePaginationChange,
navigateToEditAdmin,
openDeleteModalForUser,
openChangeStatusModalForUser,
};
};
+79 -41
View File
@@ -1,4 +1,8 @@
"use client";
import InputGroup from "@/components/FormElements/InputGroup";
import { Button } from "@/components/ui-elements/button";
import InlineListFiltersPanel from "@/components/ui/InlineListFiltersPanel";
import {
FieldValues,
UseFormHandleSubmit,
@@ -6,64 +10,98 @@ import {
UseFormSetValue,
UseFormWatch,
} from "react-hook-form";
import { useMemo } from "react";
interface CmsListFiltersProps {
filterRegister: UseFormRegister<FieldValues>;
setIsFilterModalOpen: (isOpen: boolean) => void;
isMutating: number;
handleFilterSubmit: UseFormHandleSubmit<FieldValues>;
search: (data: any) => void;
watch: UseFormWatch<FieldValues>;
setValue: UseFormSetValue<any>;
onClearAll: () => void;
}
function countActive(values: Record<string, unknown>): number {
let n = 0;
const q = values.q;
if (typeof q === "string" && q.trim()) n++;
const key = values.key;
if (typeof key === "string" && key.trim()) n++;
return n;
}
const CmsListFilters = ({
filterRegister,
setIsFilterModalOpen,
isMutating,
handleFilterSubmit,
search,
watch,
onClearAll,
}: CmsListFiltersProps) => {
return (
<form
className="grid !min-w-full grid-cols-1 gap-4 xl:grid-cols-2"
onSubmit={handleFilterSubmit(search)}
>
<InputGroup
{...filterRegister("q")}
name="q"
label="search"
placeholder="Search"
type="search"
/>
<InputGroup
{...filterRegister("key")}
name="key"
label="key"
placeholder="Key"
type="text"
/>
const watched = watch();
const activeCount = useMemo(
() => countActive(watched as Record<string, unknown>),
[watched],
);
<div className="flex gap-4">
<button
type="submit"
className="mt-6 flex w-fit justify-center rounded-lg bg-primary p-[13px] font-medium capitalize text-white hover:bg-opacity-90"
>
{isMutating ? (
<div className="h-5 w-5 animate-spin rounded-full border-b-2 border-gray-1"></div>
) : (
<span>Search</span>
)}
</button>
<button
type="button"
className="mt-6 flex w-fit justify-center rounded-lg bg-error p-[13px] font-medium capitalize text-white hover:bg-opacity-90"
onClick={() => setIsFilterModalOpen(false)}
>
Close
</button>
</div>
</form>
return (
<InlineListFiltersPanel
title="Filter CMS entries"
description="Search by keyword or exact key — inline, no popup."
activeFilterCount={activeCount}
expandAriaLabel="Expand CMS filters"
collapseAriaLabel="Collapse CMS filters"
panelToggleDataCy="cms-filter-panel-toggle"
>
<form
className="space-y-5 px-4 pb-6 pt-1 sm:px-6"
onSubmit={handleFilterSubmit(search)}
data-cy="cms-filter-form"
>
<div className="grid gap-4 sm:grid-cols-2">
<InputGroup
{...filterRegister("q")}
name="q"
label="Keyword search"
placeholder="Search"
type="search"
dataCy="cms-filter-q-input"
/>
<InputGroup
{...filterRegister("key")}
name="key"
label="Key"
placeholder="Key"
type="text"
dataCy="cms-filter-key-input"
/>
</div>
<div className="flex flex-col gap-2 sm:flex-row sm:items-center">
<Button
type="submit"
size="small"
className="min-h-[52px] min-w-[140px] rounded-2xl bg-gradient-to-r from-primary via-primary to-violet-600 px-8 text-sm font-semibold transition hover:opacity-[0.97]"
data-cy="cms-filter-submit-button"
label={
isMutating ? (
<span className="inline-flex h-5 w-5 animate-spin rounded-full border-2 border-white/30 border-t-white" />
) : (
<span>Apply filters</span>
)
}
/>
<button
type="button"
data-cy="cms-filter-reset-button"
onClick={onClearAll}
className="min-h-[52px] rounded-2xl border border-stroke/70 bg-white/80 px-6 text-sm font-semibold text-dark transition hover:border-primary/40 hover:text-primary dark:border-dark-3 dark:bg-dark-3/60 dark:text-white dark:hover:bg-white/[0.06]"
>
Reset all
</button>
</div>
</form>
</InlineListFiltersPanel>
);
};
+91 -89
View File
@@ -3,7 +3,7 @@ import { PencilSquareIcon, TrashIcon } from "@/assets/icons";
import EmptyListWrapper from "@/components/HOC/EmptyListWrapper";
import ConfirmationModal from "@/components/ui/ConfirmationModal";
import ListHeader from "@/components/ui/ListHeader";
import Modal from "@/components/ui/modal";
import ListWrapper from "@/components/ui/ListWrapper";
import {
Table,
TableBody,
@@ -18,7 +18,6 @@ import { getPaginatedRowNumber, unixToDate } from "@/utils/shared";
import { Tooltip } from "react-tooltip";
import CmsListFilters from "./CmsListFilters";
import useCmsTablePresenter from "./useCmsTablePresenter";
import ListWrapper from "@/components/ui/ListWrapper";
const CmsTable = () => {
const {
@@ -32,103 +31,106 @@ const CmsTable = () => {
onConfirmDelete,
router,
pathname,
isFilterModalOpen,
setIsFilterModalOpen,
filterRegister,
handleFilterSubmit,
search,
watch,
setFilterValue,
isMutating,
clearAllFilters,
tableSectionRef,
skeletonTbodyRef,
dataTbodyRef,
} = useCmsTablePresenter();
return (
<ListWrapper>
<ListHeader onFilter={() => setIsFilterModalOpen(true)} />
<Table>
<TableHeader>
<TableRow className="border-none uppercase [&>th]:text-start">
{columns.map((column) => (
<TableHead key={column} colSpan={100}>
{column}
</TableHead>
))}
</TableRow>
</TableHeader>
{isPending && <TableSkeleton column={columns.length} />}
<TableBody>
<EmptyListWrapper
list={data?.data.cms ?? []}
columns={columns}
emptyMessage="No Cms were found"
>
{data?.data.cms.map((item, index) => (
<TableRow
key={item?.id || index}
className="odd:bg-gray-2 dark:odd:bg-gray-7"
<ListHeader hasFilter={false} />
<CmsListFilters
setValue={setFilterValue}
filterRegister={filterRegister}
handleFilterSubmit={handleFilterSubmit}
isMutating={isMutating as number}
search={search}
watch={watch}
onClearAll={clearAllFilters}
/>
<div ref={tableSectionRef}>
<Table>
<TableHeader>
<TableRow className="border-none uppercase [&>th]:text-start">
{columns.map((column) => (
<TableHead key={column} colSpan={100}>
{column}
</TableHead>
))}
</TableRow>
</TableHeader>
{isPending ? (
<TableSkeleton ref={skeletonTbodyRef} column={columns.length} />
) : (
<TableBody ref={dataTbodyRef}>
<EmptyListWrapper
list={data?.data.cms ?? []}
columns={columns}
emptyMessage="No Cms were found"
isLoading={false}
>
<TableCell className="text-start" colSpan={100}>
{getPaginatedRowNumber({
index,
page: data?.meta?.page,
limit: data?.meta?.limit,
})}
</TableCell>
<TableCell className="text-start" colSpan={100}>
{item.key}
</TableCell>
<TableCell className="text-start" colSpan={100}>
{unixToDate({ unix: item.created_at, hasTime: true })}
</TableCell>
<TableCell className="text-start xl:pr-7.5">
<div className="flex items-center justify-start gap-x-3.5">
<button
data-tooltip-id="delete"
data-tooltip-content="Delete"
data-tooltip-place="top"
className="hover:text-red-500"
onClick={() => {
setCurrentCms(item);
setIsModalOpen(true);
}}
>
<Tooltip {..._TooltipDefaultParams({ id: "delete" })} />
<span className="sr-only">Delete Marketing </span>
<TrashIcon />
</button>
<button
data-tooltip-id="edit"
data-tooltip-content="Edit"
data-tooltip-place="top"
className="hover:text-primary"
onClick={() => router.push(`${pathname}/edit/${item.id}`)}
>
<Tooltip {..._TooltipDefaultParams({ id: "edit" })} />
<span className="sr-only">Edit Marketing </span>
<PencilSquareIcon />
</button>
</div>
</TableCell>
</TableRow>
))}
</EmptyListWrapper>
</TableBody>
</Table>
<Modal
isOpen={isFilterModalOpen}
onClose={() => setIsFilterModalOpen(false)}
classNames="w-full xl:w-1/2"
showButtons={false}
>
<CmsListFilters
setValue={setFilterValue}
filterRegister={filterRegister}
handleFilterSubmit={handleFilterSubmit}
isMutating={isMutating as number}
search={search}
setIsFilterModalOpen={setIsFilterModalOpen}
watch={watch}
/>
</Modal>
{data?.data.cms.map((item, index) => (
<TableRow
key={item?.id || index}
className="odd:bg-gray-2 dark:odd:bg-gray-7"
>
<TableCell className="text-start" colSpan={100}>
{getPaginatedRowNumber({
index,
page: data?.meta?.page,
limit: data?.meta?.limit,
})}
</TableCell>
<TableCell className="text-start" colSpan={100}>
{item.key}
</TableCell>
<TableCell className="text-start" colSpan={100}>
{unixToDate({ unix: item.created_at, hasTime: true })}
</TableCell>
<TableCell className="text-start xl:pr-7.5">
<div className="flex items-center justify-start gap-x-3.5">
<button
data-tooltip-id="delete"
data-tooltip-content="Delete"
data-tooltip-place="top"
className="hover:text-red-500"
onClick={() => {
setCurrentCms(item);
setIsModalOpen(true);
}}
>
<Tooltip {..._TooltipDefaultParams({ id: "delete" })} />
<span className="sr-only">Delete Marketing </span>
<TrashIcon />
</button>
<button
data-tooltip-id="edit"
data-tooltip-content="Edit"
data-tooltip-place="top"
className="hover:text-primary"
onClick={() =>
router.push(`${pathname}/edit/${item.id}`)
}
>
<Tooltip {..._TooltipDefaultParams({ id: "edit" })} />
<span className="sr-only">Edit Marketing </span>
<PencilSquareIcon />
</button>
</div>
</TableCell>
</TableRow>
))}
</EmptyListWrapper>
</TableBody>
)}
</Table>
</div>
{isModalOpen && (
<ConfirmationModal
isOpen={isModalOpen}
@@ -1,15 +1,15 @@
"use client";
import { useTableSectionRowAnimations } from "@/hooks/useTableSectionRowAnimations";
import { useDeleteCms, useGetCmsList } from "@/hooks/queries/useCmsQueries";
import { TCms } from "@/lib/api/types/TCms";
import { deleteEmptyKeys } from "@/utils/shared";
import { useIsMutating } from "@tanstack/react-query";
import { usePathname, useRouter, useSearchParams } from "next/navigation";
import { useEffect, useState } from "react";
import { useCallback, useEffect, useRef, useState } from "react";
import { useForm } from "react-hook-form";
const useCmsTablePresenter = () => {
const [isModalOpen, setIsModalOpen] = useState(false);
const [isFilterModalOpen, setIsFilterModalOpen] = useState(false);
const [currentCms, setCurrentCms] = useState<TCms | null>(null);
const [params, setParams] = useState<Record<string, any>>({});
const columns = ["row", "key", "created at", "actions"];
@@ -26,6 +26,10 @@ const useCmsTablePresenter = () => {
setValue: setFilterValue,
} = useForm();
const tableSectionRef = useRef<HTMLDivElement>(null);
const skeletonTbodyRef = useRef<HTMLTableSectionElement>(null);
const dataTbodyRef = useRef<HTMLTableSectionElement>(null);
useEffect(() => {
const newParams: Record<string, any> = {};
for (const [key, value] of searchParams.entries()) {
@@ -37,19 +41,28 @@ const useCmsTablePresenter = () => {
const { isPending, data } = useGetCmsList(params);
useTableSectionRowAnimations(isPending, data, {
tableSectionRef,
skeletonTbodyRef,
dataTbodyRef,
});
const { mutate: deleteCms } = useDeleteCms(() => {
setIsModalOpen(false);
setCurrentCms(null);
});
const search = (data: any) => {
const newParams = { ...params, ...data };
const search = (formData: any) => {
const newParams = { ...params, ...formData };
const cleanedParams = deleteEmptyKeys(newParams);
const queryString = new URLSearchParams(cleanedParams).toString();
router.push(`${pathname}?${queryString}`);
setIsFilterModalOpen(false);
};
const clearAllFilters = useCallback(() => {
router.push(pathname);
}, [pathname, router]);
const onConfirmDelete = () => {
if (!currentCms?.id) return;
deleteCms(currentCms.id);
@@ -65,14 +78,16 @@ const useCmsTablePresenter = () => {
onConfirmDelete,
router,
pathname,
isFilterModalOpen,
setIsFilterModalOpen,
filterRegister,
handleFilterSubmit,
search,
watch,
setFilterValue,
isMutating,
clearAllFilters,
tableSectionRef,
skeletonTbodyRef,
dataTbodyRef,
};
};
@@ -1,5 +1,9 @@
"use client";
import InputGroup from "@/components/FormElements/InputGroup";
import { Select } from "@/components/FormElements/select";
import { Button } from "@/components/ui-elements/button";
import InlineListFiltersPanel from "@/components/ui/InlineListFiltersPanel";
import { Currencies } from "@/constants/enums";
import {
FieldValues,
@@ -8,90 +12,130 @@ import {
UseFormSetValue,
UseFormWatch,
} from "react-hook-form";
import { useMemo } from "react";
interface CompanyListFiltersProps {
filterRegister: UseFormRegister<FieldValues>;
setIsFilterModalOpen: (isOpen: boolean) => void;
isMutating: number;
handleFilterSubmit: UseFormHandleSubmit<FieldValues>;
search: (data: any) => void;
watch: UseFormWatch<FieldValues>;
setValue: UseFormSetValue<any>;
onClearAll: () => void;
}
function countActive(values: Record<string, unknown>): number {
let n = 0;
const keys = ["name", "email", "phone"] as const;
for (const k of keys) {
const v = values[k];
if (typeof v === "string" && v.trim()) n++;
}
const cur = values.currency;
if (cur !== undefined && cur !== null && cur !== "" && String(cur).trim()) {
n++;
}
const ec = values.employee_count;
if (ec !== undefined && ec !== null && ec !== "" && String(ec).trim()) {
n++;
}
return n;
}
const CompanyListFilters = ({
filterRegister,
setIsFilterModalOpen,
isMutating,
handleFilterSubmit,
search,
watch,
setValue,
onClearAll,
}: CompanyListFiltersProps) => {
return (
<form
className="grid !min-w-full grid-cols-1 gap-4 xl:grid-cols-2"
onSubmit={handleFilterSubmit(search)}
>
<InputGroup
{...filterRegister("name")}
name="name"
label="name"
placeholder="Name"
type="text"
/>
<InputGroup
{...filterRegister("email")}
name="email"
label="email"
placeholder="Email"
type="email"
/>
<InputGroup
{...filterRegister("phone")}
name="phone"
label="phone"
placeholder="Phone"
type="tel"
/>
<Select
{...filterRegister("currency")}
items={Currencies}
label="currency"
name="currency"
placeholder="Currency"
clearable
value={watch("currency")}
onClear={() => setValue("currency", undefined)}
/>
<InputGroup
{...filterRegister("employee_count")}
name="employee_count"
label="employee count"
placeholder="Employee Count"
type="number"
/>
const watched = watch();
const activeCount = useMemo(
() => countActive(watched as Record<string, unknown>),
[watched],
);
<div className="flex gap-4 col-span-2">
<button
type="submit"
className="mt-6 flex w-fit justify-center rounded-lg bg-primary p-[13px] font-medium capitalize text-white hover:bg-opacity-90"
>
{isMutating ? (
<div className="h-5 w-5 animate-spin rounded-full border-b-2 border-gray-1"></div>
) : (
<span>Search</span>
)}
</button>
<button
type="button"
className="mt-6 flex w-fit justify-center rounded-lg bg-error p-[13px] font-medium capitalize text-white hover:bg-opacity-90"
onClick={() => setIsFilterModalOpen(false)}
>
Close
</button>
</div>
</form>
return (
<InlineListFiltersPanel
title="Filter companies"
description="Filter by identity, currency, or headcount — inline, no popup."
activeFilterCount={activeCount}
expandAriaLabel="Expand company filters"
collapseAriaLabel="Collapse company filters"
panelToggleDataCy="companies-filter-panel-toggle"
>
<form
className="space-y-5 px-4 pb-6 pt-1 sm:px-6"
onSubmit={handleFilterSubmit(search)}
data-cy="companies-filter-form"
>
<div className="grid gap-4 sm:grid-cols-2 xl:grid-cols-3">
<InputGroup
{...filterRegister("name")}
name="name"
label="Name"
placeholder="Name"
type="text"
/>
<InputGroup
{...filterRegister("email")}
name="email"
label="Email"
placeholder="Email"
type="email"
/>
<InputGroup
{...filterRegister("phone")}
name="phone"
label="Phone"
placeholder="Phone"
type="tel"
/>
<Select
{...filterRegister("currency")}
items={Currencies}
label="Currency"
name="currency"
placeholder="Currency"
clearable
value={watch("currency")}
onClear={() => setValue("currency", undefined)}
/>
<InputGroup
{...filterRegister("employee_count")}
name="employee_count"
label="Employee count"
placeholder="Employee count"
type="number"
/>
</div>
<div className="flex flex-col gap-2 sm:flex-row sm:items-center">
<Button
type="submit"
size="small"
className="min-h-[52px] min-w-[140px] rounded-2xl bg-gradient-to-r from-primary via-primary to-violet-600 px-8 text-sm font-semibold transition hover:opacity-[0.97]"
data-cy="companies-filter-submit-button"
label={
isMutating ? (
<span className="inline-flex h-5 w-5 animate-spin rounded-full border-2 border-white/30 border-t-white" />
) : (
<span>Apply filters</span>
)
}
/>
<button
type="button"
data-cy="companies-filter-reset-button"
onClick={onClearAll}
className="min-h-[52px] rounded-2xl border border-stroke/70 bg-white/80 px-6 text-sm font-semibold text-dark transition hover:border-primary/40 hover:text-primary dark:border-dark-3 dark:bg-dark-3/60 dark:text-white dark:hover:bg-white/[0.06]"
>
Reset all
</button>
</div>
</form>
</InlineListFiltersPanel>
);
};
@@ -1,5 +1,9 @@
"use client";
import InputGroup from "@/components/FormElements/InputGroup";
import { Select } from "@/components/FormElements/select";
import { Button } from "@/components/ui-elements/button";
import InlineListFiltersPanel from "@/components/ui/InlineListFiltersPanel";
import {
FieldValues,
UseFormHandleSubmit,
@@ -7,77 +11,107 @@ import {
UseFormSetValue,
UseFormWatch,
} from "react-hook-form";
import { useMemo } from "react";
interface CompanyCategoryListFiltersProps {
filterRegister: UseFormRegister<FieldValues>;
setIsFilterModalOpen: (isOpen: boolean) => void;
isMutating: number;
handleFilterSubmit: UseFormHandleSubmit<FieldValues>;
search: (data: any) => void;
watch: UseFormWatch<FieldValues>;
setValue: UseFormSetValue<any>;
onClearAll: () => void;
}
function countActive(values: Record<string, unknown>): number {
let n = 0;
const q = values.q;
if (typeof q === "string" && q.trim()) n++;
const pub = values.published;
if (pub !== undefined && pub !== null && pub !== "" && String(pub).trim()) {
n++;
}
return n;
}
const CompanyCategoryListFilters = ({
filterRegister,
setIsFilterModalOpen,
isMutating,
handleFilterSubmit,
search,
watch,
setValue,
onClearAll,
}: CompanyCategoryListFiltersProps) => {
return (
<form
className="grid !min-w-full grid-cols-1 gap-4 xl:grid-cols-2"
data-cy="company-categories-filter-form"
onSubmit={handleFilterSubmit(search)}
>
<InputGroup
{...filterRegister("q")}
name="q"
label="search"
placeholder="Search"
type="search"
dataCy="company-categories-filter-search-input"
/>
<Select
{...filterRegister("published")}
items={[
{ label: "Published", value: "true" },
{ label: "Not Published", value: "false" },
]}
label="published"
name="published"
placeholder="Published Status"
clearable
value={watch("published")}
onClear={() => setValue("published", undefined)}
dataCy="company-categories-filter-published-select"
/>
const watched = watch();
const activeCount = useMemo(
() => countActive(watched as Record<string, unknown>),
[watched],
);
<div className="flex gap-4">
<button
type="submit"
data-cy="company-categories-filter-submit-button"
className="mt-6 flex w-fit justify-center rounded-lg bg-primary p-[13px] font-medium capitalize text-white hover:bg-opacity-90"
>
{isMutating ? (
<div className="h-5 w-5 animate-spin rounded-full border-b-2 border-gray-1"></div>
) : (
<span>Search</span>
)}
</button>
<button
type="button"
data-cy="company-categories-filter-close-button"
className="mt-6 flex w-fit justify-center rounded-lg bg-error p-[13px] font-medium capitalize text-white hover:bg-opacity-90"
onClick={() => setIsFilterModalOpen(false)}
>
Close
</button>
</div>
</form>
return (
<InlineListFiltersPanel
title="Filter categories"
description="Keyword search and published state — inline, no popup."
activeFilterCount={activeCount}
expandAriaLabel="Expand category filters"
collapseAriaLabel="Collapse category filters"
panelToggleDataCy="company-categories-filter-panel-toggle"
>
<form
className="space-y-5 px-4 pb-6 pt-1 sm:px-6"
data-cy="company-categories-filter-form"
onSubmit={handleFilterSubmit(search)}
>
<div className="grid gap-4 sm:grid-cols-2">
<InputGroup
{...filterRegister("q")}
name="q"
label="Keyword search"
placeholder="Search"
type="search"
dataCy="company-categories-filter-search-input"
/>
<Select
{...filterRegister("published")}
items={[
{ label: "Published", value: "true" },
{ label: "Not Published", value: "false" },
]}
label="Published"
name="published"
placeholder="Published status"
clearable
value={watch("published")}
onClear={() => setValue("published", undefined)}
dataCy="company-categories-filter-published-select"
/>
</div>
<div className="flex flex-col gap-2 sm:flex-row sm:items-center">
<Button
type="submit"
size="small"
className="min-h-[52px] min-w-[140px] rounded-2xl bg-gradient-to-r from-primary via-primary to-violet-600 px-8 text-sm font-semibold transition hover:opacity-[0.97]"
data-cy="company-categories-filter-submit-button"
label={
isMutating ? (
<span className="inline-flex h-5 w-5 animate-spin rounded-full border-2 border-white/30 border-t-white" />
) : (
<span>Apply filters</span>
)
}
/>
<button
type="button"
data-cy="company-categories-filter-reset-button"
onClick={onClearAll}
className="min-h-[52px] rounded-2xl border border-stroke/70 bg-white/80 px-6 text-sm font-semibold text-dark transition hover:border-primary/40 hover:text-primary dark:border-dark-3 dark:bg-dark-3/60 dark:text-white dark:hover:bg-white/[0.06]"
>
Reset all
</button>
</div>
</form>
</InlineListFiltersPanel>
);
};
@@ -6,7 +6,7 @@ import ConfirmationModal from "@/components/ui/ConfirmationModal";
import IsVisible from "@/components/ui/IsVisible";
import ListHeader from "@/components/ui/ListHeader";
import ListWrapper from "@/components/ui/ListWrapper";
import Modal from "@/components/ui/modal";
import Pagination from "@/components/ui/pagination";
import {
Table,
TableBody,
@@ -16,7 +16,6 @@ import {
TableRow,
} from "@/components/ui/table";
import TableSkeleton from "@/components/ui/TableSkeleton";
import Pagination from "@/components/ui/pagination";
import { _TooltipDefaultParams } from "@/constants/tooltip";
import { getPaginatedRowNumber, unixToDate } from "@/utils/shared";
import { startTransition } from "react";
@@ -37,8 +36,6 @@ const CompanyCategoriesTable = () => {
setCurrentCategory,
isModalOpen,
setIsModalOpen,
isFilterModalOpen,
setIsFilterModalOpen,
filterRegister,
handleFilterSubmit,
search,
@@ -47,133 +44,135 @@ const CompanyCategoriesTable = () => {
columns,
isMutating,
metadata,
setParams,
shouldShowPagination,
clearAllFilters,
tableSectionRef,
skeletonTbodyRef,
dataTbodyRef,
pagination,
handlePaginationChange,
} = useCompanyCategoriesPresenter();
return (
<ListWrapper data-cy="company-categories-page">
<ListHeader
onFilter={() => setIsFilterModalOpen(true)}
hasFilter={false}
createButtonText="Create Category"
dataCyPrefix="company-categories"
/>
<Table data-cy="company-categories-table">
<TableHeader>
<TableRow className="border-none uppercase [&>th]:text-start">
<TableHead colSpan={100}>Row</TableHead>
<TableHead colSpan={100}>Title</TableHead>
<TableHead colSpan={100}>Description</TableHead>
<TableHead colSpan={100}>Created at</TableHead>
<TableHead colSpan={100}>Published</TableHead>
<TableHead colSpan={100}>Actions</TableHead>
</TableRow>
</TableHeader>
{isLoading && <TableSkeleton column={columns.length} />}
<TableBody>
<EmptyListWrapper
list={optimisticCategories}
columns={columns}
emptyMessage="No categories were found"
isLoading={isLoading}
>
{optimisticCategories.map((category, index) => (
<TableRow
key={category.id}
data-cy={`company-categories-table-row-${category.id}`}
className="odd:bg-gray-2 dark:odd:bg-gray-7"
<CompanyCategoryListFilters
setValue={setFilterValue}
filterRegister={filterRegister}
handleFilterSubmit={handleFilterSubmit}
isMutating={isMutating as number}
search={search}
watch={watch}
onClearAll={clearAllFilters}
/>
<div ref={tableSectionRef}>
<Table data-cy="company-categories-table">
<TableHeader>
<TableRow className="border-none uppercase [&>th]:text-start">
<TableHead colSpan={100}>Row</TableHead>
<TableHead colSpan={100}>Title</TableHead>
<TableHead colSpan={100}>Description</TableHead>
<TableHead colSpan={100}>Created at</TableHead>
<TableHead colSpan={100}>Published</TableHead>
<TableHead colSpan={100}>Actions</TableHead>
</TableRow>
</TableHeader>
{isLoading ? (
<TableSkeleton ref={skeletonTbodyRef} column={columns.length} />
) : (
<TableBody ref={dataTbodyRef}>
<EmptyListWrapper
list={optimisticCategories}
columns={columns}
emptyMessage="No categories were found"
isLoading={false}
>
<TableCell colSpan={100}>
{getPaginatedRowNumber({
index,
page: metadata?.page,
limit: metadata?.limit,
})}
</TableCell>
<TableCell colSpan={100}>{category.name}</TableCell>
<TableCell colSpan={100}>{category.description}</TableCell>
<TableCell colSpan={100}>
{unixToDate({ unix: category.created_at, hasTime: true })}
</TableCell>
<TableCell colSpan={100}>
{/* Published: flip UI immediately via useOptimistic; if togglePublished rejects, React rolls the optimistic row back. */}
<Switch
withIcon
dataCy={`company-categories-published-toggle-${category.id}`}
onToggle={() => {}}
onChange={() => {
const nextPublished = !category.published;
startTransition(async () => {
addOptimisticPublished({
id: category.id,
published: nextPublished,
});
try {
await togglePublished(category.id);
} catch {
// useOptimistic automatically reverts when the transition ends
}
});
}}
checked={category.published}
/>
</TableCell>
<TableCell className="text-start xl:pr-7.5">
<div className="flex items-center justify-start gap-x-3.5">
<button
type="button"
data-cy={`company-categories-row-delete-${category.id}`}
data-tooltip-id="delete"
data-tooltip-content="Delete"
data-tooltip-place="top"
className="hover:text-red-500"
onClick={() => {
setCurrentCategory(category);
setIsModalOpen(true);
}}
>
<Tooltip {..._TooltipDefaultParams({ id: "delete" })} />
<span className="sr-only">Delete Admin </span>
<TrashIcon />
</button>
<button
type="button"
data-cy={`company-categories-row-edit-${category.id}`}
data-tooltip-id="edit"
data-tooltip-content="Edit"
data-tooltip-place="top"
className="hover:text-primary"
onClick={() =>
router.push(`${pathName}/edit/${category.id}`)
}
>
<Tooltip {..._TooltipDefaultParams({ id: "edit" })} />
<span className="sr-only">Edit Category</span>
<PencilSquareIcon />
</button>
</div>
</TableCell>
</TableRow>
))}
</EmptyListWrapper>
</TableBody>
</Table>
<Modal
isOpen={isFilterModalOpen}
onClose={() => setIsFilterModalOpen(false)}
classNames="w-full xl:w-1/2"
showButtons={false}
>
<CompanyCategoryListFilters
setValue={setFilterValue}
filterRegister={filterRegister}
handleFilterSubmit={handleFilterSubmit}
isMutating={isMutating as number}
search={search}
setIsFilterModalOpen={setIsFilterModalOpen}
watch={watch}
/>
</Modal>
{optimisticCategories.map((category, index) => (
<TableRow
key={category.id}
data-cy={`company-categories-table-row-${category.id}`}
className="odd:bg-gray-2 dark:odd:bg-gray-7"
>
<TableCell colSpan={100}>
{getPaginatedRowNumber({
index,
page: metadata?.page,
limit: metadata?.limit,
})}
</TableCell>
<TableCell colSpan={100}>{category.name}</TableCell>
<TableCell colSpan={100}>{category.description}</TableCell>
<TableCell colSpan={100}>
{unixToDate({ unix: category.created_at, hasTime: true })}
</TableCell>
<TableCell colSpan={100}>
<Switch
withIcon
dataCy={`company-categories-published-toggle-${category.id}`}
onToggle={() => {}}
onChange={() => {
const nextPublished = !category.published;
startTransition(async () => {
addOptimisticPublished({
id: category.id,
published: nextPublished,
});
try {
await togglePublished(category.id);
} catch {
// useOptimistic automatically reverts when the transition ends
}
});
}}
checked={category.published}
/>
</TableCell>
<TableCell className="text-start xl:pr-7.5">
<div className="flex items-center justify-start gap-x-3.5">
<button
type="button"
data-cy={`company-categories-row-delete-${category.id}`}
data-tooltip-id="delete"
data-tooltip-content="Delete"
data-tooltip-place="top"
className="hover:text-red-500"
onClick={() => {
setCurrentCategory(category);
setIsModalOpen(true);
}}
>
<Tooltip {..._TooltipDefaultParams({ id: "delete" })} />
<span className="sr-only">Delete Admin </span>
<TrashIcon />
</button>
<button
type="button"
data-cy={`company-categories-row-edit-${category.id}`}
data-tooltip-id="edit"
data-tooltip-content="Edit"
data-tooltip-place="top"
className="hover:text-primary"
onClick={() =>
router.push(`${pathName}/edit/${category.id}`)
}
>
<Tooltip {..._TooltipDefaultParams({ id: "edit" })} />
<span className="sr-only">Edit Category</span>
<PencilSquareIcon />
</button>
</div>
</TableCell>
</TableRow>
))}
</EmptyListWrapper>
</TableBody>
)}
</Table>
</div>
{isModalOpen && (
<ConfirmationModal
isOpen={isModalOpen}
@@ -191,15 +190,9 @@ const CompanyCategoriesTable = () => {
)}
<IsVisible condition={shouldShowPagination}>
<Pagination
currentPage={metadata?.page ? metadata.page - 1 : 0}
totalPages={metadata?.pages ?? 1}
onPageChange={(e) => {
setParams((currentParams) => ({
...currentParams,
offset: e.selected * (metadata?.limit ?? 10),
limit: metadata?.limit ?? 10,
}));
}}
currentPage={pagination.currentPage}
totalPages={pagination.totalPages}
onPageChange={handlePaginationChange}
/>
</IsVisible>
</ListWrapper>
@@ -5,18 +5,18 @@ import {
useDeleteCompanyCategoryQuery,
useToggleCompanyCategoryPublished,
} from "@/hooks/queries";
import { useTableSectionRowAnimations } from "@/hooks/useTableSectionRowAnimations";
import { TCompanyCategory } from "@/lib/api";
import { deleteEmptyKeys } from "@/utils/shared";
import { useIsMutating } from "@tanstack/react-query";
import { usePathname, useRouter, useSearchParams } from "next/navigation";
import { useEffect, useOptimistic, useState } from "react";
import { useCallback, useEffect, useMemo, useOptimistic, useRef, useState } from "react";
import { useForm } from "react-hook-form";
type TogglePublishedOptimistic = Pick<TCompanyCategory, "id" | "published">;
const useCompanyCategoriesPresenter = () => {
const [isModalOpen, setIsModalOpen] = useState(false);
const [isFilterModalOpen, setIsFilterModalOpen] = useState(false);
const [currentCategory, setCurrentCategory] = useState<TCompanyCategory>();
const [params, setParams] = useState<Record<string, any>>({});
const router = useRouter();
@@ -32,6 +32,10 @@ const useCompanyCategoriesPresenter = () => {
setValue: setFilterValue,
} = useForm();
const tableSectionRef = useRef<HTMLDivElement>(null);
const skeletonTbodyRef = useRef<HTMLTableSectionElement>(null);
const dataTbodyRef = useRef<HTMLTableSectionElement>(null);
useEffect(() => {
const newParams: Record<string, any> = {
...apiDefaultParams,
@@ -46,11 +50,39 @@ const useCompanyCategoriesPresenter = () => {
}, [searchParams, filterFormReset]);
const { data, isLoading, isError } = useCompanyCategoriesQuery(params);
useTableSectionRowAnimations(isLoading, data, {
tableSectionRef,
skeletonTbodyRef,
dataTbodyRef,
});
const categories = data?.data.categories;
const metadata = data?.meta;
const shouldShowPagination =
!isLoading &&
(data?.meta?.pages ?? 1) * (data?.meta?.limit ?? 10) > 10;
const pagination = useMemo(
() => ({
currentPage: data?.meta?.page ? data.meta.page - 1 : 0,
totalPages: data?.meta?.pages ?? 1,
}),
[data?.meta?.page, data?.meta?.pages],
);
const handlePaginationChange = useCallback(
(e: { selected: number }) => {
const limit = data?.meta?.limit ?? 10;
setParams((currentParams) => ({
...currentParams,
offset: e.selected * limit,
limit,
}));
},
[data?.meta?.limit],
);
const [optimisticCategories, addOptimisticPublished] = useOptimistic<
TCompanyCategory[],
TogglePublishedOptimistic
@@ -63,14 +95,17 @@ const useCompanyCategoriesPresenter = () => {
const { mutateAsync: togglePublished } =
useToggleCompanyCategoryPublished();
const search = (data: any) => {
const newParams = { ...params, ...data, offset: 0 };
const search = (formData: any) => {
const newParams = { ...params, ...formData, offset: 0 };
const cleanedParams = deleteEmptyKeys(newParams);
const queryString = new URLSearchParams(cleanedParams).toString();
router.push(`${pathName}?${queryString}`);
setIsFilterModalOpen(false);
};
const clearAllFilters = useCallback(() => {
router.push(pathName);
}, [pathName, router]);
const onConfirmDelete = () => {
if (!currentCategory) return;
deleteCategory(currentCategory?.id);
@@ -100,8 +135,6 @@ const useCompanyCategoriesPresenter = () => {
togglePublished,
setCurrentCategory,
onConfirmDelete,
isFilterModalOpen,
setIsFilterModalOpen,
filterRegister,
handleFilterSubmit,
search,
@@ -110,6 +143,12 @@ const useCompanyCategoriesPresenter = () => {
isMutating,
metadata,
shouldShowPagination,
clearAllFilters,
tableSectionRef,
skeletonTbodyRef,
dataTbodyRef,
pagination,
handlePaginationChange,
};
};
+139 -148
View File
@@ -10,7 +10,6 @@ import ConfirmationModal from "@/components/ui/ConfirmationModal";
import IsVisible from "@/components/ui/IsVisible";
import ListHeader from "@/components/ui/ListHeader";
import ListWrapper from "@/components/ui/ListWrapper";
import Modal from "@/components/ui/modal";
import Pagination from "@/components/ui/pagination";
import {
Table,
@@ -39,8 +38,6 @@ const CompaniesTable = () => {
setCurrentCompany,
onConfirmDelete,
modalConfig,
isFilterModalOpen,
setIsFilterModalOpen,
filterRegister,
handleFilterSubmit,
search,
@@ -48,151 +45,151 @@ const CompaniesTable = () => {
setFilterValue,
isMutating,
metadata,
setParams,
shouldShowPagination,
clearAllFilters,
tableSectionRef,
skeletonTbodyRef,
dataTbodyRef,
pagination,
handlePaginationChange,
} = useCompanyListPresenter();
const companies = allCompanies.filter(
(c): c is NonNullable<typeof c> => c != null,
);
return (
<ListWrapper>
<ListHeader
onFilter={() => setIsFilterModalOpen(true)}
createButtonText="Create Company"
<ListHeader hasFilter={false} createButtonText="Create Company" />
<CompanyListFilters
setValue={setFilterValue}
filterRegister={filterRegister}
handleFilterSubmit={handleFilterSubmit}
isMutating={isMutating as number}
search={search}
watch={watch}
onClearAll={clearAllFilters}
/>
<Table>
<TableHeader>
<TableRow>
{columns.map((columns) => (
<TableHead
colSpan={100}
className="text-start uppercase"
key={columns}
<div ref={tableSectionRef}>
<Table>
<TableHeader>
<TableRow>
{columns.map((col) => (
<TableHead
colSpan={100}
className="text-start uppercase"
key={col}
>
{col}
</TableHead>
))}
</TableRow>
</TableHeader>
{isPending ? (
<TableSkeleton ref={skeletonTbodyRef} column={columns.length} />
) : (
<TableBody ref={dataTbodyRef}>
<EmptyListWrapper
list={companies}
columns={columns}
emptyMessage="No companies were found"
isLoading={false}
>
{columns}
</TableHead>
))}
</TableRow>
</TableHeader>
{isPending && <TableSkeleton column={columns.length} />}
<TableBody>
<EmptyListWrapper
list={companies}
columns={columns}
emptyMessage="No companies were found"
isLoading={isPending}
>
{companies.map((company, index) => (
<TableRow
key={company.id}
className="odd:bg-gray-2 dark:odd:bg-gray-7"
>
<TableCell className="text-start" colSpan={100}>
{getPaginatedRowNumber({
index,
page: metadata?.page,
limit: metadata?.limit,
})}
</TableCell>
<TableCell className="text-start" colSpan={100}>
{company.name}
</TableCell>
<TableCell className="text-start" colSpan={100}>
{company.email}
</TableCell>
<TableCell className="w-full text-start" colSpan={100}>
{formatPhoneNumber(company.phone)}
</TableCell>
<TableCell className="text-start" colSpan={100}>
{company.address.country}
</TableCell>
<TableCell className="text-start" colSpan={100}>
{company.address.state}
</TableCell>
<TableCell className="text-start" colSpan={100}>
{company.language}
</TableCell>
<TableCell className="text-start" colSpan={100}>
{company.currency}
</TableCell>
<TableCell className="text-start" colSpan={100}>
{company.employee_count}
</TableCell>
<TableCell className="text-start xl:pr-7.5">
<div className="flex items-center justify-start gap-x-3.5">
<button
data-tooltip-id="delete"
data-tooltip-content="Delete"
data-tooltip-place="top"
className="hover:text-red-500"
onClick={() => {
setCurrentCompany(company);
setIsModalOpen(true);
}}
>
<Tooltip {..._TooltipDefaultParams({ id: "delete" })} />
<span className="sr-only">Delete Company </span>
<TrashIcon />
</button>
<button
data-tooltip-id="edit"
data-tooltip-content="Edit"
data-tooltip-place="top"
className="hover:text-primary"
onClick={() =>
router.push(`${pathName}/edit/${company.id}`)
}
>
<Tooltip {..._TooltipDefaultParams({ id: "edit" })} />
<span className="sr-only">Edit Company </span>
<PencilSquareIcon />
</button>
<button
data-tooltip-id="details"
data-tooltip-content="Details"
data-tooltip-place="top"
onClick={() => {
router.push(`${pathName}/details/${company.id}`);
}}
>
<Tooltip {..._TooltipDefaultParams({ id: "details" })} />
<EyeIcon />
</button>
<button
data-tooltip-id="feedback"
data-tooltip-content="Feedback"
data-tooltip-place="top"
className="text-gray-40 rounded-full bg-gray-dark bg-opacity-5 hover:text-green-dark dark:bg-gray dark:bg-opacity-5"
onClick={() => {
router.push(`${pathName}/feedback/${company.id}`);
}}
>
<Tooltip {..._TooltipDefaultParams({ id: "feedback" })} />
<ExclamationIcon />
</button>
</div>
</TableCell>
</TableRow>
))}
</EmptyListWrapper>
</TableBody>
</Table>
<Modal
isOpen={isFilterModalOpen}
onClose={() => setIsFilterModalOpen(false)}
classNames="w-full xl:w-1/2"
showButtons={false}
>
<CompanyListFilters
setValue={setFilterValue}
filterRegister={filterRegister}
handleFilterSubmit={handleFilterSubmit}
isMutating={isMutating as number}
search={search}
setIsFilterModalOpen={setIsFilterModalOpen}
watch={watch}
/>
</Modal>
{companies.map((company, index) => (
<TableRow
key={company.id}
className="odd:bg-gray-2 dark:odd:bg-gray-7"
>
<TableCell className="text-start" colSpan={100}>
{getPaginatedRowNumber({
index,
page: metadata?.page,
limit: metadata?.limit,
})}
</TableCell>
<TableCell className="text-start" colSpan={100}>
{company.name}
</TableCell>
<TableCell className="text-start" colSpan={100}>
{company.email}
</TableCell>
<TableCell className="w-full text-start" colSpan={100}>
{formatPhoneNumber(company.phone)}
</TableCell>
<TableCell className="text-start" colSpan={100}>
{company.address.country}
</TableCell>
<TableCell className="text-start" colSpan={100}>
{company.address.state}
</TableCell>
<TableCell className="text-start" colSpan={100}>
{company.language}
</TableCell>
<TableCell className="text-start" colSpan={100}>
{company.currency}
</TableCell>
<TableCell className="text-start" colSpan={100}>
{company.employee_count}
</TableCell>
<TableCell className="text-start xl:pr-7.5">
<div className="flex items-center justify-start gap-x-3.5">
<button
data-tooltip-id="delete"
data-tooltip-content="Delete"
data-tooltip-place="top"
className="hover:text-red-500"
onClick={() => {
setCurrentCompany(company);
setIsModalOpen(true);
}}
>
<Tooltip {..._TooltipDefaultParams({ id: "delete" })} />
<span className="sr-only">Delete Company </span>
<TrashIcon />
</button>
<button
data-tooltip-id="edit"
data-tooltip-content="Edit"
data-tooltip-place="top"
className="hover:text-primary"
onClick={() =>
router.push(`${pathName}/edit/${company.id}`)
}
>
<Tooltip {..._TooltipDefaultParams({ id: "edit" })} />
<span className="sr-only">Edit Company </span>
<PencilSquareIcon />
</button>
<button
data-tooltip-id="details"
data-tooltip-content="Details"
data-tooltip-place="top"
onClick={() => {
router.push(`${pathName}/details/${company.id}`);
}}
>
<Tooltip {..._TooltipDefaultParams({ id: "details" })} />
<EyeIcon />
</button>
<button
data-tooltip-id="feedback"
data-tooltip-content="Feedback"
data-tooltip-place="top"
className="text-gray-40 rounded-full bg-gray-dark bg-opacity-5 hover:text-green-dark dark:bg-gray dark:bg-opacity-5"
onClick={() => {
router.push(`${pathName}/feedback/${company.id}`);
}}
>
<Tooltip {..._TooltipDefaultParams({ id: "feedback" })} />
<ExclamationIcon />
</button>
</div>
</TableCell>
</TableRow>
))}
</EmptyListWrapper>
</TableBody>
)}
</Table>
</div>
{isModalOpen && (
<ConfirmationModal
isOpen={isModalOpen}
@@ -210,15 +207,9 @@ const CompaniesTable = () => {
)}
<IsVisible condition={shouldShowPagination}>
<Pagination
currentPage={metadata?.page ? metadata.page - 1 : 0}
totalPages={metadata?.pages ?? 1}
onPageChange={(e) => {
setParams((currentParams) => ({
...currentParams,
offset: e.selected * (metadata?.limit ?? 10),
limit: metadata?.limit ?? 10,
}));
}}
currentPage={pagination.currentPage}
totalPages={pagination.totalPages}
onPageChange={handlePaginationChange}
/>
</IsVisible>
</ListWrapper>
@@ -5,16 +5,16 @@ import {
useCompaniesQuery,
useDeleteCompanyQuery,
} from "@/hooks/queries";
import { useTableSectionRowAnimations } from "@/hooks/useTableSectionRowAnimations";
import { TCompany } from "@/lib/api";
import { deleteEmptyKeys } from "@/utils/shared";
import { useIsMutating } from "@tanstack/react-query";
import { usePathname, useRouter, useSearchParams } from "next/navigation";
import { useEffect, useState } from "react";
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { useForm } from "react-hook-form";
export const useCompanyListPresenter = () => {
const [currentCompany, setCurrentCompany] = useState<TCompany | null>(null);
const [isFilterModalOpen, setIsFilterModalOpen] = useState(false);
const [modalConfig, setModalConfig] = useState<
Partial<ConfirmationModalProps>
>({});
@@ -33,6 +33,10 @@ export const useCompanyListPresenter = () => {
setValue: setFilterValue,
} = useForm();
const tableSectionRef = useRef<HTMLDivElement>(null);
const skeletonTbodyRef = useRef<HTMLTableSectionElement>(null);
const dataTbodyRef = useRef<HTMLTableSectionElement>(null);
const columns = [
"row",
"name",
@@ -61,6 +65,12 @@ export const useCompanyListPresenter = () => {
const { data, error, isPending, isError } = useCompaniesQuery(params);
useTableSectionRowAnimations(isPending, data, {
tableSectionRef,
skeletonTbodyRef,
dataTbodyRef,
});
const shouldShowPagination =
!isPending && (data?.meta?.pages ?? 1) * (data?.meta?.limit ?? 10) > 10;
@@ -68,13 +78,37 @@ export const useCompanyListPresenter = () => {
setIsModalOpen(false),
);
const search = (data: any) => {
const newParams = { ...params, ...data, offset: 0 };
const search = (formData: any) => {
const newParams = { ...params, ...formData, offset: 0 };
const cleanedParams = deleteEmptyKeys(newParams);
const queryString = new URLSearchParams(cleanedParams).toString();
router.push(`${pathName}?${queryString}`);
setIsFilterModalOpen(false);
};
const clearAllFilters = useCallback(() => {
router.push(pathName);
}, [pathName, router]);
const pagination = useMemo(
() => ({
currentPage: data?.meta?.page ? data.meta.page - 1 : 0,
totalPages: data?.meta?.pages ?? 1,
}),
[data?.meta?.page, data?.meta?.pages],
);
const handlePaginationChange = useCallback(
(e: { selected: number }) => {
const limit = data?.meta?.limit ?? 10;
setParams((currentParams) => ({
...currentParams,
offset: e.selected * limit,
limit,
}));
},
[data?.meta?.limit],
);
const onConfirmDelete = () => {
if (currentCompany) {
deleteCompany(currentCompany.id);
@@ -98,8 +132,6 @@ export const useCompanyListPresenter = () => {
router,
isPending,
isError,
isFilterModalOpen,
setIsFilterModalOpen,
filterRegister,
handleFilterSubmit,
search,
@@ -108,5 +140,11 @@ export const useCompanyListPresenter = () => {
isMutating,
setParams,
shouldShowPagination,
clearAllFilters,
tableSectionRef,
skeletonTbodyRef,
dataTbodyRef,
pagination,
handlePaginationChange,
};
};
@@ -0,0 +1,113 @@
"use client";
import InputGroup from "@/components/FormElements/InputGroup";
import { Button } from "@/components/ui-elements/button";
import InlineListFiltersPanel from "@/components/ui/InlineListFiltersPanel";
import {
FieldValues,
UseFormHandleSubmit,
UseFormRegister,
UseFormSetValue,
UseFormWatch,
} from "react-hook-form";
import { useMemo } from "react";
interface ContactUsListFiltersProps {
filterRegister: UseFormRegister<FieldValues>;
isMutating: number;
handleFilterSubmit: UseFormHandleSubmit<FieldValues>;
search: (data: any) => void;
watch: UseFormWatch<FieldValues>;
setValue: UseFormSetValue<any>;
onClearAll: () => void;
}
function countActive(values: Record<string, unknown>): number {
let n = 0;
const q = values.q;
if (typeof q === "string" && q.trim()) n++;
const status = values.status;
if (
status !== undefined &&
status !== null &&
status !== "" &&
String(status).trim()
) {
n++;
}
return n;
}
export default function ContactUsListFilters({
filterRegister,
isMutating,
handleFilterSubmit,
search,
watch,
onClearAll,
}: ContactUsListFiltersProps) {
const watched = watch();
const activeCount = useMemo(
() => countActive(watched as Record<string, unknown>),
[watched],
);
return (
<InlineListFiltersPanel
title="Filter contacts"
description="Search submissions or narrow by status — inline, no popup."
activeFilterCount={activeCount}
expandAriaLabel="Expand contact filters"
collapseAriaLabel="Collapse contact filters"
panelToggleDataCy="contact-us-filter-panel-toggle"
>
<form
className="space-y-5 px-4 pb-6 pt-1 sm:px-6"
onSubmit={handleFilterSubmit(search)}
data-cy="contact-us-filter-form"
>
<div className="grid gap-4 sm:grid-cols-2">
<InputGroup
{...filterRegister("q")}
name="q"
label="Keyword search"
placeholder="Name, email…"
type="search"
dataCy="contact-us-filter-q-input"
/>
<InputGroup
{...filterRegister("status")}
name="status"
label="Status"
placeholder="Status"
type="text"
dataCy="contact-us-filter-status-input"
/>
</div>
<div className="flex flex-col gap-2 sm:flex-row sm:items-center">
<Button
type="submit"
size="small"
className="min-h-[52px] min-w-[140px] rounded-2xl bg-gradient-to-r from-primary via-primary to-violet-600 px-8 text-sm font-semibold transition hover:opacity-[0.97]"
data-cy="contact-us-filter-submit-button"
label={
isMutating ? (
<span className="inline-flex h-5 w-5 animate-spin rounded-full border-2 border-white/30 border-t-white" />
) : (
<span>Apply filters</span>
)
}
/>
<button
type="button"
data-cy="contact-us-filter-reset-button"
onClick={onClearAll}
className="min-h-[52px] rounded-2xl border border-stroke/70 bg-white/80 px-6 text-sm font-semibold text-dark transition hover:border-primary/40 hover:text-primary dark:border-dark-3 dark:bg-dark-3/60 dark:text-white dark:hover:bg-white/[0.06]"
>
Reset all
</button>
</div>
</form>
</InlineListFiltersPanel>
);
}
+103 -81
View File
@@ -3,6 +3,7 @@ import { TrashIcon } from "@/assets/icons";
import EmptyListWrapper from "@/components/HOC/EmptyListWrapper";
import ConfirmationModal from "@/components/ui/ConfirmationModal";
import IsVisible from "@/components/ui/IsVisible";
import ListHeader from "@/components/ui/ListHeader";
import ListWrapper from "@/components/ui/ListWrapper";
import Pagination from "@/components/ui/pagination";
import {
@@ -17,6 +18,7 @@ import TableSkeleton from "@/components/ui/TableSkeleton";
import { _TooltipDefaultParams } from "@/constants/tooltip";
import { getPaginatedRowNumber, unixToDate } from "@/utils/shared";
import { Tooltip } from "react-tooltip";
import ContactUsListFilters from "./ContactUsListFilters";
import { useContactUsListPresenter } from "./useContactUsListPresenter";
const ContactUsTable = () => {
@@ -30,83 +32,109 @@ const ContactUsTable = () => {
isModalOpen,
currentContact,
metadata,
setParams,
shouldShowPagination,
filterRegister,
handleFilterSubmit,
search,
watch,
setFilterValue,
isMutating,
clearAllFilters,
tableSectionRef,
skeletonTbodyRef,
dataTbodyRef,
pagination,
handlePaginationChange,
} = useContactUsListPresenter();
return (
<ListWrapper>
<Table>
<TableHeader>
<TableRow>
{columns.map((columns) => (
<TableHead
colSpan={100}
className="text-start uppercase"
key={columns}
<ListHeader hasFilter={false} hasCreate={false} />
<ContactUsListFilters
setValue={setFilterValue}
filterRegister={filterRegister}
handleFilterSubmit={handleFilterSubmit}
isMutating={isMutating as number}
search={search}
watch={watch}
onClearAll={clearAllFilters}
/>
<div ref={tableSectionRef}>
<Table>
<TableHeader>
<TableRow>
{columns.map((col) => (
<TableHead
colSpan={100}
className="text-start uppercase"
key={col}
>
{col}
</TableHead>
))}
</TableRow>
</TableHeader>
{isPending ? (
<TableSkeleton ref={skeletonTbodyRef} column={columns.length} />
) : (
<TableBody ref={dataTbodyRef}>
<EmptyListWrapper
list={contacts ?? []}
columns={columns}
emptyMessage="No contact us is submitted"
isLoading={false}
>
{columns}
</TableHead>
))}
</TableRow>
</TableHeader>
{isPending && <TableSkeleton column={columns.length} />}
<TableBody>
<EmptyListWrapper
list={contacts ?? []}
columns={columns}
emptyMessage="No contact us is submitted"
isLoading={isPending}
>
{contacts?.map((item, index) => (
<TableRow
key={item?.id ?? index}
className="odd:bg-gray-2 dark:odd:bg-gray-7"
>
<TableCell className="text-start" colSpan={100}>
{getPaginatedRowNumber({
index,
page: metadata?.page,
limit: metadata?.limit,
})}
</TableCell>
<TableCell className="text-start" colSpan={100}>
{item?.full_name}
</TableCell>
<TableCell className="text-start" colSpan={100}>
{item?.email}
</TableCell>
<TableCell className="text-start" colSpan={100}>
{item?.status}
</TableCell>
<TableCell className="text-start" colSpan={100}>
{item?.phone}
</TableCell>
<TableCell className="text-start" colSpan={100}>
{unixToDate({ unix: item?.created_at })}
</TableCell>
<TableCell className="text-start xl:pr-7.5">
<div className="flex items-center justify-start gap-x-3.5">
<button
data-tooltip-id="delete"
data-tooltip-content="Delete"
data-tooltip-place="top"
className="hover:text-red-500"
onClick={() => {
setCurrentContact(item);
setIsModalOpen(true);
}}
>
<Tooltip {..._TooltipDefaultParams({ id: "delete" })} />
<span className="sr-only">Delete Contact Us </span>
<TrashIcon />
</button>
</div>
</TableCell>
</TableRow>
))}
</EmptyListWrapper>
</TableBody>
</Table>
{contacts?.map((item, index) => (
<TableRow
key={item?.id ?? index}
className="odd:bg-gray-2 dark:odd:bg-gray-7"
>
<TableCell className="text-start" colSpan={100}>
{getPaginatedRowNumber({
index,
page: metadata?.page,
limit: metadata?.limit,
})}
</TableCell>
<TableCell className="text-start" colSpan={100}>
{item?.full_name}
</TableCell>
<TableCell className="text-start" colSpan={100}>
{item?.email}
</TableCell>
<TableCell className="text-start" colSpan={100}>
{item?.status}
</TableCell>
<TableCell className="text-start" colSpan={100}>
{item?.phone}
</TableCell>
<TableCell className="text-start" colSpan={100}>
{unixToDate({ unix: item?.created_at })}
</TableCell>
<TableCell className="text-start xl:pr-7.5">
<div className="flex items-center justify-start gap-x-3.5">
<button
data-tooltip-id="delete"
data-tooltip-content="Delete"
data-tooltip-place="top"
className="hover:text-red-500"
onClick={() => {
setCurrentContact(item);
setIsModalOpen(true);
}}
>
<Tooltip {..._TooltipDefaultParams({ id: "delete" })} />
<span className="sr-only">Delete Contact Us </span>
<TrashIcon />
</button>
</div>
</TableCell>
</TableRow>
))}
</EmptyListWrapper>
</TableBody>
)}
</Table>
</div>
{isModalOpen && (
<ConfirmationModal
isOpen={isModalOpen}
@@ -124,15 +152,9 @@ const ContactUsTable = () => {
)}
<IsVisible condition={shouldShowPagination}>
<Pagination
currentPage={metadata?.page ? metadata.page - 1 : 0}
totalPages={metadata?.pages ?? 1}
onPageChange={(e) => {
setParams((currentParams) => ({
...currentParams,
offset: e.selected * (metadata?.limit ?? 10),
limit: metadata?.limit ?? 10,
}));
}}
currentPage={pagination.currentPage}
totalPages={pagination.totalPages}
onPageChange={handlePaginationChange}
/>
</IsVisible>
</ListWrapper>
@@ -4,9 +4,13 @@ import {
useDeleteContactUs,
useReadAllContactUs,
} from "@/hooks/queries/useContactUsQuery";
import { useTableSectionRowAnimations } from "@/hooks/useTableSectionRowAnimations";
import { TContact } from "@/lib/api/types/TContacts";
import { deleteEmptyKeys } from "@/utils/shared";
import { useIsMutating } from "@tanstack/react-query";
import { usePathname, useRouter, useSearchParams } from "next/navigation";
import { useEffect, useState } from "react";
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { useForm } from "react-hook-form";
export const useContactUsListPresenter = () => {
const [isModalOpen, setIsModalOpen] = useState(false);
@@ -24,6 +28,19 @@ export const useContactUsListPresenter = () => {
const pathname = usePathname();
const router = useRouter();
const searchParams = useSearchParams();
const isMutating = useIsMutating();
const {
register: filterRegister,
handleSubmit: handleFilterSubmit,
reset: filterFormReset,
watch,
setValue: setFilterValue,
} = useForm();
const tableSectionRef = useRef<HTMLDivElement>(null);
const skeletonTbodyRef = useRef<HTMLTableSectionElement>(null);
const dataTbodyRef = useRef<HTMLTableSectionElement>(null);
useEffect(() => {
const newParams: Record<string, unknown> = {
@@ -35,9 +52,17 @@ export const useContactUsListPresenter = () => {
newParams[key] = value;
}
setParams(newParams);
}, [searchParams]);
filterFormReset(newParams as any);
}, [searchParams, filterFormReset]);
const { data, isPending } = useReadAllContactUs(params);
useTableSectionRowAnimations(isPending, data, {
tableSectionRef,
skeletonTbodyRef,
dataTbodyRef,
});
const { mutate: deleteContactUS } = useDeleteContactUs(() =>
setIsModalOpen(false),
);
@@ -45,6 +70,39 @@ export const useContactUsListPresenter = () => {
const shouldShowPagination =
!isPending && (data?.meta?.pages ?? 1) * (data?.meta?.limit ?? 10) > 10;
const pagination = useMemo(
() => ({
currentPage: data?.meta?.page ? data.meta.page - 1 : 0,
totalPages: data?.meta?.pages ?? 1,
}),
[data?.meta?.page, data?.meta?.pages],
);
const handlePaginationChange = useCallback(
(e: { selected: number }) => {
const limit = data?.meta?.limit ?? 10;
setParams((currentParams) => ({
...currentParams,
offset: e.selected * limit,
limit,
}));
},
[data?.meta?.limit],
);
const search = (formData: any) => {
const newParams = { ...params, ...formData, offset: 0 };
const cleanedParams = deleteEmptyKeys(newParams);
const queryString = new URLSearchParams(
cleanedParams as Record<string, string>,
).toString();
router.push(`${pathname}?${queryString}`);
};
const clearAllFilters = useCallback(() => {
router.push(pathname);
}, [pathname, router]);
const onConfirmDelete = () => {
if (!currentContact) return;
deleteContactUS(currentContact?.id);
@@ -63,5 +121,17 @@ export const useContactUsListPresenter = () => {
setCurrentContact,
isModalOpen,
currentContact,
filterRegister,
handleFilterSubmit,
search,
watch,
setFilterValue,
isMutating,
clearAllFilters,
tableSectionRef,
skeletonTbodyRef,
dataTbodyRef,
pagination,
handlePaginationChange,
};
};
@@ -1,4 +1,8 @@
"use client";
import { Select } from "@/components/FormElements/select";
import { Button } from "@/components/ui-elements/button";
import InlineListFiltersPanel from "@/components/ui/InlineListFiltersPanel";
import { AdminRoles, CustomerStatus, CustomerType } from "@/constants/enums";
import { getEnumAsArray } from "@/utils/shared";
import {
@@ -8,82 +12,113 @@ import {
UseFormSetValue,
UseFormWatch,
} from "react-hook-form";
import { useMemo } from "react";
interface CustomerListFiltersProps {
filterRegister: UseFormRegister<FieldValues>;
setIsFilterModalOpen: (isOpen: boolean) => void;
isMutating: number;
handleFilterSubmit: UseFormHandleSubmit<FieldValues>;
search: (data: any) => void;
watch: UseFormWatch<FieldValues>;
setValue: UseFormSetValue<any>;
onClearAll: () => void;
}
function countActive(values: Record<string, unknown>): number {
let n = 0;
for (const key of ["status", "role", "type"] as const) {
const v = values[key];
if (v !== undefined && v !== null && v !== "" && String(v).trim()) n++;
}
return n;
}
const CustomerListFilters = ({
filterRegister,
setIsFilterModalOpen,
isMutating,
handleFilterSubmit,
search,
watch,
setValue,
onClearAll,
}: CustomerListFiltersProps) => {
return (
<form
className="grid !min-w-full grid-cols-1 gap-4 xl:grid-cols-2"
onSubmit={handleFilterSubmit(search)}
>
<Select
{...filterRegister("status")}
items={getEnumAsArray(CustomerStatus)}
label="status"
name="status"
placeholder="Status"
clearable
value={watch("status")}
onClear={() => setValue("status", undefined)}
/>
<Select
{...filterRegister("role")}
items={getEnumAsArray(AdminRoles)}
label="role"
name="role"
placeholder="Role"
clearable
value={watch("role")}
onClear={() => setValue("role", undefined)}
/>
<Select
{...filterRegister("type")}
items={getEnumAsArray(CustomerType)}
label="type"
name="type"
placeholder="Type"
clearable
value={watch("type")}
onClear={() => setValue("type", undefined)}
/>
const watched = watch();
const activeCount = useMemo(
() => countActive(watched as Record<string, unknown>),
[watched],
);
<div className="flex gap-4 col-span-2">
<button
type="submit"
className="mt-6 flex w-fit justify-center rounded-lg bg-primary p-[13px] font-medium capitalize text-white hover:bg-opacity-90"
>
{isMutating ? (
<div className="h-5 w-5 animate-spin rounded-full border-b-2 border-gray-1"></div>
) : (
<span>Search</span>
)}
</button>
<button
type="button"
className="mt-6 flex w-fit justify-center rounded-lg bg-error p-[13px] font-medium capitalize text-white hover:bg-opacity-90"
onClick={() => setIsFilterModalOpen(false)}
>
Close
</button>
</div>
</form>
return (
<InlineListFiltersPanel
title="Filter customers"
description="Slice by status, role, or account type — inline, no popup."
activeFilterCount={activeCount}
expandAriaLabel="Expand customer filters"
collapseAriaLabel="Collapse customer filters"
panelToggleDataCy="customers-filter-panel-toggle"
>
<form
className="space-y-5 px-4 pb-6 pt-1 sm:px-6"
onSubmit={handleFilterSubmit(search)}
data-cy="customers-filter-form"
>
<div className="grid gap-4 sm:grid-cols-2 xl:grid-cols-3">
<Select
{...filterRegister("status")}
items={getEnumAsArray(CustomerStatus)}
label="Status"
name="status"
placeholder="Status"
clearable
value={watch("status")}
onClear={() => setValue("status", undefined)}
/>
<Select
{...filterRegister("role")}
items={getEnumAsArray(AdminRoles)}
label="Role"
name="role"
placeholder="Role"
clearable
value={watch("role")}
onClear={() => setValue("role", undefined)}
/>
<Select
{...filterRegister("type")}
items={getEnumAsArray(CustomerType)}
label="Type"
name="type"
placeholder="Type"
clearable
value={watch("type")}
onClear={() => setValue("type", undefined)}
/>
</div>
<div className="flex flex-col gap-2 sm:flex-row sm:items-center">
<Button
type="submit"
size="small"
className="min-h-[52px] min-w-[140px] rounded-2xl bg-gradient-to-r from-primary via-primary to-violet-600 px-8 text-sm font-semibold transition hover:opacity-[0.97]"
data-cy="customers-filter-submit-button"
label={
isMutating ? (
<span className="inline-flex h-5 w-5 animate-spin rounded-full border-2 border-white/30 border-t-white" />
) : (
<span>Apply filters</span>
)
}
/>
<button
type="button"
data-cy="customers-filter-reset-button"
onClick={onClearAll}
className="min-h-[52px] rounded-2xl border border-stroke/70 bg-white/80 px-6 text-sm font-semibold text-dark transition hover:border-primary/40 hover:text-primary dark:border-dark-3 dark:bg-dark-3/60 dark:text-white dark:hover:bg-white/[0.06]"
>
Reset all
</button>
</div>
</form>
</InlineListFiltersPanel>
);
};
+146 -154
View File
@@ -1,10 +1,11 @@
"use client";
import { ExclamationIcon, PencilSquareIcon, TrashIcon } from "@/assets/icons";
import EmptyListWrapper from "@/components/HOC/EmptyListWrapper";
import { Building } from "@/components/Layouts/sidebar/icons";
import EmptyListWrapper from "@/components/HOC/EmptyListWrapper";
import ConfirmationModal from "@/components/ui/ConfirmationModal";
import IsVisible from "@/components/ui/IsVisible";
import ListHeader from "@/components/ui/ListHeader";
import ListWrapper from "@/components/ui/ListWrapper";
import Modal from "@/components/ui/modal";
import Pagination from "@/components/ui/pagination";
import Status from "@/components/ui/Status";
@@ -24,7 +25,6 @@ import { Tooltip } from "react-tooltip";
import AssignToCompanyModalContent from "./AssignToCompanyModalContent";
import CustomerListFilters from "./CustomerListFilters";
import useCustomerListPresenter from "./useCustomerListPresenter";
import ListWrapper from "@/components/ui/ListWrapper";
const CustomersTable = () => {
const {
@@ -44,179 +44,171 @@ const CustomersTable = () => {
setCurrentCustomer,
isAssignCompanyModalOpen,
setIsAssignCompanyModalOpen,
isFilterModalOpen,
setIsFilterModalOpen,
filterRegister,
handleFilterSubmit,
search,
watch,
setFilterValue,
isMutating,
setParams,
assignCompanyCompaniesQuery,
shouldShowPagination,
clearAllFilters,
tableSectionRef,
skeletonTbodyRef,
dataTbodyRef,
pagination,
handlePaginationChange,
} = useCustomerListPresenter();
return (
<ListWrapper>
<ListHeader
onFilter={() => setIsFilterModalOpen(true)}
createButtonText="Create Customer"
<ListHeader hasFilter={false} createButtonText="Create Customer" />
<CustomerListFilters
setValue={setFilterValue}
filterRegister={filterRegister}
handleFilterSubmit={handleFilterSubmit}
isMutating={isMutating as number}
search={search}
watch={watch}
onClearAll={clearAllFilters}
/>
<Table>
<TableHeader>
<TableRow>
{columns.map((columns) => (
<TableHead
colSpan={100}
className="text-start font-extrabold uppercase"
key={columns}
<div ref={tableSectionRef}>
<Table>
<TableHeader>
<TableRow>
{columns.map((col) => (
<TableHead
colSpan={100}
className="text-start font-extrabold uppercase"
key={col}
>
{col}
</TableHead>
))}
</TableRow>
</TableHeader>
{isPending ? (
<TableSkeleton ref={skeletonTbodyRef} column={columns.length} />
) : (
<TableBody ref={dataTbodyRef}>
<EmptyListWrapper
list={allCustomers ?? []}
columns={columns}
emptyMessage="No Customers were found"
isLoading={false}
>
{columns}
</TableHead>
))}
</TableRow>
</TableHeader>
{isPending && <TableSkeleton column={columns.length} />}
<TableBody>
<EmptyListWrapper
list={allCustomers ?? []}
columns={columns}
emptyMessage="No Customers were found"
isLoading={isPending}
>
{allCustomers?.map((customer, index) => (
<TableRow key={customer?.id ?? index}>
<TableCell colSpan={100}>
{getPaginatedRowNumber({
index,
page: metadata?.page,
limit: metadata?.limit,
})}
</TableCell>
<TableCell colSpan={100}>{customer?.full_name}</TableCell>
<TableCell colSpan={100}>{customer?.email}</TableCell>
<TableCell colSpan={100}>
{unixToDate({ unix: customer?.created_at, hasTime: true })}
</TableCell>
{allCustomers?.map((customer, index) => (
<TableRow key={customer?.id ?? index}>
<TableCell colSpan={100}>
{getPaginatedRowNumber({
index,
page: metadata?.page,
limit: metadata?.limit,
})}
</TableCell>
<TableCell colSpan={100}>{customer?.full_name}</TableCell>
<TableCell colSpan={100}>{customer?.email}</TableCell>
<TableCell colSpan={100}>
{unixToDate({ unix: customer?.created_at, hasTime: true })}
</TableCell>
<TableCell colSpan={100} className="uppercase">
{customer?.type}
</TableCell>
<TableCell colSpan={100}>
<Status status={customer?.status}>{customer?.status}</Status>
</TableCell>
<TableCell colSpan={100}>
{customer?.companies?.length ? (
customer?.companies.map((c) => (
<span key={c.id}>{c.name}, </span>
))
) : (
<span>None</span>
)}
</TableCell>
<TableCell colSpan={100} className="uppercase">
{customer?.role ?? "-"}
</TableCell>
<TableCell className="text-start xl:pr-7.5">
<div className="flex items-center justify-start gap-x-3.5">
<button
data-tooltip-id="delete"
data-tooltip-content="Delete"
data-tooltip-place="top"
className="hover:text-red-500"
onClick={() => {
setCurrentCustomer(customer);
setIsModalOpen(true);
}}
>
<Tooltip {..._TooltipDefaultParams({ id: "delete" })} />
<span className="sr-only">Delete Company </span>
<TrashIcon />
</button>
<button
data-tooltip-id="edit"
data-tooltip-content="Edit"
data-tooltip-place="top"
className="hover:text-primary"
onClick={() =>
router.push(`${pathName}/edit/${customer.id}`)
}
>
<Tooltip {..._TooltipDefaultParams({ id: "edit" })} />
<span className="sr-only">Edit Company </span>
<PencilSquareIcon />
</button>
<button
data-tooltip-id="assign-company"
data-tooltip-content="Assign to company"
data-tooltip-place="top"
className="hover:text-green-dark"
onClick={() => {
setCurrentCustomer(customer);
setIsAssignCompanyModalOpen(true);
setSelectedCompanies({
company_ids: [customer?.companies?.[0]?.id ?? ""],
});
}}
>
<Tooltip
{..._TooltipDefaultParams({ id: "assign-company" })}
/>
<span className="sr-only">Assign To Company </span>
<Building />
</button>
<button
data-tooltip-id="feedback"
data-tooltip-content="Feedback"
data-tooltip-place="top"
className="text-gray-40 rounded-full bg-gray-dark bg-opacity-5 hover:text-green-dark dark:bg-gray dark:bg-opacity-5"
onClick={() => {
router.push(`${pathName}/feedback/${customer.id}`);
}}
>
<Tooltip {..._TooltipDefaultParams({ id: "feedback" })} />
<ExclamationIcon />
</button>
</div>
</TableCell>
</TableRow>
))}
</EmptyListWrapper>
</TableBody>
</Table>
<TableCell colSpan={100} className="uppercase">
{customer?.type}
</TableCell>
<TableCell colSpan={100}>
<Status status={customer?.status}>{customer?.status}</Status>
</TableCell>
<TableCell colSpan={100}>
{customer?.companies?.length ? (
customer?.companies.map((c) => (
<span key={c.id}>{c.name}, </span>
))
) : (
<span>None</span>
)}
</TableCell>
<TableCell colSpan={100} className="uppercase">
{customer?.role ?? "-"}
</TableCell>
<TableCell className="text-start xl:pr-7.5">
<div className="flex items-center justify-start gap-x-3.5">
<button
data-tooltip-id="delete"
data-tooltip-content="Delete"
data-tooltip-place="top"
className="hover:text-red-500"
onClick={() => {
setCurrentCustomer(customer);
setIsModalOpen(true);
}}
>
<Tooltip {..._TooltipDefaultParams({ id: "delete" })} />
<span className="sr-only">Delete Company </span>
<TrashIcon />
</button>
<button
data-tooltip-id="edit"
data-tooltip-content="Edit"
data-tooltip-place="top"
className="hover:text-primary"
onClick={() =>
router.push(`${pathName}/edit/${customer.id}`)
}
>
<Tooltip {..._TooltipDefaultParams({ id: "edit" })} />
<span className="sr-only">Edit Company </span>
<PencilSquareIcon />
</button>
<button
data-tooltip-id="assign-company"
data-tooltip-content="Assign to company"
data-tooltip-place="top"
className="hover:text-green-dark"
onClick={() => {
setCurrentCustomer(customer);
setIsAssignCompanyModalOpen(true);
setSelectedCompanies({
company_ids: [customer?.companies?.[0]?.id ?? ""],
});
}}
>
<Tooltip
{..._TooltipDefaultParams({ id: "assign-company" })}
/>
<span className="sr-only">Assign To Company </span>
<Building />
</button>
<button
data-tooltip-id="feedback"
data-tooltip-content="Feedback"
data-tooltip-place="top"
className="text-gray-40 rounded-full bg-gray-dark bg-opacity-5 hover:text-green-dark dark:bg-gray dark:bg-opacity-5"
onClick={() => {
router.push(`${pathName}/feedback/${customer.id}`);
}}
>
<Tooltip {..._TooltipDefaultParams({ id: "feedback" })} />
<ExclamationIcon />
</button>
</div>
</TableCell>
</TableRow>
))}
</EmptyListWrapper>
</TableBody>
)}
</Table>
</div>
<IsVisible condition={shouldShowPagination}>
<Pagination
currentPage={metadata?.page ? metadata?.page - 1 : 0}
totalPages={metadata?.pages ?? 1}
onPageChange={(e) => {
setParams((currentParams) => ({
...currentParams,
offset: e.selected * (metadata?.limit ?? 10),
limit: metadata?.limit ?? 10,
}));
}}
currentPage={pagination.currentPage}
totalPages={pagination.totalPages}
onPageChange={handlePaginationChange}
/>
</IsVisible>
<Modal
isOpen={isFilterModalOpen}
onClose={() => setIsFilterModalOpen(false)}
classNames="w-full xl:w-1/2"
showButtons={false}
>
<CustomerListFilters
setValue={setFilterValue}
filterRegister={filterRegister}
handleFilterSubmit={handleFilterSubmit}
isMutating={isMutating as number}
search={search}
setIsFilterModalOpen={setIsFilterModalOpen}
watch={watch}
/>
</Modal>
<div className="w-1/12">
<Modal
classNames="w-full xl:w-1/2"
classNames="w-full xl:w-1/2"
onConfirm={() => {
if (!selectedCompanies || !currentCustomer) {
toast.error("Invalid credentials");
@@ -6,18 +6,18 @@ import {
useCustomersQuery,
useDeleteCustomerQuery,
} from "@/hooks/queries";
import { useTableSectionRowAnimations } from "@/hooks/useTableSectionRowAnimations";
import { TAssignCustomerToCompanyCredentials, TCustomer } from "@/lib/api";
import { apiDefaultParams } from "@/constants/Api_Params";
import { deleteEmptyKeys } from "@/utils/shared";
import { useIsMutating } from "@tanstack/react-query";
import { usePathname, useRouter } from "next/navigation";
import { useState } from "react";
import { usePathname, useRouter, useSearchParams } from "next/navigation";
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { useForm } from "react-hook-form";
const useCustomerListPresenter = () => {
const [isAssignCompanyModalOpen, setIsAssignCompanyModalOpen] =
useState(false);
const [isFilterModalOpen, setIsFilterModalOpen] = useState(false);
const [selectedCompanies, setSelectedCompanies] = useState<
TAssignCustomerToCompanyCredentials | undefined
>();
@@ -27,6 +27,7 @@ const useCustomerListPresenter = () => {
const [isModalOpen, setIsModalOpen] = useState(false);
const pathName = usePathname();
const searchParams = useSearchParams();
const [params, setParams] = useState<Record<string, any>>({
...apiDefaultParams,
});
@@ -41,7 +42,31 @@ const useCustomerListPresenter = () => {
setValue: setFilterValue,
} = useForm();
const tableSectionRef = useRef<HTMLDivElement>(null);
const skeletonTbodyRef = useRef<HTMLTableSectionElement>(null);
const dataTbodyRef = useRef<HTMLTableSectionElement>(null);
useEffect(() => {
const newParams: Record<string, any> = {
...apiDefaultParams,
offset: 0,
limit: 10,
};
for (const [key, value] of searchParams.entries()) {
newParams[key] = value;
}
setParams(newParams);
filterFormReset(newParams);
}, [searchParams, filterFormReset]);
const { data, isPending } = useCustomersQuery(params);
useTableSectionRowAnimations(isPending, data, {
tableSectionRef,
skeletonTbodyRef,
dataTbodyRef,
});
const assignCompanyCompaniesQuery = useCompanyFullList({
enabled: isAssignCompanyModalOpen,
});
@@ -55,15 +80,17 @@ const useCustomerListPresenter = () => {
},
);
const search = (data: any) => {
const newParams = { ...params, ...data, offset: 0 };
const search = (formData: any) => {
const newParams = { ...params, ...formData, offset: 0 };
const cleanedParams = deleteEmptyKeys(newParams);
setParams(cleanedParams);
const queryString = new URLSearchParams(cleanedParams).toString();
router.push(`${pathName}?${queryString}`);
setIsFilterModalOpen(false);
};
const clearAllFilters = useCallback(() => {
router.push(pathName);
}, [pathName, router]);
const columns = [
"row",
"full name",
@@ -85,6 +112,26 @@ const useCustomerListPresenter = () => {
const shouldShowPagination =
!isPending && (data?.meta?.pages ?? 1) * (data?.meta?.limit ?? 10) > 10;
const pagination = useMemo(
() => ({
currentPage: data?.meta?.page ? data.meta.page - 1 : 0,
totalPages: data?.meta?.pages ?? 1,
}),
[data?.meta?.page, data?.meta?.pages],
);
const handlePaginationChange = useCallback(
(e: { selected: number }) => {
const limit = data?.meta?.limit ?? 10;
setParams((currentParams) => ({
...currentParams,
offset: e.selected * limit,
limit,
}));
},
[data?.meta?.limit],
);
return {
allCustomers: customers,
metadata: data?.meta,
@@ -102,8 +149,6 @@ const useCustomerListPresenter = () => {
router,
isModalOpen,
setIsModalOpen,
isFilterModalOpen,
setIsFilterModalOpen,
filterRegister,
handleFilterSubmit,
search,
@@ -113,6 +158,12 @@ const useCustomerListPresenter = () => {
setParams,
assignCompanyCompaniesQuery,
shouldShowPagination,
clearAllFilters,
tableSectionRef,
skeletonTbodyRef,
dataTbodyRef,
pagination,
handlePaginationChange,
};
};
@@ -0,0 +1,92 @@
"use client";
import InputGroup from "@/components/FormElements/InputGroup";
import { Button } from "@/components/ui-elements/button";
import InlineListFiltersPanel from "@/components/ui/InlineListFiltersPanel";
import {
FieldValues,
UseFormHandleSubmit,
UseFormRegister,
UseFormWatch,
} from "react-hook-form";
import { useMemo } from "react";
interface FeedbackListFiltersProps {
filterRegister: UseFormRegister<FieldValues>;
isMutating: number;
handleFilterSubmit: UseFormHandleSubmit<FieldValues>;
search: (data: any) => void;
watch: UseFormWatch<FieldValues>;
onClearAll: () => void;
}
function countActive(values: Record<string, unknown>): number {
const q = values.q;
return typeof q === "string" && q.trim() ? 1 : 0;
}
export default function FeedbackListFilters({
filterRegister,
isMutating,
handleFilterSubmit,
search,
watch,
onClearAll,
}: FeedbackListFiltersProps) {
const watched = watch();
const activeCount = useMemo(
() => countActive(watched as Record<string, unknown>),
[watched],
);
return (
<InlineListFiltersPanel
title="Filter feedback"
description="Keyword search across this list — inline, no popup."
activeFilterCount={activeCount}
expandAriaLabel="Expand feedback filters"
collapseAriaLabel="Collapse feedback filters"
panelToggleDataCy="feedback-filter-panel-toggle"
>
<form
className="space-y-5 px-4 pb-6 pt-1 sm:px-6"
onSubmit={handleFilterSubmit(search)}
data-cy="feedback-filter-form"
>
<div className="max-w-xl">
<InputGroup
{...filterRegister("q")}
name="q"
label="Keyword search"
placeholder="Search feedback…"
type="search"
dataCy="feedback-filter-q-input"
/>
</div>
<div className="flex flex-col gap-2 sm:flex-row sm:items-center">
<Button
type="submit"
size="small"
className="min-h-[52px] min-w-[140px] rounded-2xl bg-gradient-to-r from-primary via-primary to-violet-600 px-8 text-sm font-semibold transition hover:opacity-[0.97]"
data-cy="feedback-filter-submit-button"
label={
isMutating ? (
<span className="inline-flex h-5 w-5 animate-spin rounded-full border-2 border-white/30 border-t-white" />
) : (
<span>Apply filters</span>
)
}
/>
<button
type="button"
data-cy="feedback-filter-reset-button"
onClick={onClearAll}
className="min-h-[52px] rounded-2xl border border-stroke/70 bg-white/80 px-6 text-sm font-semibold text-dark transition hover:border-primary/40 hover:text-primary dark:border-dark-3 dark:bg-dark-3/60 dark:text-white dark:hover:bg-white/[0.06]"
>
Reset all
</button>
</div>
</form>
</InlineListFiltersPanel>
);
}
+85 -67
View File
@@ -15,6 +15,7 @@ import {
import TableSkeleton from "@/components/ui/TableSkeleton";
import { cn } from "@/lib/utils";
import { capitalize, getPaginatedRowNumber, unixToDate } from "@/utils/shared";
import FeedbackListFilters from "./FeedbackListFilters";
import useFeedbackTablePresenter from "./useFeedbackTablePresenter";
interface IProps {
@@ -30,8 +31,18 @@ const FeedbackTable = ({ id, paramKey }: IProps) => {
router,
getShowcaseSectionTitle,
metadata,
setParams,
shouldShowPagination,
filterRegister,
handleFilterSubmit,
search,
watch,
clearAllFilters,
tableSectionRef,
skeletonTbodyRef,
dataTbodyRef,
pagination,
handlePaginationChange,
isMutating,
} = useFeedbackTablePresenter({ id, paramKey });
return (
@@ -42,76 +53,83 @@ const FeedbackTable = ({ id, paramKey }: IProps) => {
}
className="flex flex-col rounded-[10px] bg-white px-7.5 pb-4 pt-7.5 capitalize shadow-1 dark:bg-gray-dark dark:shadow-card"
>
<Table>
<TableHeader>
<TableRow>
{columns.map((column) => (
<TableHead className="capitalize" key={column} colSpan={100}>
{column}
</TableHead>
))}
</TableRow>
</TableHeader>
{isLoading && <TableSkeleton column={columns.length} />}
<TableBody>
<EmptyListWrapper
list={feedback ?? []}
columns={columns}
emptyMessage="No feedback were found"
isLoading={isLoading}
>
{(feedback ?? []).map((item, index) => (
<TableRow
key={item.id}
className="odd:bg-gray-2 dark:odd:bg-gray-7"
<FeedbackListFilters
filterRegister={filterRegister}
handleFilterSubmit={handleFilterSubmit}
isMutating={isMutating as number}
search={search}
watch={watch}
onClearAll={clearAllFilters}
/>
<div ref={tableSectionRef}>
<Table>
<TableHeader>
<TableRow>
{columns.map((column) => (
<TableHead className="capitalize" key={column} colSpan={100}>
{column}
</TableHead>
))}
</TableRow>
</TableHeader>
{isLoading ? (
<TableSkeleton ref={skeletonTbodyRef} column={columns.length} />
) : (
<TableBody ref={dataTbodyRef}>
<EmptyListWrapper
list={feedback ?? []}
columns={columns}
emptyMessage="No feedback were found"
isLoading={false}
>
<TableCell className="text-start" colSpan={100}>
{getPaginatedRowNumber({
index,
page: metadata?.page,
limit: metadata?.limit,
})}
</TableCell>
<TableCell className="uppercase" colSpan={100}>
{item.feedback_type}
</TableCell>
<TableCell className="uppercase" colSpan={100}>
{unixToDate({ unix: item.updated_at, hasTime: true })}
</TableCell>
<TableCell className="uppercase" colSpan={100}>
{unixToDate({ unix: item.created_at, hasTime: true })}
</TableCell>
<TableCell
className={cn(`text flex gap-2 capitalize`)}
colSpan={100}
>
<button
className="text-gray-40 rounded-full bg-gray-dark bg-opacity-5 hover:text-green-dark dark:bg-gray dark:bg-opacity-5"
onClick={() => {
router.push(`${pathName}/${item.id}`);
}}
{(feedback ?? []).map((item, index) => (
<TableRow
key={item.id}
className="odd:bg-gray-2 dark:odd:bg-gray-7"
>
<ExclamationIcon />
</button>
</TableCell>
</TableRow>
))}
</EmptyListWrapper>
</TableBody>
</Table>
<TableCell className="text-start" colSpan={100}>
{getPaginatedRowNumber({
index,
page: metadata?.page,
limit: metadata?.limit,
})}
</TableCell>
<TableCell className="uppercase" colSpan={100}>
{item.feedback_type}
</TableCell>
<TableCell className="uppercase" colSpan={100}>
{unixToDate({ unix: item.updated_at, hasTime: true })}
</TableCell>
<TableCell className="uppercase" colSpan={100}>
{unixToDate({ unix: item.created_at, hasTime: true })}
</TableCell>
<TableCell
className={cn(`text flex gap-2 capitalize`)}
colSpan={100}
>
<button
type="button"
className="text-gray-40 rounded-full bg-gray-dark bg-opacity-5 hover:text-green-dark dark:bg-gray dark:bg-opacity-5"
onClick={() => {
router.push(`${pathName}/${item.id}`);
}}
>
<ExclamationIcon />
</button>
</TableCell>
</TableRow>
))}
</EmptyListWrapper>
</TableBody>
)}
</Table>
</div>
<IsVisible condition={shouldShowPagination}>
<Pagination
currentPage={metadata?.page ? metadata.page - 1 : 0}
totalPages={metadata?.pages ?? 1}
onPageChange={(e) => {
setParams((currentParams) => ({
...currentParams,
offset: e.selected * (metadata?.limit ?? 10),
limit: metadata?.limit ?? 10,
}));
}}
currentPage={pagination.currentPage}
totalPages={pagination.totalPages}
onPageChange={handlePaginationChange}
/>
</IsVisible>
</ShowcaseSection>
@@ -1,8 +1,12 @@
"use client";
import { apiDefaultParams } from "@/constants/Api_Params";
import { useGetFeedback } from "@/hooks/queries";
import { useTableSectionRowAnimations } from "@/hooks/useTableSectionRowAnimations";
import { deleteEmptyKeys } from "@/utils/shared";
import { useIsMutating } from "@tanstack/react-query";
import { usePathname, useRouter, useSearchParams } from "next/navigation";
import { useEffect, useMemo, useState } from "react";
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { useForm } from "react-hook-form";
interface IProps {
id: string;
@@ -14,6 +18,19 @@ const useFeedbackTablePresenter = ({ id, paramKey }: IProps) => {
const searchParams = useSearchParams();
const router = useRouter();
const pathName = usePathname();
const isMutating = useIsMutating();
const {
register: filterRegister,
handleSubmit: handleFilterSubmit,
reset: filterFormReset,
watch,
setValue: setFilterValue,
} = useForm();
const tableSectionRef = useRef<HTMLDivElement>(null);
const skeletonTbodyRef = useRef<HTMLTableSectionElement>(null);
const dataTbodyRef = useRef<HTMLTableSectionElement>(null);
useEffect(() => {
const newParams: Record<string, unknown> = {
@@ -25,7 +42,8 @@ const useFeedbackTablePresenter = ({ id, paramKey }: IProps) => {
newParams[key] = value;
}
setParams(newParams);
}, [searchParams]);
filterFormReset(newParams as any);
}, [searchParams, filterFormReset]);
const queryParams = useMemo(
() => ({ ...params, [paramKey]: id }),
@@ -33,6 +51,13 @@ const useFeedbackTablePresenter = ({ id, paramKey }: IProps) => {
);
const { data, isLoading } = useGetFeedback(queryParams);
useTableSectionRowAnimations(isLoading, data, {
tableSectionRef,
skeletonTbodyRef,
dataTbodyRef,
});
const columns = [
"row",
"feedback type",
@@ -45,6 +70,39 @@ const useFeedbackTablePresenter = ({ id, paramKey }: IProps) => {
!isLoading &&
(data?.meta?.pages ?? 1) * (data?.meta?.limit ?? 10) > 10;
const pagination = useMemo(
() => ({
currentPage: data?.meta?.page ? data.meta.page - 1 : 0,
totalPages: data?.meta?.pages ?? 1,
}),
[data?.meta?.page, data?.meta?.pages],
);
const handlePaginationChange = useCallback(
(e: { selected: number }) => {
const limit = data?.meta?.limit ?? 10;
setParams((currentParams) => ({
...currentParams,
offset: e.selected * limit,
limit,
}));
},
[data?.meta?.limit],
);
const search = (formData: any) => {
const next = { ...params, ...formData, offset: 0 };
const cleaned = deleteEmptyKeys(next);
const queryString = new URLSearchParams(
cleaned as Record<string, string>,
).toString();
router.push(`${pathName}?${queryString}`);
};
const clearAllFilters = useCallback(() => {
router.push(pathName);
}, [pathName, router]);
const getShowcaseSectionTitle = () => {
if (paramKey === "tender_id") {
const title = data?.data?.[0]?.tender?.title;
@@ -62,6 +120,18 @@ const useFeedbackTablePresenter = ({ id, paramKey }: IProps) => {
metadata: data?.meta,
setParams,
shouldShowPagination,
filterRegister,
handleFilterSubmit,
search,
watch,
setFilterValue,
clearAllFilters,
tableSectionRef,
skeletonTbodyRef,
dataTbodyRef,
pagination,
handlePaginationChange,
isMutating,
};
};
@@ -1,5 +1,9 @@
"use client";
import InputGroup from "@/components/FormElements/InputGroup";
import { Select } from "@/components/FormElements/select";
import { Button } from "@/components/ui-elements/button";
import InlineListFiltersPanel from "@/components/ui/InlineListFiltersPanel";
import { INQUIRY_STATUS } from "@/constants/enums";
import {
FieldValues,
@@ -8,15 +12,16 @@ import {
UseFormSetValue,
UseFormWatch,
} from "react-hook-form";
import { useMemo } from "react";
interface InquiriesListFiltersProps {
filterRegister: UseFormRegister<FieldValues>;
setIsFilterModalOpen: (isOpen: boolean) => void;
isMutating: number;
handleFilterSubmit: UseFormHandleSubmit<FieldValues>;
search: (data: any) => void;
watch: UseFormWatch<FieldValues>;
setValue: UseFormSetValue<any>;
onClearAll: () => void;
}
const statusOptions = Object.values(INQUIRY_STATUS).map((status) => ({
@@ -24,58 +29,97 @@ const statusOptions = Object.values(INQUIRY_STATUS).map((status) => ({
value: status,
}));
function countActive(values: Record<string, unknown>): number {
let n = 0;
const q = values.q;
if (typeof q === "string" && q.trim()) n++;
const status = values.status;
if (
status !== undefined &&
status !== null &&
status !== "" &&
String(status).trim()
) {
n++;
}
return n;
}
const InquiriesListFilters = ({
filterRegister,
setIsFilterModalOpen,
isMutating,
handleFilterSubmit,
search,
setValue,
watch,
onClearAll,
}: InquiriesListFiltersProps) => {
return (
<form
className="grid !min-w-full grid-cols-1 gap-4 xl:grid-cols-2"
onSubmit={handleFilterSubmit(search)}
>
<InputGroup
{...filterRegister("q")}
name="q"
label="search"
placeholder="Search"
type="search"
/>
<Select
{...filterRegister("status")}
name="status"
label="status"
placeholder="Select Status"
items={statusOptions}
clearable
value={watch("status")}
onClear={() => setValue("status", "")}
/>
const watched = watch();
const activeCount = useMemo(
() => countActive(watched as Record<string, unknown>),
[watched],
);
<div className="flex gap-4">
<button
type="submit"
className="mt-6 flex w-fit justify-center rounded-lg bg-primary p-[13px] font-medium capitalize text-white hover:bg-opacity-90"
>
{isMutating ? (
<div className="h-5 w-5 animate-spin rounded-full border-b-2 border-gray-1"></div>
) : (
<span>Search</span>
)}
</button>
<button
type="button"
className="mt-6 flex w-fit justify-center rounded-lg bg-error p-[13px] font-medium capitalize text-white hover:bg-opacity-90"
onClick={() => setIsFilterModalOpen(false)}
>
Close
</button>
</div>
</form>
return (
<InlineListFiltersPanel
title="Filter inquiries"
description="Search or filter by workflow status — inline, no popup."
activeFilterCount={activeCount}
expandAriaLabel="Expand inquiry filters"
collapseAriaLabel="Collapse inquiry filters"
panelToggleDataCy="inquiries-filter-panel-toggle"
>
<form
className="space-y-5 px-4 pb-6 pt-1 sm:px-6"
onSubmit={handleFilterSubmit(search)}
data-cy="inquiries-filter-form"
>
<div className="grid gap-4 sm:grid-cols-2">
<InputGroup
{...filterRegister("q")}
name="q"
label="Keyword search"
placeholder="Search"
type="search"
dataCy="inquiries-filter-q-input"
/>
<Select
{...filterRegister("status")}
name="status"
label="Status"
placeholder="Select status"
items={statusOptions}
clearable
value={watch("status")}
onClear={() => setValue("status", "")}
dataCy="inquiries-filter-status-select"
/>
</div>
<div className="flex flex-col gap-2 sm:flex-row sm:items-center">
<Button
type="submit"
size="small"
className="min-h-[52px] min-w-[140px] rounded-2xl bg-gradient-to-r from-primary via-primary to-violet-600 px-8 text-sm font-semibold transition hover:opacity-[0.97]"
data-cy="inquiries-filter-submit-button"
label={
isMutating ? (
<span className="inline-flex h-5 w-5 animate-spin rounded-full border-2 border-white/30 border-t-white" />
) : (
<span>Apply filters</span>
)
}
/>
<button
type="button"
data-cy="inquiries-filter-reset-button"
onClick={onClearAll}
className="min-h-[52px] rounded-2xl border border-stroke/70 bg-white/80 px-6 text-sm font-semibold text-dark transition hover:border-primary/40 hover:text-primary dark:border-dark-3 dark:bg-dark-3/60 dark:text-white dark:hover:bg-white/[0.06]"
>
Reset all
</button>
</div>
</form>
</InlineListFiltersPanel>
);
};
+112 -120
View File
@@ -34,8 +34,6 @@ const InquiriesTable = () => {
columns,
inquiries,
allInquiries,
isFilterModalOpen,
setIsFilterModalOpen,
isPending,
currentInquiry,
isDeleteModalOpen,
@@ -51,130 +49,117 @@ const InquiriesTable = () => {
watch,
setFilterValue,
isMutating,
setParams,
shouldShowPagination,
clearAllFilters,
tableSectionRef,
skeletonTbodyRef,
dataTbodyRef,
pagination,
handlePaginationChange,
} = useInquiriesListPresenter();
return (
<ListWrapper>
<ListHeader
onFilter={() => setIsFilterModalOpen(true)}
hasCreate={false}
<ListHeader hasFilter={false} hasCreate={false} />
<InquiriesListFilters
setValue={setFilterValue}
filterRegister={filterRegister}
handleFilterSubmit={handleFilterSubmit}
isMutating={isMutating as number}
search={search}
watch={watch}
onClearAll={clearAllFilters}
/>
<Table>
<TableHeader>
<TableRow className="border-none uppercase [&>th]:text-start">
{columns.map((column: string) => (
<TableHead key={column} colSpan={100}>
{column}
</TableHead>
))}
</TableRow>
</TableHeader>
{isPending && <TableSkeleton column={columns.length} />}
<TableBody>
<EmptyListWrapper
columns={columns}
list={inquiries ?? []}
emptyMessage="No Inquiries were found"
isLoading={isPending}
>
{inquiries?.map((inquiry: TInquiries, index: number) => (
<TableRow key={inquiry?.id ?? index}>
<TableCell className="text-start" colSpan={100}>
{getPaginatedRowNumber({
index,
page: allInquiries?.meta?.page,
limit: allInquiries?.meta?.limit,
})}
</TableCell>
<TableCell className="text-start" colSpan={100}>
{inquiry?.company_name}
</TableCell>
<TableCell className="text-start" colSpan={100}>
{inquiry?.full_name}
</TableCell>
<TableCell className="text-start" colSpan={100}>
{formatPhoneNumber(inquiry?.phone_number)}
</TableCell>
<TableCell className="text-start" colSpan={100}>
<Status status={inquiry?.status ?? "pending"}>
{inquiry?.status}
</Status>
</TableCell>
<TableCell className="text-start" colSpan={100}>
{unixToDate({ unix: inquiry.created_at, hasTime: true })}
</TableCell>
<TableCell className="text-start xl:pr-7.5">
<div className="flex items-center justify-start gap-x-3.5">
<button
data-tooltip-id="change-status"
data-tooltip-content="Change Status"
data-tooltip-place="top"
className="hover:text-primary"
onClick={() => {
setCurrentInquiry(inquiry);
setIsChangeStatusModalOpen(true);
}}
>
<Tooltip
{..._TooltipDefaultParams({ id: "change-status" })}
/>
<span className="sr-only">Change Status</span>
<PencilSquareIcon />
</button>
<button
data-tooltip-id="delete"
data-tooltip-content="Delete"
data-tooltip-place="top"
className="hover:text-red-500"
onClick={() => {
setCurrentInquiry(inquiry);
setIsDeleteModalOpen(true);
}}
>
<Tooltip {..._TooltipDefaultParams({ id: "delete" })} />
<span className="sr-only">Delete Contact Us </span>
<TrashIcon />
</button>
</div>
</TableCell>
</TableRow>
))}
</EmptyListWrapper>
</TableBody>
</Table>
<Modal
isOpen={isFilterModalOpen}
onClose={() => setIsFilterModalOpen(false)}
classNames="w-full xl:w-1/2"
showButtons={false}
>
<InquiriesListFilters
setValue={setFilterValue}
filterRegister={filterRegister}
handleFilterSubmit={handleFilterSubmit}
isMutating={isMutating as number}
search={search}
setIsFilterModalOpen={setIsFilterModalOpen}
watch={watch}
/>
</Modal>
<IsVisible condition={shouldShowPagination}>
<Pagination
currentPage={
allInquiries?.meta?.page ? allInquiries?.meta?.page - 1 : 0
}
totalPages={allInquiries?.meta?.pages ?? 1}
onPageChange={(e) => {
setParams((currentParams) => ({
...currentParams,
offset: e.selected * (allInquiries?.meta?.limit ?? 10),
limit: allInquiries?.meta?.limit ?? 10,
}));
}}
/>
</IsVisible>
<div ref={tableSectionRef}>
<Table>
<TableHeader>
<TableRow className="border-none uppercase [&>th]:text-start">
{columns.map((column: string) => (
<TableHead key={column} colSpan={100}>
{column}
</TableHead>
))}
</TableRow>
</TableHeader>
{isPending ? (
<TableSkeleton ref={skeletonTbodyRef} column={columns.length} />
) : (
<TableBody ref={dataTbodyRef}>
<EmptyListWrapper
columns={columns}
list={inquiries ?? []}
emptyMessage="No Inquiries were found"
isLoading={false}
>
{inquiries?.map((inquiry: TInquiries, index: number) => (
<TableRow key={inquiry?.id ?? index}>
<TableCell className="text-start" colSpan={100}>
{getPaginatedRowNumber({
index,
page: allInquiries?.meta?.page,
limit: allInquiries?.meta?.limit,
})}
</TableCell>
<TableCell className="text-start" colSpan={100}>
{inquiry?.company_name}
</TableCell>
<TableCell className="text-start" colSpan={100}>
{inquiry?.full_name}
</TableCell>
<TableCell className="text-start" colSpan={100}>
{formatPhoneNumber(inquiry?.phone_number)}
</TableCell>
<TableCell className="text-start" colSpan={100}>
<Status status={inquiry?.status ?? "pending"}>
{inquiry?.status}
</Status>
</TableCell>
<TableCell className="text-start" colSpan={100}>
{unixToDate({ unix: inquiry.created_at, hasTime: true })}
</TableCell>
<TableCell className="text-start xl:pr-7.5">
<div className="flex items-center justify-start gap-x-3.5">
<button
type="button"
data-tooltip-id="change-status"
data-tooltip-content="Change Status"
data-tooltip-place="top"
className="hover:text-primary"
onClick={() => {
setCurrentInquiry(inquiry);
setIsChangeStatusModalOpen(true);
}}
>
<Tooltip
{..._TooltipDefaultParams({ id: "change-status" })}
/>
<span className="sr-only">Change Status</span>
<PencilSquareIcon />
</button>
<button
type="button"
data-tooltip-id="delete"
data-tooltip-content="Delete"
data-tooltip-place="top"
className="hover:text-red-500"
onClick={() => {
setCurrentInquiry(inquiry);
setIsDeleteModalOpen(true);
}}
>
<Tooltip {..._TooltipDefaultParams({ id: "delete" })} />
<span className="sr-only">Delete Contact Us </span>
<TrashIcon />
</button>
</div>
</TableCell>
</TableRow>
))}
</EmptyListWrapper>
</TableBody>
)}
</Table>
</div>
<Modal
isOpen={isChangeStatusModalOpen}
onClose={() => setIsChangeStatusModalOpen(false)}
@@ -192,6 +177,13 @@ const InquiriesTable = () => {
currentStatus={currentInquiry?.status ?? "pending"}
/>
</Modal>
<IsVisible condition={shouldShowPagination}>
<Pagination
currentPage={pagination.currentPage}
totalPages={pagination.totalPages}
onPageChange={handlePaginationChange}
/>
</IsVisible>
{isDeleteModalOpen && (
<ConfirmationModal
isOpen={isDeleteModalOpen}
@@ -4,15 +4,15 @@ import {
useDeleteInquiry,
useGetInquiries,
} from "@/hooks/queries/useInquiryQueries";
import { useTableSectionRowAnimations } from "@/hooks/useTableSectionRowAnimations";
import { TInquiries } from "@/lib/api";
import { deleteEmptyKeys } from "@/utils/shared";
import { useIsMutating } from "@tanstack/react-query";
import { usePathname, useRouter, useSearchParams } from "next/navigation";
import { useEffect, useState } from "react";
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { useForm } from "react-hook-form";
const useInquiriesListPresenter = () => {
const [isFilterModalOpen, setIsFilterModalOpen] = useState(false);
const [isDeleteModalOpen, setIsDeleteModalOpen] = useState(false);
const [isChangeStatusModalOpen, setIsChangeStatusModalOpen] = useState(false);
const [currentInquiry, setCurrentInquiry] = useState<TInquiries | null>(null);
@@ -39,6 +39,10 @@ const useInquiriesListPresenter = () => {
setValue: setFilterValue,
} = useForm();
const tableSectionRef = useRef<HTMLDivElement>(null);
const skeletonTbodyRef = useRef<HTMLTableSectionElement>(null);
const dataTbodyRef = useRef<HTMLTableSectionElement>(null);
useEffect(() => {
const newParams: Record<string, any> = {
offset: 0,
@@ -52,30 +56,61 @@ const useInquiriesListPresenter = () => {
}, [searchParams, filterFormReset]);
const { data, isPending } = useGetInquiries(params);
useTableSectionRowAnimations(isPending, data, {
tableSectionRef,
skeletonTbodyRef,
dataTbodyRef,
});
const shouldShowPagination =
!isPending && (data?.meta?.pages ?? 1) * (data?.meta?.limit ?? 10) > 10;
const pagination = useMemo(
() => ({
currentPage: data?.meta?.page ? data.meta.page - 1 : 0,
totalPages: data?.meta?.pages ?? 1,
}),
[data?.meta?.page, data?.meta?.pages],
);
const handlePaginationChange = useCallback(
(e: { selected: number }) => {
const limit = data?.meta?.limit ?? 10;
setParams((currentParams) => ({
...currentParams,
offset: e.selected * limit,
limit,
}));
},
[data?.meta?.limit],
);
const { mutate } = useDeleteInquiry(() => setIsDeleteModalOpen(false));
const { mutate: changeStatus } =
useChangeInquiryStatus(() => setIsChangeStatusModalOpen(false));
const search = (data: any) => {
const newParams = { ...params, ...data, offset: 0 };
const search = (formData: any) => {
const newParams = { ...params, ...formData, offset: 0 };
const cleanedParams = deleteEmptyKeys(newParams);
const queryString = new URLSearchParams(cleanedParams).toString();
router.push(`${pathname}?${queryString}`);
setIsFilterModalOpen(false);
};
const clearAllFilters = useCallback(() => {
router.push(pathname);
}, [pathname, router]);
const deleteInquiry = () => {
if (!currentInquiry?.id) return;
mutate(currentInquiry.id);
};
const handleChangeStatus = (data: any) => {
const handleChangeStatus = (payload: any) => {
if (!currentInquiry?.id) return;
changeStatus({
id: currentInquiry.id,
credentials: data,
credentials: payload,
});
};
@@ -83,8 +118,6 @@ const useInquiriesListPresenter = () => {
columns,
inquiries: data?.data.inquiries,
allInquiries: data,
isFilterModalOpen,
setIsFilterModalOpen,
isPending,
isDeleteModalOpen,
setIsDeleteModalOpen,
@@ -103,6 +136,12 @@ const useInquiriesListPresenter = () => {
isMutating,
setParams,
shouldShowPagination,
clearAllFilters,
tableSectionRef,
skeletonTbodyRef,
dataTbodyRef,
pagination,
handlePaginationChange,
};
};
@@ -1,5 +1,9 @@
"use client";
import InputGroup from "@/components/FormElements/InputGroup";
import { Select } from "@/components/FormElements/select";
import { Button } from "@/components/ui-elements/button";
import InlineListFiltersPanel from "@/components/ui/InlineListFiltersPanel";
import {
FieldValues,
UseFormHandleSubmit,
@@ -7,66 +11,111 @@ import {
UseFormSetValue,
UseFormWatch,
} from "react-hook-form";
import { useMemo } from "react";
interface NotificationHistoryListFiltersProps {
filterRegister: UseFormRegister<FieldValues>;
setIsFilterModalOpen: (isOpen: boolean) => void;
isMutating: number;
handleFilterSubmit: UseFormHandleSubmit<FieldValues>;
search: (data: any) => void;
watch: UseFormWatch<FieldValues>;
setValue: UseFormSetValue<any>;
onClearAll: () => void;
}
function countActive(values: Record<string, unknown>): number {
let n = 0;
const seen = values.seen;
if (
seen !== undefined &&
seen !== null &&
seen !== "" &&
String(seen).trim()
) {
n++;
}
const searchVal = values.search;
if (typeof searchVal === "string" && searchVal.trim()) n++;
return n;
}
const NotificationHistoryListFilters = ({
filterRegister,
setIsFilterModalOpen,
isMutating,
handleFilterSubmit,
search,
watch,
setValue,
onClearAll,
}: NotificationHistoryListFiltersProps) => {
return (
<form
className="grid !min-w-full grid-cols-1 gap-4 xl:grid-cols-2"
onSubmit={handleFilterSubmit(search)}
>
<Select
{...filterRegister("seen")}
items={[
{ label: "Seen", value: "true" },
{ label: "Not seen", value: "false" },
]}
label="Seen"
name="seen"
/>
<InputGroup
{...filterRegister("search")}
name="search"
label="search"
placeholder="search"
type="search"
/>
const watched = watch();
const activeCount = useMemo(
() => countActive(watched as Record<string, unknown>),
[watched],
);
<div className="flex gap-4">
<button
type="submit"
className="mt-6 flex w-fit justify-center rounded-lg bg-primary p-[13px] font-medium capitalize text-white hover:bg-opacity-90"
>
{isMutating ? (
<div className="h-5 w-5 animate-spin rounded-full border-b-2 border-gray-1"></div>
) : (
<span>Search</span>
)}
</button>
<button
type="button"
className="mt-6 flex w-fit justify-center rounded-lg bg-error p-[13px] font-medium capitalize text-white hover:bg-opacity-90"
onClick={() => setIsFilterModalOpen(false)}
>
Close
</button>
</div>
</form>
return (
<InlineListFiltersPanel
title="Filter notifications"
description="Seen state and message search — inline, no popup."
activeFilterCount={activeCount}
expandAriaLabel="Expand notification filters"
collapseAriaLabel="Collapse notification filters"
panelToggleDataCy="notification-history-filter-panel-toggle"
>
<form
className="space-y-5 px-4 pb-6 pt-1 sm:px-6"
onSubmit={handleFilterSubmit(search)}
data-cy="notification-history-filter-form"
>
<div className="grid gap-4 sm:grid-cols-2">
<Select
{...filterRegister("seen")}
items={[
{ label: "Seen", value: "true" },
{ label: "Not seen", value: "false" },
]}
label="Seen"
name="seen"
placeholder="Any"
clearable
value={watch("seen")}
onClear={() => setValue("seen", undefined)}
/>
<InputGroup
{...filterRegister("search")}
name="search"
label="Search"
placeholder="Search"
type="search"
dataCy="notification-history-filter-search-input"
/>
</div>
<div className="flex flex-col gap-2 sm:flex-row sm:items-center">
<Button
type="submit"
size="small"
className="min-h-[52px] min-w-[140px] rounded-2xl bg-gradient-to-r from-primary via-primary to-violet-600 px-8 text-sm font-semibold transition hover:opacity-[0.97]"
data-cy="notification-history-filter-submit-button"
label={
isMutating ? (
<span className="inline-flex h-5 w-5 animate-spin rounded-full border-2 border-white/30 border-t-white" />
) : (
<span>Apply filters</span>
)
}
/>
<button
type="button"
data-cy="notification-history-filter-reset-button"
onClick={onClearAll}
className="min-h-[52px] rounded-2xl border border-stroke/70 bg-white/80 px-6 text-sm font-semibold text-dark transition hover:border-primary/40 hover:text-primary dark:border-dark-3 dark:bg-dark-3/60 dark:text-white dark:hover:bg-white/[0.06]"
>
Reset all
</button>
</div>
</form>
</InlineListFiltersPanel>
);
};
@@ -24,7 +24,6 @@ import { Tooltip } from "react-tooltip";
import Boolean from "@/components/ui/Boolean";
import ListHeader from "@/components/ui/ListHeader";
import ListWrapper from "@/components/ui/ListWrapper";
import Modal from "@/components/ui/modal";
import NotificationHistoryListFilters from "./NotificationHistoryListFilters";
import useNotificationHistoryTablePresenter from "./useNotificationHistoryTablePresenter";
@@ -36,130 +35,119 @@ const NotificationHistoryTable = () => {
notificationHistory,
pathName,
router,
isFilterModalOpen,
setIsFilterModalOpen,
setParams,
handleSubmit,
register,
handleFilterSubmit,
filterRegister,
setValue,
watch,
search,
isMutating,
shouldShowPagination,
clearAllFilters,
tableSectionRef,
skeletonTbodyRef,
dataTbodyRef,
pagination,
handlePaginationChange,
} = useNotificationHistoryTablePresenter();
return (
<>
<ListWrapper>
<ListHeader
createButtonText="Create notification"
onFilter={() => setIsFilterModalOpen(true)}
<ListHeader hasFilter={false} createButtonText="Create notification" />
<NotificationHistoryListFilters
filterRegister={filterRegister}
handleFilterSubmit={handleFilterSubmit}
isMutating={isMutating as number}
watch={watch}
setValue={setValue}
search={search}
onClearAll={clearAllFilters}
/>
<Table>
<TableHeader>
<TableRow>
{columns.map((column) => (
<TableHead key={column}>
{column}
</TableHead>
))}
</TableRow>
</TableHeader>
{isLoading && (
<TableSkeleton column={columns.length} cellColSpan={1} />
)}
<TableBody>
<EmptyListWrapper
list={notificationHistory ?? []}
columns={columns}
emptyMessage="No notifications were found"
>
{(notificationHistory ?? []).map((item, index) => (
<TableRow key={item.id}>
<TableCell>
{getPaginatedRowNumber({
index,
page: metadata?.page,
limit: metadata?.limit,
})}
</TableCell>
<TableCell>{item.title}</TableCell>
<TableCell>{item.event_type}</TableCell>
<TableCell>
{unixToDate({
hasTime: true,
unix: item.created_at,
})}
</TableCell>
<TableCell>
{item.recipient?.full_name}
</TableCell>
<TableCell className="truncate">
{truncateString(item.message, 20, 20)}
</TableCell>
<TableCell className="capitalize">
{item.priority}
</TableCell>
<TableCell className="capitalize">
{item.type}
</TableCell>
<TableCell className="capitalize">
<Boolean value={item.seen} />
</TableCell>
<TableCell>
<Status status={item.status}>{item.status}</Status>
</TableCell>
<TableCell>
<button
data-tooltip-id="details"
data-tooltip-content="Details"
data-tooltip-place="top"
onClick={() => {
router.push(`${pathName}/${item.id}`);
}}
>
<Tooltip
{..._TooltipDefaultParams({
id: "details",
<div ref={tableSectionRef}>
<Table>
<TableHeader>
<TableRow>
{columns.map((column) => (
<TableHead key={column}>{column}</TableHead>
))}
</TableRow>
</TableHeader>
{isLoading ? (
<TableSkeleton
ref={skeletonTbodyRef}
column={columns.length}
cellColSpan={1}
/>
) : (
<TableBody ref={dataTbodyRef}>
<EmptyListWrapper
list={notificationHistory ?? []}
columns={columns}
emptyMessage="No notifications were found"
isLoading={false}
>
{(notificationHistory ?? []).map((item, index) => (
<TableRow key={item.id}>
<TableCell>
{getPaginatedRowNumber({
index,
page: metadata?.page,
limit: metadata?.limit,
})}
/>
<EyeIcon />
</button>
</TableCell>
</TableRow>
))}
</EmptyListWrapper>
</TableBody>
</Table>
</TableCell>
<TableCell>{item.title}</TableCell>
<TableCell>{item.event_type}</TableCell>
<TableCell>
{unixToDate({
hasTime: true,
unix: item.created_at,
})}
</TableCell>
<TableCell>{item.recipient?.full_name}</TableCell>
<TableCell className="truncate">
{truncateString(item.message, 20, 20)}
</TableCell>
<TableCell className="capitalize">{item.priority}</TableCell>
<TableCell className="capitalize">{item.type}</TableCell>
<TableCell className="capitalize">
<Boolean value={item.seen} />
</TableCell>
<TableCell>
<Status status={item.status}>{item.status}</Status>
</TableCell>
<TableCell>
<button
type="button"
data-tooltip-id="details"
data-tooltip-content="Details"
data-tooltip-place="top"
onClick={() => {
router.push(`${pathName}/${item.id}`);
}}
>
<Tooltip
{..._TooltipDefaultParams({
id: "details",
})}
/>
<EyeIcon />
</button>
</TableCell>
</TableRow>
))}
</EmptyListWrapper>
</TableBody>
)}
</Table>
</div>
<IsVisible condition={shouldShowPagination}>
<Pagination
currentPage={metadata?.page ? metadata?.page - 1 : 0}
totalPages={metadata?.pages ?? 1}
onPageChange={(e) => {
setParams((currentParams: Record<string, any>) => ({
...currentParams,
offset: e.selected * (metadata?.limit ?? 10),
limit: metadata?.limit ?? 10,
}));
}}
currentPage={pagination.currentPage}
totalPages={pagination.totalPages}
onPageChange={handlePaginationChange}
/>
</IsVisible>
</ListWrapper>
<Modal
isOpen={isFilterModalOpen}
onClose={() => setIsFilterModalOpen(false)}
showButtons={false}
>
<NotificationHistoryListFilters
filterRegister={register}
handleFilterSubmit={handleSubmit}
isMutating={isMutating}
watch={watch}
setValue={setValue}
setIsFilterModalOpen={setIsFilterModalOpen}
search={search}
/>
</Modal>
</>
);
};
@@ -1,19 +1,37 @@
"use client";
import { apiDefaultParams } from "@/constants/Api_Params";
import { useGetNotificationHistoryQuery } from "@/hooks/queries/useNotificationQueries";
import { useTableSectionRowAnimations } from "@/hooks/useTableSectionRowAnimations";
import { deleteEmptyKeys } from "@/utils/shared";
import { useIsMutating } from "@tanstack/react-query";
import { usePathname, useRouter } from "next/navigation";
import { useState } from "react";
import { usePathname, useRouter, useSearchParams } from "next/navigation";
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { useForm } from "react-hook-form";
const useNotificationHistoryTablePresenter = () => {
const { register, handleSubmit, watch, setValue } = useForm();
const [isFilterModalOpen, setIsFilterModalOpen] = useState(false);
const {
register: filterRegister,
handleSubmit,
watch,
setValue,
reset: filterFormReset,
} = useForm();
const [params, setParams] = useState<Record<string, any>>({
...apiDefaultParams,
});
const searchParams = useSearchParams();
const tableSectionRef = useRef<HTMLDivElement>(null);
const skeletonTbodyRef = useRef<HTMLTableSectionElement>(null);
const dataTbodyRef = useRef<HTMLTableSectionElement>(null);
const { data, isLoading } = useGetNotificationHistoryQuery(params);
useTableSectionRowAnimations(isLoading, data, {
tableSectionRef,
skeletonTbodyRef,
dataTbodyRef,
});
const shouldShowPagination =
!isLoading && (data?.meta?.pages ?? 1) * (data?.meta?.limit ?? 10) > 10;
const isMutating = useIsMutating();
@@ -32,14 +50,51 @@ const useNotificationHistoryTablePresenter = () => {
];
const pathName = usePathname();
const router = useRouter();
const search = (data: any) => {
const newParams = { ...params, ...data, offset: 0 };
useEffect(() => {
const newParams: Record<string, any> = {
...apiDefaultParams,
offset: 0,
limit: 10,
};
for (const [key, value] of searchParams.entries()) {
newParams[key] = value;
}
setParams(newParams);
filterFormReset(newParams);
}, [searchParams, filterFormReset]);
const pagination = useMemo(
() => ({
currentPage: data?.meta?.page ? data.meta.page - 1 : 0,
totalPages: data?.meta?.pages ?? 1,
}),
[data?.meta?.page, data?.meta?.pages],
);
const handlePaginationChange = useCallback(
(e: { selected: number }) => {
const limit = data?.meta?.limit ?? 10;
setParams((currentParams: Record<string, any>) => ({
...currentParams,
offset: e.selected * limit,
limit,
}));
},
[data?.meta?.limit],
);
const search = (formData: any) => {
const newParams = { ...params, ...formData, offset: 0 };
const cleanedParams = deleteEmptyKeys(newParams);
setParams(cleanedParams);
const queryString = new URLSearchParams(cleanedParams).toString();
router.push(`${pathName}?${queryString}`);
setIsFilterModalOpen(false);
};
const clearAllFilters = useCallback(() => {
router.push(pathName);
}, [pathName, router]);
return {
notificationHistory: data?.data,
metadata: data?.meta,
@@ -48,15 +103,20 @@ const useNotificationHistoryTablePresenter = () => {
router,
pathName,
setParams,
register,
filterRegister,
handleSubmit,
handleFilterSubmit: handleSubmit,
watch,
setValue,
isFilterModalOpen,
setIsFilterModalOpen,
isMutating,
search,
shouldShowPagination,
clearAllFilters,
tableSectionRef,
skeletonTbodyRef,
dataTbodyRef,
pagination,
handlePaginationChange,
};
};
@@ -1,12 +1,14 @@
"use client";
import { ChevronUpIcon, GlobeIcon, SearchIcon } from "@/assets/icons";
import { GlobeIcon, SearchIcon } from "@/assets/icons";
import DatePicker from "@/components/FormElements/DatePicker/DatePicker";
import InputGroup from "@/components/FormElements/InputGroup";
import { Select } from "@/components/FormElements/select";
import { Button } from "@/components/ui-elements/button";
import CollapsibleFilterSection from "@/components/ui/CollapsibleFilterSection";
import InlineListFiltersPanel from "@/components/ui/InlineListFiltersPanel";
import { Countries } from "@/constants/countries";
import { cn } from "@/lib/utils";
import { useMemo } from "react";
import {
Control,
FieldValues,
@@ -15,8 +17,6 @@ import {
UseFormSetValue,
UseFormWatch,
} from "react-hook-form";
import gsap from "gsap";
import { type ReactNode, useLayoutEffect, useMemo, useRef, useState } from "react";
interface TenderListFiltersProps {
filterRegister: UseFormRegister<FieldValues>;
@@ -71,69 +71,6 @@ function countActiveFilters(values: Record<string, unknown>): number {
return n;
}
function CollapsibleFilterCard({
title,
subtitle,
badge,
defaultOpen,
children,
}: {
title: string;
subtitle?: string;
badge?: string;
defaultOpen?: boolean;
children: ReactNode;
}) {
const [open, setOpen] = useState(Boolean(defaultOpen));
return (
<div
className={cn(
"overflow-hidden rounded-2xl border border-stroke/50 bg-white/50 backdrop-blur-md dark:border-white/[0.08] dark:bg-dark-2/45",
open && "ring-1 ring-primary/15 dark:ring-primary/25",
)}
>
<button
type="button"
onClick={() => setOpen((o) => !o)}
className="flex w-full items-center justify-between gap-3 px-4 py-3.5 text-start transition hover:bg-white/60 dark:hover:bg-white/[0.04]"
>
<div className="min-w-0">
<div className="flex flex-wrap items-center gap-2">
<span className="text-sm font-semibold text-dark dark:text-white">{title}</span>
{badge ? (
<span className="rounded-full bg-gradient-to-r from-primary/90 to-violet-500/85 px-2.5 py-0.5 text-[10px] font-bold uppercase tracking-wider text-white">
{badge}
</span>
) : null}
</div>
{subtitle ? (
<p className="mt-0.5 text-xs text-dark-5 dark:text-dark-6">{subtitle}</p>
) : null}
</div>
<ChevronUpIcon
className={cn(
"h-5 w-5 shrink-0 text-primary transition-transform duration-300 dark:text-primary/90",
!open && "rotate-180",
)}
/>
</button>
<div
className={cn(
"grid transition-[grid-template-rows] duration-300 ease-out",
open ? "grid-rows-[1fr]" : "grid-rows-[0fr]",
)}
>
<div className="min-h-0 overflow-hidden">
<div className="border-t border-stroke/40 px-4 pb-5 pt-1 dark:border-white/[0.06]">
{children}
</div>
</div>
</div>
</div>
);
}
const TenderListFilters = ({
filterRegister,
control,
@@ -145,363 +82,198 @@ const TenderListFilters = ({
onClearAll,
}: TenderListFiltersProps) => {
const watched = watch();
const activeCount = useMemo(() => countActiveFilters(watched as Record<string, unknown>), [watched]);
const [panelOpen, setPanelOpen] = useState(false);
/** When true, header stays flush (square bottom) with the panel — keep true until collapse GSAP finishes so radius does not pop early. */
const [headerFlushBottom, setHeaderFlushBottom] = useState(false);
const panelOuterRef = useRef<HTMLDivElement>(null);
const panelInnerRef = useRef<HTMLDivElement>(null);
const chevronRef = useRef<HTMLSpanElement>(null);
const prevPanelOpen = useRef(panelOpen);
/** Initial dimensions without animating (avoids flash + Strict Mode double-invoke issues). */
useLayoutEffect(() => {
const outer = panelOuterRef.current;
const inner = panelInnerRef.current;
const chevron = chevronRef.current;
if (!outer || !inner) return;
gsap.set(outer, { height: panelOpen ? "auto" : 0 });
gsap.set(inner, {
opacity: panelOpen ? 1 : 0,
y: panelOpen ? 0 : -14,
scale: panelOpen ? 1 : 0.995,
});
if (chevron) gsap.set(chevron, { rotation: panelOpen ? 0 : 180, transformOrigin: "50% 50%" });
if (!panelOpen) {
setHeaderFlushBottom(false);
}
// eslint-disable-next-line react-hooks/exhaustive-deps -- run once on mount for initial `panelOpen`
}, []);
useLayoutEffect(() => {
const outer = panelOuterRef.current;
const inner = panelInnerRef.current;
const chevron = chevronRef.current;
if (!outer || !inner) return;
if (prevPanelOpen.current === panelOpen) return;
prevPanelOpen.current = panelOpen;
const reduced =
typeof window !== "undefined" &&
window.matchMedia("(prefers-reduced-motion: reduce)").matches;
gsap.killTweensOf([outer, inner, chevron].filter(Boolean));
if (reduced) {
gsap.set(outer, { height: panelOpen ? "auto" : 0 });
gsap.set(inner, { opacity: panelOpen ? 1 : 0, y: panelOpen ? 0 : -10, scale: 1 });
if (chevron) gsap.set(chevron, { rotation: panelOpen ? 0 : 180 });
setHeaderFlushBottom(panelOpen);
return;
}
const ctx = gsap.context(() => {
if (panelOpen) {
setHeaderFlushBottom(true);
const targetH = inner.scrollHeight;
gsap.set(outer, { height: 0 });
gsap.set(inner, { opacity: 0, y: -36, scale: 0.985 });
const tl = gsap.timeline({ defaults: { ease: "none" } });
tl.to(outer, {
height: targetH,
duration: 0.62,
ease: "power4.out",
onComplete: () => {
gsap.set(outer, { height: "auto" });
},
}).to(
inner,
{
opacity: 1,
y: 0,
scale: 1,
duration: 0.52,
ease: "power3.out",
},
"-=0.5",
);
if (chevron) {
tl.to(
chevron,
{ rotation: 0, duration: 0.48, ease: "back.out(1.35)" },
"-=0.52",
);
}
} else {
const currentH = Math.max(outer.offsetHeight, inner.scrollHeight);
gsap.set(outer, { height: currentH });
const tl = gsap.timeline({
onComplete: () => {
setHeaderFlushBottom(false);
},
});
tl.to(inner, {
opacity: 0,
y: -22,
scale: 0.992,
duration: 0.3,
ease: "power2.in",
})
.to(
outer,
{
height: 0,
duration: 0.48,
ease: "power3.inOut",
},
"-=0.12",
);
if (chevron) {
tl.to(chevron, { rotation: 180, duration: 0.42, ease: "power2.inOut" }, 0);
}
}
}, outer);
return () => ctx.revert();
}, [panelOpen]);
const activeCount = useMemo(
() => countActiveFilters(watched as Record<string, unknown>),
[watched],
);
return (
<div className="relative mb-8">
<div className="relative overflow-hidden rounded-[1.75rem] border border-stroke/60 bg-white dark:border-dark-3 dark:bg-dark-2">
<button
type="button"
onClick={() => setPanelOpen((o) => !o)}
aria-expanded={panelOpen}
aria-label={panelOpen ? "Collapse tender filters" : "Expand tender filters"}
className={cn(
"flex w-full flex-wrap items-start gap-x-3 gap-y-3 px-4 py-4 text-start transition-colors sm:items-center sm:justify-between sm:gap-4 sm:px-6 sm:py-5",
"rounded-[1.65rem] border border-transparent",
"hover:border-stroke/50 hover:bg-white/70 dark:hover:border-white/[0.08] dark:hover:bg-dark-3/50",
"focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary/40 focus-visible:ring-offset-2 focus-visible:ring-offset-white dark:focus-visible:ring-offset-dark-2",
headerFlushBottom &&
"rounded-b-none border-b border-stroke/45 dark:border-white/[0.07]",
)}
>
<div className="flex min-w-0 flex-1 items-start gap-3 sm:items-center">
<span className="mt-0.5 flex h-11 w-11 shrink-0 items-center justify-center rounded-2xl bg-gradient-to-br from-primary to-violet-600 text-white sm:mt-0">
<SearchIcon className="h-5 w-5 opacity-95" />
</span>
<div className="min-w-0">
<span className="block text-lg font-bold tracking-tight text-dark dark:text-white">
Refine tenders
</span>
<p className="mt-0.5 max-w-xl text-sm text-dark-5 dark:text-dark-6">
Search and slice the catalogue inline no popup. Expand sections when you need
precision.
</p>
</div>
</div>
<div className="ml-auto flex shrink-0 items-center gap-2">
{activeCount > 0 ? (
<div className="flex items-center gap-2 rounded-full border border-primary/25 bg-primary/10 px-3 py-1.5 dark:border-primary/35 dark:bg-primary/15">
<span className="text-xs font-semibold uppercase tracking-wide text-primary">
{activeCount} active
</span>
</div>
) : (
<span className="inline-flex items-center rounded-full border border-stroke/50 bg-white/60 px-3 py-1.5 text-xs font-medium text-dark-5 dark:border-white/10 dark:bg-dark-3/80 dark:text-dark-6">
No filters applied
</span>
)}
<span
ref={chevronRef}
className="inline-flex h-5 w-5 shrink-0 text-primary dark:text-primary/90"
aria-hidden
>
<ChevronUpIcon className="h-5 w-5" />
</span>
</div>
</button>
<div
ref={panelOuterRef}
className="overflow-hidden"
aria-hidden={!panelOpen}
>
<div ref={panelInnerRef} className="will-change-transform">
<form
className="space-y-4 px-4 pb-6 pt-1 sm:px-6"
onSubmit={handleFilterSubmit(search)}
>
{/* Hero search */}
<div className="flex flex-col gap-3 lg:flex-row lg:items-end">
<div className="min-h-[52px] flex-1">
<InputGroup
{...filterRegister("q")}
name="q"
label="Keyword search"
type="text"
placeholder="Title, buyer, CPV — cast a wide net…"
icon={<SearchIcon className="text-primary dark:text-primary/90" />}
iconPosition="left"
className="[&_input]:rounded-2xl [&_input]:border-stroke/70 [&_input]:bg-white/90 [&_input]:py-3.5 [&_input]:text-sm dark:[&_input]:border-dark-3 dark:[&_input]:bg-dark-2/80"
/>
</div>
<div className="flex shrink-0 flex-col gap-2 sm:flex-row sm:items-center">
<Button
type="submit"
size="small"
shape="rounded"
className="min-h-[52px] min-w-[140px] rounded-2xl bg-gradient-to-r from-primary via-primary to-violet-600 px-8 text-sm font-semibold transition hover:opacity-[0.97]"
label={
isMutating ? (
<span className="inline-flex h-5 w-5 animate-spin rounded-full border-2 border-white/30 border-t-white" />
) : (
<span>Apply filters</span>
)
}
/>
<button
type="button"
onClick={onClearAll}
className="min-h-[52px] rounded-2xl border border-stroke/70 bg-white/80 px-6 text-sm font-semibold text-dark transition hover:border-primary/40 hover:text-primary dark:border-dark-3 dark:bg-dark-3/60 dark:text-white dark:hover:bg-white/[0.06]"
>
Reset all
</button>
</div>
</div>
<CollapsibleFilterCard
title="Scope & geography"
subtitle="Country pick or comma-separated codes for multi-region searches."
<InlineListFiltersPanel
title="Refine tenders"
activeFilterCount={activeCount}
expandAriaLabel="Expand tender filters"
collapseAriaLabel="Collapse tender filters"
panelToggleDataCy="tenders-filter-panel-toggle"
>
<form
className="space-y-4 px-4 pb-6 pt-1 sm:px-6"
onSubmit={handleFilterSubmit(search)}
>
{/* Hero search */}
<div className="flex flex-col gap-3 lg:flex-row lg:items-end">
<div className="min-h-[52px] flex-1">
<InputGroup
{...filterRegister("q")}
name="q"
label="Keyword search"
type="text"
placeholder="Title, buyer, CPV — cast a wide net…"
icon={
<SearchIcon className="text-primary dark:text-primary/90" />
}
iconPosition="left"
className="[&_input]:rounded-2xl [&_input]:border-stroke/70 [&_input]:bg-white/90 [&_input]:py-3.5 [&_input]:text-sm dark:[&_input]:border-dark-3 dark:[&_input]:bg-dark-2/80"
/>
</div>
<div className="flex shrink-0 flex-col gap-2 sm:flex-row sm:items-center">
<Button
type="submit"
size="small"
className="min-h-[52px] min-w-[140px] rounded-2xl bg-gradient-to-r from-primary via-primary to-violet-600 px-8 text-sm font-semibold transition hover:opacity-[0.97]"
label={
isMutating ? (
<span className="inline-flex h-5 w-5 animate-spin rounded-full border-2 border-white/30 border-t-white" />
) : (
<span>Apply filters</span>
)
}
/>
<button
type="button"
onClick={onClearAll}
className="min-h-[52px] rounded-2xl border border-stroke/70 bg-white/80 px-6 text-sm font-semibold text-dark transition hover:border-primary/40 hover:text-primary dark:border-dark-3 dark:bg-dark-3/60 dark:text-white dark:hover:bg-white/[0.06]"
>
<div className="grid gap-4 sm:grid-cols-2 xl:grid-cols-3">
<Select<FieldValues>
{...filterRegister("country")}
name="country"
label="Country"
items={Countries}
placeholder="Any country"
clearable
prefixIcon={<GlobeIcon />}
value={watch("country")}
onClear={() => setValue("country", "")}
/>
<InputGroup
{...filterRegister("country_codes")}
name="country_codes"
label="Country codes"
type="text"
placeholder="e.g. SWE, DEU"
/>
</div>
</CollapsibleFilterCard>
<CollapsibleFilterCard
title="Notice & classification"
subtitle="TED notice dimensions and CPV-style filters."
>
<div className="grid gap-4 sm:grid-cols-2 xl:grid-cols-3 2xl:grid-cols-4">
<InputGroup
{...filterRegister("title")}
name="title"
label="Title contains"
type="text"
/>
<InputGroup
{...filterRegister("description")}
name="description"
label="Description contains"
type="text"
/>
<InputGroup
{...filterRegister("notice_type")}
name="notice_type"
label="Notice type"
type="text"
/>
<InputGroup
{...filterRegister("notice_types")}
name="notice_types"
label="Notice types (comma-separated)"
type="text"
/>
<InputGroup
{...filterRegister("form_types")}
name="form_types"
label="Form types (comma-separated)"
type="text"
/>
<InputGroup
{...filterRegister("main_classification")}
name="main_classification"
label="Main classification"
type="text"
/>
<InputGroup
{...filterRegister("classifications")}
name="classifications"
label="Classifications (comma-separated)"
type="text"
/>
<InputGroup
{...filterRegister("cpv_codes")}
name="cpv_codes"
label="CPV codes (comma-separated)"
type="text"
/>
</div>
</CollapsibleFilterCard>
<CollapsibleFilterCard
title="Date ranges"
subtitle="Unix-backed ranges — pick start/end; cleared ranges are omitted from the query."
badge="Time travel"
>
<div className="grid gap-5 lg:grid-cols-2">
<div className="rounded-xl border border-stroke/40 bg-white/60 p-3 dark:border-white/[0.06] dark:bg-dark-3/40">
<DatePicker
control={control}
name="created_at_range"
label="Created at"
range
timePicker
clearable
/>
</div>
<div className="rounded-xl border border-stroke/40 bg-white/60 p-3 dark:border-white/[0.06] dark:bg-dark-3/40">
<DatePicker
control={control}
name="tender_deadline_range"
label="Tender deadline"
range
timePicker
clearable
/>
</div>
<div className="rounded-xl border border-stroke/40 bg-white/60 p-3 dark:border-white/[0.06] dark:bg-dark-3/40">
<DatePicker
control={control}
name="publication_date_range"
label="Publication date"
range
timePicker
clearable
/>
</div>
<div className="rounded-xl border border-stroke/40 bg-white/60 p-3 dark:border-white/[0.06] dark:bg-dark-3/40">
<DatePicker
control={control}
name="submission_deadline_range"
label="Submission deadline"
range
timePicker
clearable
/>
</div>
</div>
</CollapsibleFilterCard>
</form>
</div>
Reset all
</button>
</div>
</div>
</div>
<CollapsibleFilterSection
title="Scope & geography"
subtitle="Country pick or comma-separated codes for multi-region searches."
>
<div className="grid gap-4 sm:grid-cols-2 xl:grid-cols-3">
<Select<FieldValues>
{...filterRegister("country")}
name="country"
label="Country"
items={Countries}
placeholder="Any country"
clearable
prefixIcon={<GlobeIcon />}
value={watch("country")}
onClear={() => setValue("country", "")}
/>
<InputGroup
{...filterRegister("country_codes")}
name="country_codes"
label="Country codes"
type="text"
placeholder="e.g. SWE, DEU"
/>
</div>
</CollapsibleFilterSection>
<CollapsibleFilterSection
title="Notice & classification"
subtitle="TED notice dimensions and CPV-style filters."
>
<div className="grid gap-4 sm:grid-cols-2 xl:grid-cols-3 2xl:grid-cols-4">
<InputGroup
{...filterRegister("title")}
name="title"
label="Title contains"
type="text"
/>
<InputGroup
{...filterRegister("description")}
name="description"
label="Description contains"
type="text"
/>
<InputGroup
{...filterRegister("notice_type")}
name="notice_type"
label="Notice type"
type="text"
/>
<InputGroup
{...filterRegister("notice_types")}
name="notice_types"
label="Notice types (comma-separated)"
type="text"
/>
<InputGroup
{...filterRegister("form_types")}
name="form_types"
label="Form types (comma-separated)"
type="text"
/>
<InputGroup
{...filterRegister("main_classification")}
name="main_classification"
label="Main classification"
type="text"
/>
<InputGroup
{...filterRegister("classifications")}
name="classifications"
label="Classifications (comma-separated)"
type="text"
/>
<InputGroup
{...filterRegister("cpv_codes")}
name="cpv_codes"
label="CPV codes (comma-separated)"
type="text"
/>
</div>
</CollapsibleFilterSection>
<CollapsibleFilterSection
title="Date ranges"
subtitle="Unix-backed ranges — pick start/end; cleared ranges are omitted from the query."
badge="Time travel"
>
<div className="grid gap-5 lg:grid-cols-2">
<div className="rounded-xl border border-stroke/40 bg-white/60 p-3 dark:border-white/[0.06] dark:bg-dark-3/40">
<DatePicker
control={control}
name="created_at_range"
label="Created at"
range
timePicker
clearable
portal
/>
</div>
<div className="rounded-xl border border-stroke/40 bg-white/60 p-3 dark:border-white/[0.06] dark:bg-dark-3/40">
<DatePicker
control={control}
name="tender_deadline_range"
label="Tender deadline"
range
timePicker
clearable
portal
/>
</div>
<div className="rounded-xl border border-stroke/40 bg-white/60 p-3 dark:border-white/[0.06] dark:bg-dark-3/40">
<DatePicker
control={control}
name="publication_date_range"
label="Publication date"
range
timePicker
clearable
portal
/>
</div>
<div className="rounded-xl border border-stroke/40 bg-white/60 p-3 dark:border-white/[0.06] dark:bg-dark-3/40">
<DatePicker
control={control}
name="submission_deadline_range"
label="Submission deadline"
range
timePicker
clearable
portal
/>
</div>
</div>
</CollapsibleFilterSection>
</form>
</InlineListFiltersPanel>
);
};
@@ -101,6 +101,37 @@ const buildTenderFilterFormValues = (params: Record<string, any>) => ({
),
});
/** RHF `reset` does not drop fields omitted from the payload — merge empties so URL sync / clear-all match the UI. */
const TENDER_FILTER_FORM_DEFAULTS: Record<string, any> = {
q: "",
title: "",
description: "",
country: "",
country_codes: "",
notice_type: "",
notice_types: "",
form_types: "",
main_classification: "",
classifications: "",
cpv_codes: "",
created_at_range: [],
tender_deadline_range: [],
publication_date_range: [],
submission_deadline_range: [],
};
const mergeTenderFilterFormState = (params: Record<string, any>) => {
const built = buildTenderFilterFormValues(params);
return {
...TENDER_FILTER_FORM_DEFAULTS,
...built,
created_at_range: built.created_at_range ?? [],
tender_deadline_range: built.tender_deadline_range ?? [],
publication_date_range: built.publication_date_range ?? [],
submission_deadline_range: built.submission_deadline_range ?? [],
};
};
const mapDateRangeFilters = (values: Record<string, any>) => {
const mappedValues = { ...values };
@@ -250,7 +281,7 @@ const useTenderListPresenter = () => {
newParams[key] = value;
}
setParams(newParams);
filterFormReset(buildTenderFilterFormValues(newParams));
filterFormReset(mergeTenderFilterFormState(newParams));
}, [searchParams, filterFormReset]);
const { data, error, isPending } = useTendersQuery(params);
@@ -377,8 +408,14 @@ const useTenderListPresenter = () => {
};
const clearAllFilters = useCallback(() => {
const cleared: Record<string, any> = {
...apiDefaultParams,
offset: 0,
limit: 10,
};
filterFormReset(mergeTenderFilterFormState(cleared));
router.push(pathName);
}, [pathName, router]);
}, [pathName, router, filterFormReset]);
const columns = [
"row",
"title",
+58 -85
View File
@@ -1,106 +1,79 @@
"use client";
import rawLoadingMain from "@/assets/lottie/loading-main.json";
import { applyLoadingLottieTheme } from "@/lib/lottie-theme";
import { cn } from "@/lib/utils";
import Lottie from "lottie-react";
import { useTheme } from "next-themes";
import { useEffect, useMemo, useState } from "react";
interface IProps {
className?: string | null;
}
/**
* Full-screen route-style loader (orbs, rings, shimmer). Pass `className` to adapt
* the shell—for example `min-h-48 flex-none` inside compact panels.
* Extract of `animations/main.json` from `public/lottie/loading.lottie` (regenerate when
* the .lottie changes: `unzip -p public/lottie/loading.lottie animations/main.json > src/assets/lottie/loading-main.json`).
*/
const LOTTIE_RAW = rawLoadingMain as object;
/**
* Route-level loader: SVG Lottie via `lottie-react` (reliable on refresh). Source animation
* stays in sync with `public/lottie/loading.lottie` via the bundled extract above.
*/
const Loading = ({ className }: IProps) => {
const { resolvedTheme } = useTheme();
const [mounted, setMounted] = useState(false);
useEffect(() => {
setMounted(true);
}, []);
const mode = mounted && resolvedTheme === "dark" ? "dark" : "light";
const animationData = useMemo(
() => applyLoadingLottieTheme(LOTTIE_RAW, mode),
[mode],
);
return (
<>
<style>{`
@keyframes app-route-loading-shimmer {
0% { transform: translateX(-100%); }
100% { transform: translateX(400%); }
}
`}</style>
<div
role="status"
aria-busy="true"
aria-live="polite"
className={cn(
"relative flex min-h-svh w-full min-w-0 flex-1 flex-col items-center justify-center overflow-hidden",
"bg-gray-1 dark:bg-dark",
className,
)}
>
<div
className={cn(
"relative flex min-h-svh w-full min-w-0 flex-1 items-center justify-center overflow-hidden bg-gray dark:bg-dark",
className,
)}
>
{/* Soft gradient wash */}
<div
className="pointer-events-none absolute inset-0 bg-[radial-gradient(ellipse_80%_50%_at_50%_-20%,rgba(87,80,241,0.22),transparent)] dark:bg-[radial-gradient(ellipse_80%_50%_at_50%_-20%,rgba(87,80,241,0.35),transparent)]"
aria-hidden
/>
className="pointer-events-none absolute inset-0 bg-[radial-gradient(ellipse_75%_55%_at_50%_45%,transparent,rgba(17,25,40,0.06))] dark:bg-[radial-gradient(ellipse_75%_55%_at_50%_45%,transparent,rgba(0,0,0,0.45))]"
aria-hidden
/>
{/* Floating orbs */}
<div
className="pointer-events-none absolute -left-32 top-1/4 h-72 w-72 animate-pulse rounded-full bg-primary/20 blur-3xl dark:bg-primary/25"
aria-hidden
/>
<div
className="pointer-events-none absolute -right-24 bottom-1/4 h-64 w-64 animate-pulse rounded-full bg-blue/25 blur-3xl dark:bg-blue/20"
style={{ animationDelay: "1s" }}
aria-hidden
/>
<div
className="pointer-events-none absolute left-1/2 top-1/2 h-96 w-96 -translate-x-1/2 -translate-y-1/2 rounded-full bg-primary/10 blur-[100px] dark:bg-primary/15"
aria-hidden
/>
<div className="pointer-events-none absolute left-6 top-6 h-14 w-14 border-l-2 border-t-2 border-primary/45 dark:border-primary/40 sm:left-10 sm:top-10" />
<div className="pointer-events-none absolute bottom-6 right-6 h-14 w-14 border-b-2 border-r-2 border-primary/45 dark:border-primary/40 sm:bottom-10 sm:right-10" />
{/* Subtle grid */}
<div
className="pointer-events-none absolute inset-0 bg-[linear-gradient(to_right,rgba(148,163,184,0.06)_1px,transparent_1px),linear-gradient(to_bottom,rgba(148,163,184,0.06)_1px,transparent_1px)] bg-[size:48px_48px] dark:bg-[linear-gradient(to_right,rgba(55,65,81,0.15)_1px,transparent_1px),linear-gradient(to_bottom,rgba(55,65,81,0.15)_1px,transparent_1px)]"
aria-hidden
/>
<div className="relative z-10 flex flex-col items-center gap-8 px-6">
<div className="relative flex h-28 w-28 items-center justify-center">
{/* Outer glow */}
<div className="absolute inset-0 rounded-full bg-primary/20 blur-xl dark:bg-primary/30" />
{/* Concentric rings */}
<div className="absolute inset-0 animate-spin rounded-full border-2 border-transparent border-r-primary/40 border-t-primary/90 shadow-[0_0_20px_rgba(87,80,241,0.35)]" />
<div
className="absolute inset-2 animate-[spin_2.5s_linear_infinite_reverse] rounded-full border-2 border-transparent border-b-blue/70 border-l-blue/30"
<div className="relative z-10 flex flex-col items-center gap-6 px-8">
<div className="relative aspect-square w-[min(200px,55vw)] shrink-0">
<div className="absolute inset-0 flex items-center justify-center [&_svg]:h-full [&_svg]:w-full [&_svg]:max-h-full [&_svg]:max-w-full">
<Lottie
key={mode}
animationData={animationData}
loop
className="h-full w-full"
aria-hidden
/>
<div className="absolute inset-5 rounded-full border border-stroke/80 dark:border-stroke-dark/60" />
{/* Core */}
<div className="relative flex h-14 w-14 items-center justify-center rounded-2xl bg-gradient-to-br from-primary to-blue shadow-lg ring-4 ring-white/60 dark:ring-dark-2/80">
<span className="text-lg font-bold tracking-tight text-white drop-shadow-sm">
OL
</span>
</div>
</div>
<div className="flex flex-col items-center gap-3 text-center">
<div className="flex items-center gap-1.5">
<span className="h-2 w-2 animate-pulse rounded-full bg-primary" />
<span
className="h-2 w-2 animate-pulse rounded-full bg-primary/70"
style={{ animationDelay: "0.2s" }}
/>
<span
className="h-2 w-2 animate-pulse rounded-full bg-primary/40"
style={{ animationDelay: "0.4s" }}
/>
</div>
<p className="bg-gradient-to-r from-dark via-primary to-blue bg-clip-text text-body-sm font-medium text-transparent dark:from-gray-7 dark:via-primary dark:to-blue-light">
Preparing your workspace
</p>
</div>
{/* Shimmer bar */}
<div className="h-1 w-48 overflow-hidden rounded-full bg-stroke dark:bg-stroke-dark">
<div
className="h-full w-1/3 rounded-full bg-gradient-to-r from-transparent via-primary to-transparent"
style={{
animation:
"app-route-loading-shimmer 1.5s ease-in-out infinite",
}}
/>
</div>
</div>
<p className="font-mono text-[11px] font-semibold uppercase tracking-[0.45em] text-gray-6 dark:text-dark-6">
Loading
</p>
</div>
</>
<span className="sr-only">Loading, please wait.</span>
</div>
);
};
+185
View File
@@ -0,0 +1,185 @@
"use client";
import { DotLottieReact } from "@lottiefiles/dotlottie-react";
import { cn } from "@/lib/utils";
import Link from "next/link";
import { useTheme } from "next-themes";
import { useEffect, useState } from "react";
/** Served from `public/lottie/404.lottie` (mirrored in `src/assets/lottie/404.lottie` for the repo). */
const LOTTIE_URL = "/lottie/404.lottie";
export default function NotFoundContent() {
const { resolvedTheme } = useTheme();
const [mounted, setMounted] = useState(false);
const [loadError, setLoadError] = useState(false);
useEffect(() => {
setMounted(true);
}, []);
const mode = mounted && resolvedTheme === "dark" ? "dark" : "light";
return (
<div
className={cn(
"relative isolate flex min-h-svh w-full flex-col overflow-hidden",
"bg-gray-1 dark:bg-dark",
)}
>
{/* Ambient mesh */}
<div
className="pointer-events-none absolute -left-1/4 top-0 h-[min(85vh,720px)] w-[min(85vw,720px)] rounded-full bg-primary/[0.22] blur-[100px] dark:bg-primary/[0.12]"
aria-hidden
/>
<div
className="pointer-events-none absolute -right-1/4 bottom-0 h-[min(70vh,560px)] w-[min(70vw,560px)] rounded-full bg-blue/[0.18] blur-[90px] dark:bg-blue/[0.1]"
aria-hidden
/>
<div
className="pointer-events-none absolute left-1/2 top-1/3 h-64 w-64 -translate-x-1/2 rounded-full bg-primary/[0.08] blur-3xl dark:bg-primary/[0.06]"
aria-hidden
/>
{/* Vignette + fine grid */}
<div
className="pointer-events-none absolute inset-0 bg-[radial-gradient(ellipse_80%_55%_at_50%_38%,transparent_0%,rgba(17,25,40,0.04)_55%,rgba(17,25,40,0.09)_100%)] dark:bg-[radial-gradient(ellipse_80%_55%_at_50%_38%,transparent_0%,rgba(0,0,0,0.35)_55%,rgba(0,0,0,0.65)_100%)]"
aria-hidden
/>
<div
className={cn(
"pointer-events-none absolute inset-0 opacity-[0.45] dark:opacity-[0.22]",
"[background-image:linear-gradient(to_right,rgba(17,25,40,0.06)_1px,transparent_1px),linear-gradient(to_bottom,rgba(17,25,40,0.06)_1px,transparent_1px)]",
"dark:[background-image:linear-gradient(to_right,rgba(148,163,184,0.08)_1px,transparent_1px),linear-gradient(to_bottom,rgba(148,163,184,0.08)_1px,transparent_1px)]",
)}
style={{
backgroundSize: "28px 28px",
backgroundPosition: "0 0",
}}
aria-hidden
/>
{/* Frame accents */}
<div className="pointer-events-none absolute left-5 top-5 h-16 w-16 border-l-2 border-t-2 border-primary/40 dark:border-primary/35 sm:left-8 sm:top-8" />
<div className="pointer-events-none absolute bottom-5 right-5 h-16 w-16 border-b-2 border-r-2 border-primary/40 dark:border-primary/35 sm:bottom-8 sm:right-8" />
<div className="relative z-10 flex flex-1 flex-col items-center justify-center px-4 py-14 sm:px-6 sm:py-16">
<div className="w-full max-w-xl">
{/* Gradient hairline frame */}
<div
className={cn(
"rounded-[1.75rem] p-[1px] shadow-5",
"bg-gradient-to-br from-primary/55 via-primary/[0.12] to-blue/35",
"dark:from-primary/45 dark:via-primary/[0.08] dark:to-blue/25",
)}
>
<div
className={cn(
"relative overflow-hidden rounded-[1.7rem]",
"bg-white/[0.92] backdrop-blur-2xl dark:bg-dark-2/[0.88]",
"border border-white/60 dark:border-dark-3/80",
)}
>
<div
className="pointer-events-none absolute inset-x-0 top-0 h-px bg-gradient-to-r from-transparent via-primary/35 to-transparent"
aria-hidden
/>
<div className="relative px-6 pb-8 pt-7 sm:px-10 sm:pb-10 sm:pt-9">
<div className="mb-6 flex flex-col items-center gap-3 text-center sm:mb-8">
<span className="inline-flex items-center gap-2 rounded-full border border-stroke/90 bg-gray-1/90 px-3 py-1 font-mono text-[10px] font-semibold uppercase tracking-[0.28em] text-gray-6 shadow-theme-xs dark:border-dark-3 dark:bg-dark/60 dark:text-dark-6">
<span
className="h-1.5 w-1.5 shrink-0 rounded-full bg-primary shadow-[0_0_10px_rgba(87,80,241,0.65)]"
aria-hidden
/>
Route not found
</span>
<h1 className="text-[clamp(3.25rem,12vw,5.5rem)] font-bold leading-none tracking-tight">
<span className="bg-gradient-to-br from-dark via-dark to-gray-7 bg-clip-text text-transparent dark:from-white dark:via-white dark:to-dark-6">
4
</span>
<span className="bg-gradient-to-br from-primary via-primary to-blue bg-clip-text text-transparent">
0
</span>
<span className="bg-gradient-to-br from-dark via-dark to-gray-7 bg-clip-text text-transparent dark:from-white dark:via-white dark:to-dark-6">
4
</span>
</h1>
<p className="max-w-sm text-body-sm text-gray-6 dark:text-dark-6">
This URL is not mapped in the panel. The page may have moved
or the link could be mistyped.
</p>
</div>
<div className="relative mx-auto w-full max-w-[min(100%,400px)]">
<div
className="pointer-events-none absolute -inset-3 rounded-2xl bg-gradient-to-b from-primary/[0.07] to-transparent dark:from-primary/[0.05]"
aria-hidden
/>
<div className="relative [&_canvas]:mx-auto [&_canvas]:block [&_canvas]:h-auto [&_canvas]:w-full">
{loadError ? (
<div
className={cn(
"flex min-h-[200px] w-full items-center justify-center rounded-2xl border border-dashed border-error/40 bg-gray-1/50 dark:border-error/35 dark:bg-dark/40",
"animate-pulse",
)}
role="alert"
>
<span className="text-body-xs font-medium text-gray-6 dark:text-dark-6">
Could not load illustration
</span>
</div>
) : (
<DotLottieReact
key={mode}
src={LOTTIE_URL}
loop
autoplay
className="h-auto w-full max-w-full drop-shadow-[0_12px_40px_rgba(87,80,241,0.12)] dark:drop-shadow-[0_12px_48px_rgba(0,0,0,0.35)]"
aria-hidden
onError={() => setLoadError(true)}
/>
)}
</div>
</div>
<div className="mt-8 flex flex-col items-stretch gap-3 sm:mt-10 sm:flex-row sm:items-center sm:justify-center sm:gap-4">
<Link
href="/"
className={cn(
"shadow-theme-xs relative isolate inline-flex w-full items-center justify-center overflow-hidden rounded-xl bg-gradient-to-r from-primary to-primary/85 py-[11px] px-6 text-center text-body-sm font-semibold text-white transition hover:opacity-95",
"focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-primary",
"sm:flex-1 sm:max-w-[200px]",
)}
>
Back to dashboard
</Link>
<button
type="button"
onClick={() =>
typeof window !== "undefined" && window.history.back()
}
className={cn(
"rounded-xl border border-stroke bg-white/90 px-6 py-3 text-body-sm font-semibold text-dark shadow-theme-xs",
"transition hover:border-primary/40 hover:text-primary",
"dark:border-dark-3 dark:bg-dark/80 dark:text-white dark:hover:border-primary/35 dark:hover:bg-dark-3",
"sm:flex-1 sm:max-w-[200px]",
)}
>
Go back
</button>
</div>
</div>
</div>
</div>
<p className="mt-6 text-center font-mono text-[10px] uppercase tracking-[0.35em] text-gray-5 dark:text-dark-5">
Opp lens · admin
</p>
</div>
</div>
</div>
);
}
@@ -0,0 +1,75 @@
"use client";
import { ChevronUpIcon } from "@/assets/icons";
import { cn } from "@/lib/utils";
import { type ReactNode, useState } from "react";
interface CollapsibleFilterSectionProps {
title: string;
subtitle?: string;
badge?: string;
defaultOpen?: boolean;
children: ReactNode;
}
/**
* Nested accordion block for long filter forms (e.g. tender advanced fields).
* Uses CSS grid height — independent from the main InlineListFiltersPanel GSAP shell.
*/
export default function CollapsibleFilterSection({
title,
subtitle,
badge,
defaultOpen,
children,
}: CollapsibleFilterSectionProps) {
const [open, setOpen] = useState(Boolean(defaultOpen));
return (
<div
className={cn(
"rounded-2xl border border-stroke/50 bg-white/50 backdrop-blur-md dark:border-white/[0.08] dark:bg-dark-2/45",
open ? "overflow-visible" : "overflow-hidden",
open && "ring-1 ring-primary/15 dark:ring-primary/25",
)}
>
<button
type="button"
onClick={() => setOpen((o) => !o)}
className="flex w-full items-center justify-between gap-3 px-4 py-3.5 text-start transition hover:bg-white/60 dark:hover:bg-white/[0.04]"
>
<div className="min-w-0">
<div className="flex flex-wrap items-center gap-2">
<span className="text-sm font-semibold text-dark dark:text-white">{title}</span>
{badge ? (
<span className="rounded-full bg-gradient-to-r from-primary/90 to-violet-500/85 px-2.5 py-0.5 text-[10px] font-bold uppercase tracking-wider text-white">
{badge}
</span>
) : null}
</div>
{subtitle ? (
<p className="mt-0.5 text-xs text-dark-5 dark:text-dark-6">{subtitle}</p>
) : null}
</div>
<ChevronUpIcon
className={cn(
"h-5 w-5 shrink-0 text-primary transition-transform duration-300 dark:text-primary/90",
!open && "rotate-180",
)}
/>
</button>
<div
className={cn(
"grid transition-[grid-template-rows] duration-300 ease-out",
open ? "grid-rows-[1fr]" : "grid-rows-[0fr]",
)}
>
<div className={cn("min-h-0", open ? "overflow-visible" : "overflow-hidden")}>
<div className="border-t border-stroke/40 px-4 pb-5 pt-1 dark:border-white/[0.06]">
{children}
</div>
</div>
</div>
</div>
);
}
@@ -0,0 +1,235 @@
"use client";
import { ChevronUpIcon, SearchIcon } from "@/assets/icons";
import { cn } from "@/lib/utils";
import gsap from "gsap";
import { type ReactNode, useLayoutEffect, useRef, useState } from "react";
import IsVisible from "./IsVisible";
export interface InlineListFiltersPanelProps {
title: string;
description?: string;
/** Computed by the parent from watched form values */
activeFilterCount: number;
children: ReactNode;
/** Initial collapsed state (default false) */
defaultOpen?: boolean;
panelToggleDataCy?: string;
expandAriaLabel: string;
collapseAriaLabel: string;
className?: string;
}
/**
* Shared inline filter shell: collapsible header + GSAP height animation for the panel body.
* Wrap your `<form>...</form>` (or fragment) as `children`.
*/
export default function InlineListFiltersPanel({
title,
description,
activeFilterCount,
children,
defaultOpen = false,
panelToggleDataCy,
expandAriaLabel,
collapseAriaLabel,
className,
}: InlineListFiltersPanelProps) {
const [panelOpen, setPanelOpen] = useState(Boolean(defaultOpen));
/** Header stays square-bottom until collapse GSAP finishes so radius does not pop early */
const [headerFlushBottom, setHeaderFlushBottom] = useState(false);
const panelOuterRef = useRef<HTMLDivElement>(null);
const panelInnerRef = useRef<HTMLDivElement>(null);
const chevronRef = useRef<HTMLSpanElement>(null);
const prevPanelOpen = useRef(panelOpen);
useLayoutEffect(() => {
const outer = panelOuterRef.current;
const inner = panelInnerRef.current;
const chevron = chevronRef.current;
if (!outer || !inner) return;
gsap.set(outer, { height: panelOpen ? "auto" : 0 });
gsap.set(inner, {
opacity: panelOpen ? 1 : 0,
y: panelOpen ? 0 : -14,
scale: panelOpen ? 1 : 0.995,
});
if (chevron)
gsap.set(chevron, {
rotation: panelOpen ? 0 : 180,
transformOrigin: "50% 50%",
});
if (!panelOpen) {
setHeaderFlushBottom(false);
}
// eslint-disable-next-line react-hooks/exhaustive-deps -- mount sync only
}, []);
useLayoutEffect(() => {
const outer = panelOuterRef.current;
const inner = panelInnerRef.current;
const chevron = chevronRef.current;
if (!outer || !inner) return;
if (prevPanelOpen.current === panelOpen) return;
prevPanelOpen.current = panelOpen;
const reduced =
typeof window !== "undefined" &&
window.matchMedia("(prefers-reduced-motion: reduce)").matches;
gsap.killTweensOf([outer, inner, chevron].filter(Boolean));
if (reduced) {
gsap.set(outer, { height: panelOpen ? "auto" : 0 });
gsap.set(inner, {
opacity: panelOpen ? 1 : 0,
y: panelOpen ? 0 : -10,
scale: 1,
});
if (chevron) gsap.set(chevron, { rotation: panelOpen ? 0 : 180 });
setHeaderFlushBottom(panelOpen);
return;
}
const ctx = gsap.context(() => {
if (panelOpen) {
setHeaderFlushBottom(true);
const targetH = inner.scrollHeight;
gsap.set(outer, { height: 0 });
gsap.set(inner, { opacity: 0, y: -36, scale: 0.985 });
const tl = gsap.timeline({ defaults: { ease: "none" } });
tl.to(outer, {
height: targetH,
duration: 0.62,
ease: "power4.out",
onComplete: () => {
gsap.set(outer, { height: "auto" });
},
}).to(
inner,
{
opacity: 1,
y: 0,
scale: 1,
duration: 0.52,
ease: "power3.out",
},
"-=0.5",
);
if (chevron) {
tl.to(
chevron,
{ rotation: 0, duration: 0.48, ease: "back.out(1.35)" },
"-=0.52",
);
}
} else {
const currentH = Math.max(outer.offsetHeight, inner.scrollHeight);
gsap.set(outer, { height: currentH });
const tl = gsap.timeline({
onComplete: () => {
setHeaderFlushBottom(false);
},
});
tl.to(inner, {
opacity: 0,
y: -22,
scale: 0.992,
duration: 0.3,
ease: "power2.in",
}).to(
outer,
{
height: 0,
duration: 0.48,
ease: "power3.inOut",
},
"-=0.12",
);
if (chevron) {
tl.to(
chevron,
{ rotation: 180, duration: 0.42, ease: "power2.inOut" },
0,
);
}
}
}, outer);
return () => ctx.revert();
}, [panelOpen]);
return (
<div className={cn("relative mb-8", className)}>
<div className="relative overflow-hidden rounded-[1.75rem] border border-stroke/60 bg-white dark:border-dark-3 dark:bg-dark-2">
<button
type="button"
onClick={() => setPanelOpen((o) => !o)}
aria-expanded={panelOpen}
aria-label={panelOpen ? collapseAriaLabel : expandAriaLabel}
data-cy={panelToggleDataCy}
className={cn(
"flex w-full flex-wrap items-start gap-x-3 gap-y-3 px-4 py-4 text-start transition-colors sm:items-center sm:justify-between sm:gap-4 sm:px-6 sm:py-5",
"rounded-[1.65rem] border border-transparent",
"hover:border-stroke/50 hover:bg-white/70 dark:hover:border-white/[0.08] dark:hover:bg-dark-3/50",
"focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary/40 focus-visible:ring-offset-2 focus-visible:ring-offset-white dark:focus-visible:ring-offset-dark-2",
headerFlushBottom &&
"rounded-b-none border-b border-stroke/45 dark:border-white/[0.07]",
)}
>
<div className="flex min-w-0 flex-1 items-start gap-3 sm:items-center">
<span className="mt-0.5 flex h-11 w-11 shrink-0 items-center justify-center rounded-2xl bg-gradient-to-br from-primary to-violet-600 text-white sm:mt-0">
<SearchIcon className="h-5 w-5 opacity-95" />
</span>
<div className="min-w-0">
<span className="block text-lg font-bold tracking-tight text-dark dark:text-white">
{title}
</span>
<IsVisible condition={!!description}>
<p className="mt-0.5 max-w-xl text-sm text-dark-5 dark:text-dark-6">
{description}
</p>
</IsVisible>
</div>
</div>
<div className="ml-auto flex shrink-0 items-center gap-2">
{activeFilterCount > 0 ? (
<div className="flex items-center gap-2 rounded-full border border-primary/25 bg-primary/10 px-3 py-1.5 dark:border-primary/35 dark:bg-primary/15">
<span className="text-xs font-semibold uppercase tracking-wide text-primary">
{activeFilterCount} active
</span>
</div>
) : (
<span className="inline-flex items-center rounded-full border border-stroke/50 bg-white/60 px-3 py-1.5 text-xs font-medium text-dark-5 dark:border-white/10 dark:bg-dark-3/80 dark:text-dark-6">
No filters applied
</span>
)}
<span
ref={chevronRef}
className="inline-flex h-5 w-5 shrink-0 text-primary dark:text-primary/90"
aria-hidden
>
<ChevronUpIcon className="h-5 w-5" />
</span>
</div>
</button>
<div
ref={panelOuterRef}
className="overflow-hidden"
aria-hidden={!panelOpen}
>
<div ref={panelInnerRef} className="will-change-transform">
{children}
</div>
</div>
</div>
</div>
);
}
+11 -1
View File
@@ -23,8 +23,18 @@ const ListHeader = ({
}: IProps) => {
const pathName = usePathname();
const router = useRouter();
const rowJustify =
hasFilter && hasCreate
? "justify-between"
: hasCreate && !hasFilter
? "justify-center md:justify-end"
: "justify-start";
return (
<div className="mb-1 flex flex-col-reverse items-center justify-between gap-3 md:flex-row">
<div
className={`mb-1 flex flex-col-reverse items-center gap-3 md:flex-row ${rowJustify}`}
>
<IsVisible condition={hasFilter}>
<Button
label={filterButtonText}
+69
View File
@@ -0,0 +1,69 @@
"use client";
import gsap from "gsap";
import { type RefObject, useLayoutEffect } from "react";
type TableAnimRefs = {
tableSectionRef: RefObject<HTMLDivElement | null>;
skeletonTbodyRef: RefObject<HTMLTableSectionElement | null>;
dataTbodyRef: RefObject<HTMLTableSectionElement | null>;
};
/**
* Stagger row entrance for table skeleton vs data body (matches admins/tenders pattern).
*/
export function useTableSectionRowAnimations(
isPending: boolean,
/** Changes when loaded list/payload updates — retriggers data row animation */
dataEpoch: unknown,
refs: TableAnimRefs,
) {
const { tableSectionRef, skeletonTbodyRef, dataTbodyRef } = refs;
useLayoutEffect(() => {
const reduced =
typeof window !== "undefined" &&
window.matchMedia("(prefers-reduced-motion: reduce)").matches;
const ctx = gsap.context(() => {
if (reduced) return;
if (isPending) {
const tbody = skeletonTbodyRef.current;
if (!tbody) return;
const rows = tbody.querySelectorAll("tr");
gsap.fromTo(
rows,
{ opacity: 0.35, y: 14 },
{
opacity: 1,
y: 0,
duration: 0.42,
stagger: 0.032,
ease: "power2.out",
},
);
return;
}
const tbody = dataTbodyRef.current;
if (!tbody) return;
const rows = tbody.querySelectorAll("tr");
if (!rows.length) return;
gsap.fromTo(
rows,
{ opacity: 0, y: 18 },
{
opacity: 1,
y: 0,
duration: 0.46,
stagger: 0.05,
ease: "power3.out",
clearProps: "opacity,transform",
},
);
}, tableSectionRef);
return () => ctx.revert();
}, [isPending, dataEpoch]);
}
+66
View File
@@ -0,0 +1,66 @@
/**
* Brand-aligned Lottie tinting (Tailwind theme tokens → normalized RGBA).
* Primary: #5750F1 — slightly lifted in dark mode for contrast on `dark` bg.
*/
export type LottieThemeMode = "light" | "dark";
/** #5750F1 */
const PRIMARY_LIGHT = [87 / 255, 80 / 255, 241 / 255, 1] as const;
/** Brighter violet for fills on #111928 */
const PRIMARY_ON_DARK = [125 / 255, 118 / 255, 252 / 255, 1] as const;
function deepClone<T>(value: T): T {
return JSON.parse(JSON.stringify(value)) as T;
}
function colorsMatch(a: unknown, b: readonly number[]): boolean {
if (!Array.isArray(a) || a.length !== 4) return false;
return a.every(
(v, i) => typeof v === "number" && Math.abs(v - b[i]!) < 0.0001,
);
}
/**
* Replace exact RGBA quadruples anywhere in the JSON tree (Lottie uses nested `c.k`).
*/
function replaceRgbaEverywhere(
node: unknown,
from: readonly number[],
to: readonly number[],
): unknown {
if (node === null || typeof node !== "object") {
return node;
}
if (Array.isArray(node)) {
if (colorsMatch(node, from)) {
return [...to];
}
return node.map((item) => replaceRgbaEverywhere(item, from, to));
}
const out: Record<string, unknown> = {};
for (const [k, v] of Object.entries(node)) {
out[k] = replaceRgbaEverywhere(v, from, to);
}
return out;
}
/** Pure black fills/strokes → brand primary (loading spinner dots, etc.). */
export function applyLoadingLottieTheme<T extends object>(
data: T,
mode: LottieThemeMode,
): T {
const clone = deepClone(data);
const to = mode === "dark" ? PRIMARY_ON_DARK : PRIMARY_LIGHT;
return replaceRgbaEverywhere(clone, [0, 0, 0, 1], to) as T;
}
/**
* Full illustrations (e.g. 404): clone only until we add palette maps per asset.
*/
export function applyIllustrationLottieTheme<T extends object>(
data: T,
_mode: LottieThemeMode,
): T {
return deepClone(data);
}