chore(env): update application version to 2.2.2 in production environment

feat(checkbox): enhance Checkbox component with animation and new icon options

- Added GSAP animations for checkbox state changes to improve user interaction.
- Introduced a new "none" icon option for the Checkbox component.
- Updated Checkbox component to support additional props and improved styling.
- Refactored Checkbox rendering logic for better performance and maintainability.

feat(tender-filters): add document state filter to TenderListFilters

- Integrated a new checkbox filter for "Documents scraped" in the TenderListFilters component.
- Enhanced the UI for the filter section to improve user experience.
This commit is contained in:
AmirReza Jamali
2026-05-20 16:17:44 +03:30
parent 4779662d63
commit 44897aba5d
4 changed files with 245 additions and 59 deletions
+1 -1
View File
@@ -1,4 +1,4 @@
NEXT_PUBLIC_APP_VERSION=2.2.1 NEXT_PUBLIC_APP_VERSION=2.2.2
NEXT_PUBLIC_TOAST_AUTO_CLOSE_DURATION=2500 NEXT_PUBLIC_TOAST_AUTO_CLOSE_DURATION=2500
NEXT_PUBLIC_FIREBASE_API_KEY=AIzaSyCTjdsk2jE34IfnvSKP1JMTIf_Abd7tbt0 NEXT_PUBLIC_FIREBASE_API_KEY=AIzaSyCTjdsk2jE34IfnvSKP1JMTIf_Abd7tbt0
NEXT_PUBLIC_FIREBASE_AUTH_DOMAIN=opplens-270d1.firebaseapp.com NEXT_PUBLIC_FIREBASE_AUTH_DOMAIN=opplens-270d1.firebaseapp.com
+3 -3
View File
@@ -113,9 +113,9 @@ export default function FormElementsPage() {
title="Checkbox and radio" title="Checkbox and radio"
className="space-y-5.5 !p-6.5" className="space-y-5.5 !p-6.5"
> >
{/* <Checkbox label="Checkbox Text" /> <Checkbox label="Checkbox Text" withIcon="check" withBg />
<Checkbox label="Checkbox Text" withIcon="check" /> <Checkbox label="With check icon" withIcon="check" withBg />
<Checkbox label="Checkbox Text" withIcon="x" /> */} <Checkbox label="With X icon" withIcon="x" withBg />
<RadioInput label="Checkbox Text" /> <RadioInput label="Checkbox Text" />
<RadioInput label="Checkbox Text" variant="circle" /> <RadioInput label="Checkbox Text" variant="circle" />
</ShowcaseSection> </ShowcaseSection>
+224 -55
View File
@@ -1,69 +1,238 @@
"use client";
import { CheckIcon, XIcon } from "@/assets/icons"; import { CheckIcon, XIcon } from "@/assets/icons";
import { cn } from "@/lib/utils"; import { cn } from "@/lib/utils";
import { useId } from "react"; import gsap from "gsap";
import {
forwardRef,
useId,
useImperativeHandle,
useLayoutEffect,
useRef,
type InputHTMLAttributes,
} from "react";
type PropsType = { type PropsType = {
withIcon?: "check" | "x"; withIcon?: "check" | "x" | "none";
withBg?: boolean; withBg?: boolean;
label: string; label: string;
name?: string; name?: string;
minimal?: boolean; minimal?: boolean;
onChange?: (e: React.ChangeEvent<HTMLInputElement>) => void; onChange?: (e: React.ChangeEvent<HTMLInputElement>) => void;
radius?: "default" | "md"; radius?: "default" | "md";
}; className?: string;
} & Omit<InputHTMLAttributes<HTMLInputElement>, "type">;
export function Checkbox({
withIcon,
label,
name,
withBg,
minimal,
onChange,
radius,
}: PropsType) {
const id = useId();
function prefersReducedMotion() {
return ( return (
<div> typeof window !== "undefined" &&
<label window.matchMedia("(prefers-reduced-motion: reduce)").matches
htmlFor={id}
className={cn(
"flex cursor-pointer select-none items-center",
!minimal && "text-body-sm font-medium",
)}
>
<div className="relative">
<input
type="checkbox"
onChange={onChange}
name={name}
id={id}
className="peer sr-only"
/>
<div
className={cn(
"mr-2 flex size-5 items-center justify-center rounded border border-dark-5 peer-checked:border-primary dark:border-dark-6 peer-checked:[&>*]:block transition-all duration-300",
withBg
? "peer-checked:bg-primary [&>*]:text-white"
: "peer-checked:bg-gray-2 dark:peer-checked:bg-transparent",
minimal && "mr-3 border-stroke dark:border-dark-3",
radius === "md" && "rounded-md",
)}
>
{!withIcon && (
<span className="hidden size-2.5 rounded-sm bg-primary" />
)}
{withIcon === "check" && (
<CheckIcon className="hidden text-primary" />
)}
{withIcon === "x" && <XIcon className="hidden text-primary" />}
</div>
</div>
<span>{label}</span>
</label>
</div>
); );
} }
export const Checkbox = forwardRef<HTMLInputElement, PropsType>(function Checkbox(
{
withIcon = "check",
label,
name,
withBg = true,
minimal,
onChange,
radius = "md",
className,
disabled,
id: idProp,
checked,
defaultChecked,
...rest
},
ref,
) {
const autoId = useId();
const id = idProp ?? autoId;
const inputRef = useRef<HTMLInputElement>(null);
const boxRef = useRef<HTMLDivElement>(null);
const fillRef = useRef<HTMLDivElement>(null);
const iconRef = useRef<HTMLSpanElement>(null);
const glowRef = useRef<HTMLDivElement>(null);
useImperativeHandle(ref, () => inputRef.current as HTMLInputElement);
const animateState = (isChecked: boolean) => {
const box = boxRef.current;
if (!box || prefersReducedMotion()) return;
const fill = fillRef.current;
const icon = iconRef.current;
const glow = glowRef.current;
gsap.killTweensOf([box, fill, icon, glow].filter(Boolean));
if (isChecked) {
const tl = gsap.timeline();
tl.to(box, { scale: 1.14, duration: 0.1, ease: "power2.out" }).to(box, {
scale: 1,
duration: 0.4,
ease: "back.out(2.6)",
});
if (fill) {
tl.fromTo(
fill,
{ scale: 0.35, opacity: 0 },
{ scale: 1, opacity: 1, duration: 0.32, ease: "power3.out" },
0,
);
}
if (glow) {
tl.fromTo(
glow,
{ opacity: 0, scale: 0.75 },
{ opacity: 1, scale: 1, duration: 0.38, ease: "power2.out" },
0.04,
);
}
if (icon) {
tl.fromTo(
icon,
{ scale: 0, rotate: -48, opacity: 0 },
{
scale: 1,
rotate: 0,
opacity: 1,
duration: 0.38,
ease: "back.out(3)",
},
0.1,
);
}
} else {
gsap.to(box, { scale: 1, duration: 0.22, ease: "power2.out" });
if (fill) {
gsap.to(fill, {
scale: 0.4,
opacity: 0,
duration: 0.2,
ease: "power2.in",
});
}
if (glow) {
gsap.to(glow, { opacity: 0, scale: 0.85, duration: 0.2 });
}
if (icon) {
gsap.to(icon, {
scale: 0,
rotate: 36,
opacity: 0,
duration: 0.18,
ease: "power2.in",
});
}
}
};
useLayoutEffect(() => {
if (inputRef.current) {
animateState(inputRef.current.checked);
}
}, [checked]);
const handleChange = (e: React.ChangeEvent<HTMLInputElement>) => {
animateState(e.target.checked);
onChange?.(e);
};
const showCheckIcon = withIcon === "check";
const showXIcon = withIcon === "x";
const showDot = withIcon === "none";
return (
<label
htmlFor={id}
className={cn(
"group flex cursor-pointer select-none items-center",
disabled && "cursor-not-allowed opacity-55",
className,
)}
>
<div className="relative shrink-0">
<input
ref={inputRef}
type="checkbox"
id={id}
name={name}
disabled={disabled}
checked={checked}
defaultChecked={defaultChecked}
onChange={handleChange}
className="peer sr-only"
{...rest}
/>
<div
ref={boxRef}
className={cn(
"relative mr-2.5 flex size-5 items-center justify-center overflow-hidden border-2 shadow-theme-xs backdrop-blur-sm transition-[border-color,box-shadow] duration-200",
"border-stroke/80 bg-white/90 dark:border-dark-3 dark:bg-dark-2/90",
"peer-checked:border-primary peer-checked:shadow-[0_0_0_3px_rgba(87,80,241,0.18)]",
"peer-focus-visible:ring-2 peer-focus-visible:ring-primary/45 peer-focus-visible:ring-offset-2 peer-focus-visible:ring-offset-white dark:peer-focus-visible:ring-offset-gray-dark",
"group-hover:border-primary/45 dark:group-hover:border-primary/55",
radius === "md" ? "rounded-lg" : "rounded",
minimal && "mr-3",
)}
>
<div
ref={glowRef}
aria-hidden
className="pointer-events-none absolute inset-0 opacity-0"
style={{
background:
"radial-gradient(circle at 28% 22%, rgba(87,80,241,0.5), transparent 68%)",
}}
/>
{(withBg || showCheckIcon || showXIcon) && (
<div
ref={fillRef}
aria-hidden
className="absolute inset-0 origin-center scale-[0.35] bg-gradient-to-br from-primary via-primary/90 to-[#7B6CFF] opacity-0"
/>
)}
{showDot && (
<span
ref={iconRef}
className="relative z-[1] block size-2.5 scale-0 rounded-sm bg-primary opacity-0 peer-checked:scale-100 peer-checked:opacity-100"
/>
)}
{showCheckIcon && (
<span
ref={iconRef}
className="relative z-[1] inline-flex scale-0 opacity-0"
>
<CheckIcon className="size-3 text-white drop-shadow-sm" />
</span>
)}
{showXIcon && (
<span
ref={iconRef}
className="relative z-[1] inline-flex scale-0 opacity-0"
>
<XIcon className="size-3 text-white drop-shadow-sm" />
</span>
)}
</div>
</div>
<span
className={cn(
!minimal && "text-body-sm font-medium",
"text-dark transition-colors duration-200 group-hover:text-primary dark:text-white dark:group-hover:text-primary/90",
)}
>
{label}
</span>
</label>
);
});
@@ -1,6 +1,7 @@
"use client"; "use client";
import { GlobeIcon, SearchIcon } from "@/assets/icons"; import { GlobeIcon, SearchIcon } from "@/assets/icons";
import { Checkbox } from "@/components/FormElements/checkbox";
import DatePicker from "@/components/FormElements/DatePicker/DatePicker"; import DatePicker from "@/components/FormElements/DatePicker/DatePicker";
import InputGroup from "@/components/FormElements/InputGroup"; import InputGroup from "@/components/FormElements/InputGroup";
import { Select } from "@/components/FormElements/select"; import { Select } from "@/components/FormElements/select";
@@ -67,6 +68,7 @@ function countActiveFilters(values: Record<string, unknown>): number {
for (const key of RANGE_KEYS) { for (const key of RANGE_KEYS) {
if (isRangeActive(values[key])) n++; if (isRangeActive(values[key])) n++;
} }
if (values.documents_scraped === true) n++;
return n; return n;
} }
@@ -215,6 +217,21 @@ const TenderListFilters = ({
</div> </div>
</CollapsibleFilterSection> </CollapsibleFilterSection>
<CollapsibleFilterSection
title="Document state"
subtitle="Restrict to tenders whose documents have been scraped."
>
<div className="max-w-fit rounded-xl border border-stroke/40 bg-white/60 p-4 dark:border-white/[0.06] dark:bg-dark-3/40">
<Checkbox
label="Documents scraped"
minimal
withBg
withIcon="check"
{...filterRegister("documents_scraped")}
/>
</div>
</CollapsibleFilterSection>
<CollapsibleFilterSection <CollapsibleFilterSection
title="Date ranges" title="Date ranges"
subtitle="Unix-backed ranges — pick start/end; cleared ranges are omitted from the query." subtitle="Unix-backed ranges — pick start/end; cleared ranges are omitted from the query."