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:
@@ -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",
|
||||
|
||||
Reference in New Issue
Block a user