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

feat(multi-select): enhance MultiSelect component with dropdown positioning and portal rendering

- Added dynamic dropdown positioning to ensure it appears correctly relative to the trigger element.
- Implemented portal rendering for the dropdown to improve performance and prevent overflow issues.
- Refactored MultiSelect state management for better clarity and maintainability.

feat(switch): improve Switch component with GSAP animations and enhanced styling

- Integrated GSAP animations for thumb transitions and glow effects to enhance user interaction.
- Updated styling for the Switch component to improve visual feedback and responsiveness.

feat(companies-table): add currency and language metadata display in CompaniesTable

- Introduced currency and language metadata for better representation of company details.
- Enhanced UI for displaying company languages and currencies with tooltips for additional context.

feat(assign-to-company-modal): refactor AssignToCompanyModalContent to use MultiSelect

- Replaced Select component with MultiSelect for improved user experience in selecting companies.
- Streamlined state management and data handling for company assignments.

feat(customers-table): enhance customer company display with tooltips and improved layout

- Updated customer company display to show a maximum of two companies with a tooltip for additional companies.
- Improved styling for better visual representation of customer associations with companies.

feat(confirmation-modal): enhance ConfirmationModal with animations and improved accessibility

- Added GSAP animations for modal entrance and exit to improve user experience.
- Implemented accessibility features for better keyboard navigation and focus management.

feat(status): extend Status component with pulse and shimmer effects for enhanced visual feedback

- Introduced new visual effects for status indicators to improve user engagement and clarity.
- Updated styles for better integration with existing UI components.

feat(tooltip): enhance tooltip styling and functionality with new palettes

- Implemented a new tooltip design with gradient backgrounds and shadows for improved visibility.
- Updated tooltip parameters to support new styling options and enhance user experience.
This commit is contained in:
AmirReza Jamali
2026-05-24 13:07:07 +03:30
parent 48f0b3ce2e
commit 36334119bd
13 changed files with 829 additions and 165 deletions
+1 -1
View File
@@ -1,4 +1,4 @@
NEXT_PUBLIC_APP_VERSION=2.2.3
NEXT_PUBLIC_APP_VERSION=2.2.4
NEXT_PUBLIC_TOAST_AUTO_CLOSE_DURATION=2500
NEXT_PUBLIC_FIREBASE_API_KEY=AIzaSyCTjdsk2jE34IfnvSKP1JMTIf_Abd7tbt0
NEXT_PUBLIC_FIREBASE_AUTH_DOMAIN=opplens-270d1.firebaseapp.com
+1
View File
@@ -75,6 +75,7 @@ export function MoneyIcon(props: IconProps) {
height="20px"
viewBox="0 -960 960 960"
width="20px"
fill="currentColor"
{...props}
>
<path d="M220-60 80-200l140-140 57 56-44 44h494l-43-44 56-56 140 140L740-60l-57-56 44-44H233l43 44-56 56Zm260-460q-50 0-85-35t-35-85q0-50 35-85t85-35q50 0 85 35t35 85q0 50-35 85t-85 35ZM200-400q-33 0-56.5-23.5T120-480v-320q0-33 23.5-56.5T200-880h560q33 0 56.5 23.5T840-800v320q0 33-23.5 56.5T760-400H200Zm80-80h400q0-33 23.5-56.5T760-560v-160q-33 0-56.5-23.5T680-800H280q0 33-23.5 56.5T200-720v160q33 0 56.5 23.5T280-480Zm-80 0v-320 320Z" />
+46 -4
View File
@@ -1,7 +1,15 @@
"use client";
import { cn } from "@/lib/utils";
import React, { useCallback, useEffect, useId, useRef, useState } from "react";
import React, {
useCallback,
useEffect,
useId,
useLayoutEffect,
useRef,
useState,
} from "react";
import { createPortal } from "react-dom";
import {
type FieldErrors,
type FieldValues,
@@ -59,11 +67,40 @@ function MultiSelect<T extends FieldValues>({
const [selectedItems, setSelectedItems] = useState<Option[]>([]);
const [searchQuery, setSearchQuery] = useState("");
const [show, setShow] = useState(false);
const [dropdownStyle, setDropdownStyle] = useState<React.CSSProperties>({});
const [portalReady, setPortalReady] = useState(false);
const dropdownRef = useRef<HTMLDivElement>(null);
const listRef = useRef<HTMLDivElement>(null);
const trigger = useRef<HTMLDivElement | null>(null);
const initializedRef = useRef(false);
useEffect(() => {
setPortalReady(typeof document !== "undefined");
}, []);
const updateDropdownPosition = useCallback(() => {
if (!trigger.current) return;
const rect = trigger.current.getBoundingClientRect();
setDropdownStyle({
position: "fixed",
top: rect.bottom + 4,
left: rect.left,
width: rect.width,
});
}, []);
useLayoutEffect(() => {
if (!show) return;
updateDropdownPosition();
const handle = () => updateDropdownPosition();
window.addEventListener("resize", handle);
window.addEventListener("scroll", handle, true);
return () => {
window.removeEventListener("resize", handle);
window.removeEventListener("scroll", handle, true);
};
}, [show, updateDropdownPosition]);
// Initialize selectedItems from external value + items (edit mode)
useEffect(() => {
if (initializedRef.current) return;
@@ -238,10 +275,13 @@ function MultiSelect<T extends FieldValues>({
</div>
</div>
{portalReady &&
show &&
createPortal(
<div
style={dropdownStyle}
className={cn(
"absolute left-0 top-full z-40 mt-1 w-full rounded-lg border border-stroke bg-white shadow-lg dark:border-dark-3 dark:bg-dark-2",
!show && "hidden",
"z-[1000000] rounded-lg border border-stroke bg-white shadow-lg dark:border-dark-3 dark:bg-dark-2",
)}
ref={dropdownRef}
>
@@ -314,7 +354,9 @@ function MultiSelect<T extends FieldValues>({
</>
)}
</div>
</div>
</div>,
document.body,
)}
</div>
{error && (
+185 -14
View File
@@ -1,6 +1,14 @@
import { CheckIcon, XIcon } from "@/assets/icons";
import { cn } from "@/lib/utils";
import { ChangeEvent, ToggleEvent, useId } from "react";
import gsap from "gsap";
import {
ChangeEvent,
ToggleEvent,
useEffect,
useId,
useLayoutEffect,
useRef,
} from "react";
type PropsType = {
withIcon?: boolean;
@@ -24,11 +32,147 @@ export function Switch({
onChange,
}: PropsType) {
const id = useId();
const isSm = backgroundSize === "sm";
const isDark = background === "dark";
const TRAVEL = isSm ? 28 : 24;
const thumbRef = useRef<HTMLDivElement>(null);
const trackRef = useRef<HTMLDivElement>(null);
const glowRef = useRef<HTMLDivElement>(null);
const checkWrapRef = useRef<HTMLSpanElement>(null);
const xWrapRef = useRef<HTMLSpanElement>(null);
const initialized = useRef(false);
const activeTweens = useRef<gsap.core.Tween[]>([]);
useLayoutEffect(() => {
if (thumbRef.current) {
gsap.set(thumbRef.current, { x: checked ? TRAVEL : 0 });
}
if (withIcon && checkWrapRef.current && xWrapRef.current) {
gsap.set(checkWrapRef.current, {
autoAlpha: checked ? 1 : 0,
rotateY: 0,
});
gsap.set(xWrapRef.current, {
autoAlpha: checked ? 0 : 1,
rotateY: 0,
});
}
if (glowRef.current) {
gsap.set(glowRef.current, { autoAlpha: 0, scale: 0.6 });
}
initialized.current = true;
// run once on mount
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
useEffect(() => {
if (!initialized.current) return;
if (!thumbRef.current || !trackRef.current) return;
// Kill in-flight tweens WITHOUT reverting — new tweens will start
// from wherever the element currently sits, so toggling mid-animation
// (and toggling ON→OFF after ON has settled) feels symmetric.
activeTweens.current.forEach((t) => t.kill());
activeTweens.current = [];
const tl = gsap.timeline();
tl.to(thumbRef.current, {
scaleY: 0.82,
scaleX: 1.18,
duration: 0.12,
ease: "power2.out",
})
.to(
thumbRef.current,
{
x: checked ? TRAVEL : 0,
duration: 0.4,
ease: "back.out(2.2)",
},
"<",
)
.to(
thumbRef.current,
{
scaleY: 1,
scaleX: 1,
duration: 0.5,
ease: "elastic.out(1, 0.45)",
},
"-=0.2",
);
activeTweens.current.push(tl as unknown as gsap.core.Tween);
activeTweens.current.push(
gsap.fromTo(
trackRef.current,
{ scale: 1 },
{
scale: 1.05,
duration: 0.14,
ease: "power2.out",
yoyo: true,
repeat: 1,
},
),
);
if (checked && glowRef.current) {
activeTweens.current.push(
gsap.fromTo(
glowRef.current,
{ autoAlpha: 0.85, scale: 0.5 },
{
autoAlpha: 0,
scale: 1.9,
duration: 0.7,
ease: "power2.out",
},
),
);
}
if (withIcon && checkWrapRef.current && xWrapRef.current) {
const show = checked ? checkWrapRef.current : xWrapRef.current;
const hide = checked ? xWrapRef.current : checkWrapRef.current;
activeTweens.current.push(
gsap.to(hide, {
rotateY: 90,
autoAlpha: 0,
duration: 0.18,
ease: "power2.in",
}),
);
activeTweens.current.push(
gsap.fromTo(
show,
{ rotateY: -90, autoAlpha: 0 },
{
rotateY: 0,
autoAlpha: 1,
duration: 0.32,
ease: "back.out(2)",
delay: 0.16,
},
),
);
}
}, [checked, TRAVEL, withIcon]);
useEffect(() => {
return () => {
activeTweens.current.forEach((t) => t.kill());
activeTweens.current = [];
};
}, []);
return (
<label
htmlFor={id}
className="flex max-w-fit cursor-pointer select-none items-center"
className="group/switch flex max-w-fit cursor-pointer select-none items-center"
>
<div className="relative">
<input
@@ -41,27 +185,54 @@ export function Switch({
checked={checked}
data-cy={dataCy}
/>
{/* Track */}
<div
className={cn("h-8 w-14 rounded-full bg-gray-3 dark:bg-[#5A616B]", {
"h-5": backgroundSize === "sm",
"bg-[#212B36] dark:bg-primary": background === "dark",
})}
ref={trackRef}
className={cn(
"h-8 w-14 origin-center rounded-full bg-gradient-to-br from-gray-3 to-gray-4 shadow-[inset_0_1px_2px_rgba(0,0,0,0.12)] transition-[background-image,box-shadow] duration-300 dark:from-[#5A616B] dark:to-[#3F4651]",
"peer-checked:bg-gradient-to-br peer-checked:from-primary peer-checked:to-primary/80 peer-checked:shadow-[0_0_0_4px_rgba(91,93,185,0.18),inset_0_1px_2px_rgba(0,0,0,0.12)] peer-focus-visible:ring-2 peer-focus-visible:ring-primary/40",
"group-active/switch:scale-[0.97]",
isSm && "h-5",
isDark &&
"from-[#212B36] to-[#0F1620] dark:from-primary dark:to-primary/80",
)}
/>
{/* Glow burst (only fires when turning ON) */}
<div
ref={glowRef}
className="pointer-events-none absolute inset-0 rounded-full bg-primary opacity-0"
style={{ filter: "blur(10px)" }}
/>
{/* Thumb */}
<div
ref={thumbRef}
className={cn(
"absolute left-1 top-1 flex size-6 items-center justify-center rounded-full bg-white shadow-switch-1 transition peer-checked:right-1 peer-checked:translate-x-full peer-checked:[&_.check-icon]:block peer-checked:[&_.x-icon]:hidden",
{
"-top-1 left-0 size-7 shadow-switch-2": backgroundSize === "sm",
"peer-checked:bg-primary peer-checked:dark:bg-primary":
background !== "dark",
},
"pointer-events-none absolute left-1 top-1 flex size-6 items-center justify-center rounded-full bg-gradient-to-br from-white to-gray-2 shadow-switch-1 ring-1 ring-black/5",
"peer-checked:from-white peer-checked:to-white peer-checked:shadow-[0_2px_6px_rgba(0,0,0,0.18),0_0_0_1px_rgba(91,93,185,0.25)]",
"group-active/switch:scale-95",
isSm && "-top-1 left-0 size-7 shadow-switch-2",
)}
style={{ perspective: 400 }}
>
{withIcon && (
<>
<CheckIcon className="check-icon hidden fill-white" />
<XIcon className="x-icon" />
<span
ref={checkWrapRef}
className="absolute inline-flex items-center justify-center"
style={{ transformStyle: "preserve-3d", backfaceVisibility: "hidden" }}
>
<CheckIcon className="h-3 w-3 fill-primary text-primary" />
</span>
<span
ref={xWrapRef}
className="absolute inline-flex items-center justify-center"
style={{ transformStyle: "preserve-3d", backfaceVisibility: "hidden" }}
>
<XIcon className="h-3 w-3 text-gray-5" />
</span>
</>
)}
</div>
+80 -2
View File
@@ -26,6 +26,30 @@ import { Tooltip } from "react-tooltip";
import CompanyListFilters from "./CompanyListFilters";
import { useCompanyListPresenter } from "./useCompanyListPresenter";
const CURRENCY_META: Record<string, { symbol: string; label: string }> = {
USD: { symbol: "$", label: "US Dollar" },
EUR: { symbol: "€", label: "Euro" },
GBP: { symbol: "£", label: "British Pound" },
JPY: { symbol: "¥", label: "Japanese Yen" },
CAD: { symbol: "C$", label: "Canadian Dollar" },
AUD: { symbol: "A$", label: "Australian Dollar" },
CHF: { symbol: "₣", label: "Swiss Franc" },
CNY: { symbol: "¥", label: "Chinese Yuan" },
INR: { symbol: "₹", label: "Indian Rupee" },
BRL: { symbol: "R$", label: "Brazilian Real" },
};
const LANGUAGE_META: Record<string, { flag: string; label: string }> = {
en: { flag: "🇺🇸", label: "English" },
ar: { flag: "🇦🇪", label: "Arabic" },
fr: { flag: "🇫🇷", label: "French" },
es: { flag: "🇪🇸", label: "Spanish" },
de: { flag: "🇩🇪", label: "German" },
zh: { flag: "🇨🇳", label: "Chinese" },
ja: { flag: "🇯🇵", label: "Japanese" },
ko: { flag: "🇰🇷", label: "Korean" },
};
const CompaniesTable = () => {
const {
isPending,
@@ -121,10 +145,64 @@ const CompaniesTable = () => {
{company.address.state}
</TableCell>
<TableCell className="text-start" colSpan={100}>
{company.language}
{(() => {
const code = company.language?.toLowerCase?.() ?? "";
const meta = LANGUAGE_META[code];
if (!meta) {
return (
<span className="inline-flex items-center gap-1 rounded-full border border-dashed border-stroke px-2.5 py-1 text-xs font-medium text-dark-5 dark:border-dark-3 dark:text-dark-6">
</span>
);
}
return (
<span
data-tooltip-id={`lang-${company.id}`}
data-tooltip-content={meta.label}
className="inline-flex items-center gap-1.5 rounded-full border border-indigo-500/20 bg-indigo-500/10 px-2.5 py-1 text-xs font-semibold uppercase tracking-wide text-indigo-600 transition-colors hover:bg-indigo-500/20 dark:border-indigo-400/30 dark:bg-indigo-400/15 dark:text-indigo-200"
>
<span className="text-sm leading-none">
{meta.flag}
</span>
{code}
<Tooltip
{..._TooltipDefaultParams({
id: `lang-${company.id}`,
})}
/>
</span>
);
})()}
</TableCell>
<TableCell className="text-start" colSpan={100}>
{company.currency}
{(() => {
const code = company.currency?.toUpperCase?.() ?? "";
const meta = CURRENCY_META[code];
if (!meta) {
return (
<span className="inline-flex items-center gap-1 rounded-full border border-dashed border-stroke px-2.5 py-1 text-xs font-medium text-dark-5 dark:border-dark-3 dark:text-dark-6">
</span>
);
}
return (
<span
data-tooltip-id={`currency-${company.id}`}
data-tooltip-content={meta.label}
className="inline-flex items-center gap-1.5 rounded-full border border-emerald-500/20 bg-emerald-500/10 px-2.5 py-1 text-xs font-semibold tracking-wide text-emerald-600 transition-colors hover:bg-emerald-500/20 dark:border-emerald-400/30 dark:bg-emerald-400/15 dark:text-emerald-200"
>
<span className="flex h-4 w-4 items-center justify-center rounded-full bg-emerald-500/20 text-[10px] font-bold leading-none dark:bg-emerald-400/25">
{meta.symbol}
</span>
{code}
<Tooltip
{..._TooltipDefaultParams({
id: `currency-${company.id}`,
})}
/>
</span>
);
})()}
</TableCell>
<TableCell className="text-start" colSpan={100}>
{company.employee_count}
@@ -1,18 +1,12 @@
import {
ChangeEvent,
Dispatch,
FC,
SetStateAction,
useEffect,
useState,
} from "react";
import { Dispatch, FC, SetStateAction, useCallback, useMemo, useState } from "react";
import { Select } from "@/components/FormElements/select";
import { Building } from "@/components/Layouts/sidebar/icons";
import { ApiResponse, TAssignCustomerToCompanyCredentials, TCompaniesResponse } from "@/lib/api";
import { ILabelValue } from "@/types/shared";
import MultiSelect from "@/components/FormElements/MultiSelect";
import {
ApiResponse,
TAssignCustomerToCompanyCredentials,
TCompaniesResponse,
} from "@/lib/api";
import { UseQueryResult } from "@tanstack/react-query";
import { useForm } from "react-hook-form";
interface IProps {
companiesQuery: UseQueryResult<ApiResponse<TCompaniesResponse>, Error>;
@@ -21,66 +15,52 @@ interface IProps {
>;
defaultValues?: TAssignCustomerToCompanyCredentials;
}
const AssignToCompanyModalContent: FC<IProps> = ({
companiesQuery,
setCompanies,
defaultValues,
}) => {
const [options, setOptions] = useState<ILabelValue[]>([]);
const { data, isSuccess } = companiesQuery;
const [selected, setSelected] = useState<string[]>(
defaultValues?.companies ?? [],
);
const { data, status, isSuccess } = companiesQuery;
const { handleSubmit, watch, setValue } =
useForm<TAssignCustomerToCompanyCredentials>({
mode: "onChange",
defaultValues,
});
const selectedCompanyId = watch("company_ids")?.[0] || "";
useEffect(() => {
if (isSuccess) {
setOptions(
data.data.companies
? data.data.companies?.map((item) => ({
const options = useMemo(() => {
if (!isSuccess) return [];
return (
data?.data.companies?.map((item) => ({
label: item.name,
value: item.id,
}))
: [],
})) ?? []
);
}
}, [status, isSuccess, data?.data.companies]);
}, [isSuccess, data?.data.companies]);
useEffect(() => {
if (defaultValues?.company_ids?.[0]) {
setValue("company_ids", defaultValues.company_ids);
}
}, [defaultValues, setValue]);
const handleChange = useCallback(
(event: { target: { value: string[] } }) => {
const next = event.target.value;
setSelected(next);
setCompanies({ companies: next });
},
[setCompanies],
);
const noop = useCallback(() => {}, []);
const assignCompany = (data: TAssignCustomerToCompanyCredentials) => {
setCompanies(data);
};
return (
<form className="mt-5" onSubmit={handleSubmit(assignCompany)}>
<Select
name="company_ids"
clearable
<div className="mt-5">
<MultiSelect
name="companies"
label="Companies"
items={options}
prefixIcon={<Building />}
placeholder="Please Select company"
value={selectedCompanyId}
onChange={
((e: ChangeEvent<HTMLSelectElement>) => {
const value = e.target.value;
setValue("company_ids", [value]);
setCompanies({
company_ids: [value],
});
}) as any
}
value={selected}
placeholder="Please select companies"
required
onChange={handleChange as any}
onBlur={noop as any}
ref={noop as any}
/>
</form>
</div>
);
};
+48 -8
View File
@@ -120,11 +120,47 @@ const CustomersTable = () => {
</TableCell>
<TableCell colSpan={100}>
{customer?.companies?.length ? (
customer?.companies.map((c) => (
<span key={c.id}>{c.name}, </span>
))
<div className="flex max-w-[260px] flex-wrap items-center gap-1.5">
{customer.companies.slice(0, 2).map((c) => (
<span
key={c.id}
data-tooltip-id={`company-${customer.id}-${c.id}`}
data-tooltip-content={c.name}
className="inline-flex max-w-[140px] items-center gap-1.5 rounded-full border border-primary/20 bg-primary/10 px-2.5 py-1 text-xs font-medium text-primary transition-colors hover:bg-primary/20 dark:border-primary/30 dark:bg-primary/20 dark:text-white"
>
<Building className="h-3 w-3 shrink-0" />
<span className="truncate">{c.name}</span>
<Tooltip
{..._TooltipDefaultParams({
id: `company-${customer.id}-${c.id}`,
})}
/>
</span>
))}
{customer.companies.length > 2 && (
<>
<span
data-tooltip-id={`company-more-${customer.id}`}
data-tooltip-content={customer.companies
.slice(2)
.map((c) => c.name)
.join(", ")}
className="inline-flex cursor-default items-center rounded-full border border-stroke bg-gray-2 px-2.5 py-1 text-xs font-semibold text-dark transition-colors hover:bg-gray-3 dark:border-dark-3 dark:bg-dark-3 dark:text-white"
>
+{customer.companies.length - 2}
</span>
<Tooltip
{..._TooltipDefaultParams({
id: `company-more-${customer.id}`,
})}
/>
</>
)}
</div>
) : (
<span>None</span>
<span className="inline-flex items-center gap-1 rounded-full border border-dashed border-stroke px-2.5 py-1 text-xs font-medium text-dark-5 dark:border-dark-3 dark:text-dark-6">
None
</span>
)}
</TableCell>
<TableCell colSpan={100} className="uppercase">
@@ -168,7 +204,8 @@ const CustomersTable = () => {
setCurrentCustomer(customer);
setIsAssignCompanyModalOpen(true);
setSelectedCompanies({
company_ids: [customer?.companies?.[0]?.id ?? ""],
companies:
customer?.companies?.map((c) => c.id) ?? [],
});
}}
>
@@ -210,12 +247,15 @@ const CustomersTable = () => {
<Modal
classNames="w-full xl:w-1/2"
onConfirm={() => {
if (!selectedCompanies || !currentCustomer) {
toast.error("Invalid credentials");
if (
!currentCustomer ||
!selectedCompanies?.companies?.length
) {
toast.error("Please select at least one company");
return;
}
assignSelectedCompanies({
credentials: selectedCompanies,
credentials: { companies: selectedCompanies.companies },
id: currentCustomer?.id ?? "",
});
}}
+246 -51
View File
@@ -1,6 +1,14 @@
"use client";
import { CheckIcon, ExclamationIcon } from "@/assets/icons";
import { cn } from "@/lib/utils";
import { useIsMutating } from "@tanstack/react-query";
import React, { useEffect, useState } from "react";
import gsap from "gsap";
import React, {
useEffect,
useLayoutEffect,
useRef,
useState,
} from "react";
import { createPortal } from "react-dom";
import { z } from "zod";
@@ -23,6 +31,64 @@ export type ConfirmationModalProps = z.infer<
typeof ConfirmationModalPropsSchema
>;
type Variant = "default" | "danger" | "warning";
const variantStyles: Record<
Variant,
{
accent: string;
iconWrap: string;
iconGlow: string;
iconColor: string;
confirmBtn: string;
focusRing: string;
Icon: React.ComponentType<React.SVGProps<SVGSVGElement>>;
}
> = {
default: {
accent:
"bg-gradient-to-r from-primary via-[#6B63F3] to-primary",
iconWrap:
"bg-gradient-to-br from-primary/15 via-primary/10 to-primary/5 ring-1 ring-inset ring-primary/20",
iconGlow: "bg-primary/30",
iconColor: "text-primary",
confirmBtn:
"bg-gradient-to-br from-primary to-[#4C46C9] hover:from-primary hover:to-primary text-white shadow-[0_6px_18px_-4px_rgba(87,80,241,0.55)]",
focusRing: "focus-visible:ring-primary/40",
Icon: CheckIcon,
},
danger: {
accent:
"bg-gradient-to-r from-error via-error-light to-error",
iconWrap:
"bg-gradient-to-br from-error/15 via-error/10 to-error/5 ring-1 ring-inset ring-error/20",
iconGlow: "bg-error/35",
iconColor: "text-error",
confirmBtn:
"bg-gradient-to-br from-error to-error-dark hover:from-error hover:to-error text-white shadow-[0_6px_18px_-4px_rgba(242,48,48,0.55)]",
focusRing: "focus-visible:ring-error/40",
Icon: ExclamationIcon,
},
warning: {
accent:
"bg-gradient-to-r from-yellow-dark via-yellow-light to-yellow-dark",
iconWrap:
"bg-gradient-to-br from-yellow-light/40 via-yellow-light/25 to-yellow-light/10 ring-1 ring-inset ring-yellow-dark/20",
iconGlow: "bg-yellow-dark/30",
iconColor: "text-yellow-dark-2",
confirmBtn:
"bg-gradient-to-br from-yellow-dark to-yellow-dark-2 hover:from-yellow-dark hover:to-yellow-dark text-white shadow-[0_6px_18px_-4px_rgba(217,119,6,0.5)]",
focusRing: "focus-visible:ring-yellow-dark/40",
Icon: ExclamationIcon,
},
};
const sizeStyles: Record<"sm" | "md" | "lg", string> = {
sm: "max-w-sm",
md: "max-w-md",
lg: "max-w-lg",
};
const ConfirmationModal: React.FC<ConfirmationModalProps> = (props) => {
const isMutating = useIsMutating();
const validatedProps = ConfirmationModalPropsSchema.parse(props);
@@ -39,12 +105,27 @@ const ConfirmationModal: React.FC<ConfirmationModalProps> = (props) => {
} = validatedProps;
const [mounted, setMounted] = useState(false);
const [shouldRender, setShouldRender] = useState(isOpen);
const overlayRef = useRef<HTMLDivElement>(null);
const panelRef = useRef<HTMLDivElement>(null);
const iconRef = useRef<HTMLDivElement>(null);
const iconGlowRef = useRef<HTMLDivElement>(null);
const activeTween = useRef<gsap.core.Timeline | null>(null);
useEffect(() => {
setMounted(true);
return () => setMounted(false);
}, []);
// Mount/unmount gating so exit animation can play before unmount.
useEffect(() => {
if (isOpen) {
setShouldRender(true);
}
}, [isOpen]);
// Escape + body scroll lock
useEffect(() => {
const handleEscape = (event: KeyboardEvent) => {
if (event.key === "Escape" && isOpen) {
@@ -63,100 +144,214 @@ const ConfirmationModal: React.FC<ConfirmationModalProps> = (props) => {
};
}, [isOpen, onCancel]);
if (!mounted || !isOpen) return null;
// Drive entrance/exit animations
useLayoutEffect(() => {
if (!shouldRender) return;
if (!overlayRef.current || !panelRef.current) return;
const variantStyles = {
default: {
confirmBtn: "bg-primary hover:bg-primary/90 focus:ring-primary",
icon: "🔔",
iconBg: "bg-blue-100",
activeTween.current?.kill();
if (isOpen) {
gsap.set(overlayRef.current, { autoAlpha: 0 });
gsap.set(panelRef.current, {
autoAlpha: 0,
scale: 0.9,
y: 24,
});
if (iconRef.current) {
gsap.set(iconRef.current, { scale: 0, rotation: -90 });
}
if (iconGlowRef.current) {
gsap.set(iconGlowRef.current, { autoAlpha: 0, scale: 0.4 });
}
const tl = gsap.timeline();
tl.to(overlayRef.current, {
autoAlpha: 1,
duration: 0.28,
ease: "power2.out",
})
.to(
panelRef.current,
{
autoAlpha: 1,
scale: 1,
y: 0,
duration: 0.55,
ease: "back.out(1.5)",
},
danger: {
confirmBtn: "bg-red-light hover:bg-red-light/90 focus:ring-red-light",
icon: "⚠️",
iconBg: "bg-red-light/20",
"-=0.18",
)
.to(
iconRef.current,
{
scale: 1,
rotation: 0,
duration: 0.55,
ease: "back.out(2.4)",
},
warning: {
confirmBtn:
"bg-yellow-dark hover:bg-yellow-dark/90 focus:ring-yellow-dark",
icon: "⚠️",
iconBg: "bg-yellow-light/20",
"-=0.32",
)
.fromTo(
iconGlowRef.current,
{ autoAlpha: 0.7, scale: 0.5 },
{
autoAlpha: 0,
scale: 1.8,
duration: 0.9,
ease: "power2.out",
},
"-=0.4",
);
activeTween.current = tl;
} else {
const tl = gsap.timeline({
onComplete: () => setShouldRender(false),
});
tl.to(panelRef.current, {
autoAlpha: 0,
scale: 0.94,
y: 12,
duration: 0.22,
ease: "power2.in",
}).to(
overlayRef.current,
{
autoAlpha: 0,
duration: 0.22,
ease: "power2.in",
},
"-=0.18",
);
activeTween.current = tl;
}
return () => {
activeTween.current?.kill();
};
}, [isOpen, shouldRender]);
const sizeStyles = {
sm: "max-w-sm",
md: "max-w-md",
lg: "max-w-lg",
};
if (!mounted || !shouldRender) return null;
const currentVariant = variantStyles[variant];
const v = variantStyles[variant];
const VariantIcon = v.Icon;
const currentSize = sizeStyles[size];
const isBusy = isMutating > 0;
return createPortal(
<div
className="fixed inset-0 z-50 overflow-y-auto"
className="fixed inset-0 z-[999999] overflow-y-auto"
aria-labelledby="modal-title"
role="dialog"
aria-modal="true"
data-cy="confirmation-modal"
>
<div
className="fixed inset-0 bg-dark/70 bg-opacity-75 transition-opacity"
onClick={onCancel}
ref={overlayRef}
aria-hidden
className="pointer-events-none fixed inset-0 bg-gradient-to-br from-dark/80 via-dark/70 to-black/85 backdrop-blur-md"
data-cy="confirmation-modal-overlay"
/>
<div className="flex min-h-full items-end justify-center p-4 text-center sm:items-center sm:p-0">
<div
className={`relative transform overflow-hidden rounded-lg bg-white text-left shadow-xl transition-all sm:my-8 sm:w-full ${currentSize} dark:bg-dark-2`}
className="relative flex min-h-full items-end justify-center p-4 sm:items-center sm:p-0"
onClick={(e) => {
if (e.target === e.currentTarget) onCancel();
}}
data-cy="confirmation-modal-outside"
>
<div className="bg-white px-4 pb-4 pt-5 dark:bg-dark-2 sm:p-6 sm:pb-4">
<div className="sm:flex sm:items-start">
<div
className={`mx-auto flex h-12 w-12 flex-shrink-0 items-center justify-center rounded-full ${currentVariant.iconBg} sm:mx-0 sm:h-10 sm:w-10`}
ref={panelRef}
className={cn(
"relative w-full overflow-hidden rounded-2xl bg-white text-left shadow-2xl ring-1 ring-stroke/40 sm:my-8 dark:bg-dark-2 dark:ring-stroke-dark/60",
currentSize,
)}
style={{ willChange: "transform, opacity" }}
>
<span className="text-xl">{currentVariant.icon}</span>
{/* Top accent strip */}
<div className={cn("h-1 w-full", v.accent)} />
{/* Soft decorative glow blob */}
<div
aria-hidden
className={cn(
"pointer-events-none absolute -right-16 -top-16 h-40 w-40 rounded-full opacity-20 blur-3xl",
v.iconGlow,
)}
/>
<div className="px-6 pb-5 pt-7 sm:px-7">
<div className="flex flex-col items-center text-center sm:flex-row sm:items-start sm:gap-5 sm:text-left">
{/* Icon with glow burst */}
<div className="relative">
<div
ref={iconGlowRef}
aria-hidden
className={cn(
"pointer-events-none absolute inset-0 rounded-full blur-md",
v.iconGlow,
)}
/>
<div
ref={iconRef}
className={cn(
"relative flex h-14 w-14 shrink-0 items-center justify-center rounded-full shadow-inner",
v.iconWrap,
)}
>
<VariantIcon
className={cn("h-6 w-6", v.iconColor)}
/>
</div>
</div>
<div className="mt-3 text-center sm:ml-4 sm:mt-0 sm:text-left">
<div className="mt-4 sm:mt-1">
<h3
className="text-base font-semibold leading-6 text-dark dark:text-white"
id="modal-title"
className="text-lg font-semibold leading-7 tracking-tight text-dark dark:text-white"
>
{title}
</h3>
<div className="mt-2">
<p className="text-sm text-dark-5 dark:text-dark-6">
<p className="mt-1.5 text-sm leading-relaxed text-dark-5 dark:text-dark-6">
{message}
</p>
</div>
</div>
</div>
</div>
<div className="bg-gray px-4 py-3 dark:border-t dark:border-stroke-dark dark:bg-dark-2 sm:flex sm:flex-row-reverse sm:px-6">
{/* Divider */}
<div className="h-px w-full bg-gradient-to-r from-transparent via-stroke to-transparent dark:via-stroke-dark" />
{/* Actions */}
<div className="flex flex-col-reverse gap-2.5 bg-gray-1/60 px-6 py-4 backdrop-blur-sm dark:bg-dark/30 sm:flex-row sm:justify-end sm:gap-3 sm:px-7">
<button
type="button"
className={`inline-flex w-full justify-center rounded-md px-3 py-2 text-sm font-semibold text-white shadow-sm focus:outline-none focus:ring-2 focus:ring-offset-2 sm:ml-3 sm:w-auto ${currentVariant.confirmBtn} dark:ring-offset-dark-2`}
disabled={isMutating > 0}
onClick={onConfirm}
data-cy="confirmation-modal-confirm"
>
{isMutating ? (
<div className="h-5 w-5 animate-spin rounded-full border-b-2 border-gray-1"></div>
) : (
confirmText
)}
</button>
<button
type="button"
className="mt-3 inline-flex w-full justify-center rounded-md bg-white px-3 py-2 text-sm font-semibold text-dark shadow-sm ring-1 ring-inset ring-stroke hover:bg-gray focus:outline-none focus:ring-2 focus:ring-dark-5 focus:ring-offset-2 dark:bg-dark-3 dark:text-white dark:ring-stroke-dark dark:hover:bg-dark-4 dark:focus:ring-offset-dark-2 sm:mt-0 sm:w-auto"
className="inline-flex w-full items-center justify-center rounded-lg bg-white px-4 py-2.5 text-sm font-semibold text-dark shadow-sm ring-1 ring-inset ring-stroke transition-all duration-200 hover:-translate-y-0.5 hover:bg-gray-1 hover:shadow-md focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-dark-5/40 active:translate-y-0 dark:bg-dark-3 dark:text-white dark:ring-stroke-dark dark:hover:bg-dark-4 sm:w-auto"
onClick={onCancel}
data-cy="confirmation-modal-cancel"
>
{cancelText}
</button>
<button
type="button"
disabled={isBusy}
onClick={onConfirm}
data-cy="confirmation-modal-confirm"
className={cn(
"group inline-flex w-full items-center justify-center gap-2 overflow-hidden rounded-lg px-4 py-2.5 text-sm font-semibold transition-all duration-200 hover:-translate-y-0.5 hover:brightness-110 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-offset-2 active:translate-y-0 disabled:cursor-not-allowed disabled:opacity-70 disabled:hover:translate-y-0 dark:focus-visible:ring-offset-dark-2 sm:w-auto",
v.confirmBtn,
v.focusRing,
)}
>
{isBusy ? (
<span className="relative inline-flex items-center gap-2">
<span className="h-4 w-4 animate-spin rounded-full border-2 border-white/40 border-t-white" />
Processing
</span>
) : (
<span className="relative">{confirmText}</span>
)}
</button>
</div>
</div>
</div>
+69 -7
View File
@@ -40,6 +40,8 @@ type Tone =
| "urgent"
| "featured";
type PulseKind = "ping" | "pulse" | "none";
function resolveTone(status: string): Tone {
if (greens.includes(status)) return "green";
if (reds.includes(status)) return "red";
@@ -56,80 +58,140 @@ function resolveTone(status: string): Tone {
const toneStyles: Record<
Tone,
{ root: string; dot: string; dotRing?: string }
{
root: string;
dot: string;
dotRing?: string;
pingColor?: string;
pulse: PulseKind;
shimmer: boolean;
}
> = {
green: {
root: "bg-gradient-to-br from-green via-green to-green-dark text-white shadow-[0_2px_10px_-2px_rgba(34,173,92,0.55)] ring-1 ring-inset ring-white/25",
dot: "bg-white shadow-[0_0_10px_rgba(255,255,255,0.85)]",
dotRing: "ring-1 ring-white/40",
pingColor: "bg-white/70",
pulse: "ping",
shimmer: true,
},
red: {
root: "bg-gradient-to-br from-error via-error to-error-dark text-white shadow-[0_2px_10px_-2px_rgba(242,48,48,0.5)] ring-1 ring-inset ring-white/20",
dot: "bg-white shadow-[0_0_10px_rgba(255,255,255,0.75)]",
dotRing: "ring-1 ring-white/35",
pingColor: "bg-white/70",
pulse: "pulse",
shimmer: true,
},
blue: {
root: "bg-gradient-to-br from-blue via-blue to-blue-dark text-white shadow-[0_2px_10px_-2px_rgba(60,80,224,0.45)] ring-1 ring-inset ring-white/25",
dot: "bg-white shadow-[0_0_10px_rgba(255,255,255,0.8)]",
dotRing: "ring-1 ring-white/35",
pingColor: "bg-white/70",
pulse: "ping",
shimmer: true,
},
yellow: {
root: "border border-yellow-dark-2/15 bg-gradient-to-br from-yellow-light to-yellow-light/70 text-yellow-dark-2 shadow-sm shadow-yellow-dark/10 ring-1 ring-inset ring-white/60",
dot: "bg-yellow-dark-2 shadow-[0_0_8px_rgba(217,119,6,0.45)]",
dotRing: "ring-2 ring-yellow-dark-2/25",
pingColor: "bg-yellow-dark-2/50",
pulse: "pulse",
shimmer: false,
},
orange: {
root: "bg-gradient-to-br from-orange-light to-orange-light/85 text-white shadow-[0_2px_10px_-2px_rgba(245,148,96,0.45)] ring-1 ring-inset ring-white/25",
dot: "bg-white shadow-[0_0_8px_rgba(255,255,255,0.7)]",
dotRing: "ring-1 ring-white/30",
pingColor: "bg-white/70",
pulse: "pulse",
shimmer: true,
},
gray: {
root: "border border-gray-4/60 bg-gradient-to-b from-gray-1 to-gray-2 text-gray-7 shadow-sm ring-1 ring-inset ring-white/80",
dot: "bg-gray-5 shadow-inner",
dotRing: "ring-1 ring-gray-4/50",
pulse: "none",
shimmer: false,
},
new: {
root: "border border-green-light-3/80 bg-gradient-to-br from-green-light-6 via-green-light-6 to-green-light-5 text-green-dark shadow-sm shadow-green/10 ring-1 ring-inset ring-white/70",
dot: "bg-green shadow-[0_0_8px_rgba(34,173,92,0.5)]",
dotRing: "ring-2 ring-green/20",
pingColor: "bg-green/60",
pulse: "ping",
shimmer: false,
},
info: {
root: "border border-blue-light-3/80 bg-gradient-to-br from-blue-light-5 via-blue-light-5 to-blue-light-4 text-blue-dark shadow-sm shadow-blue/10 ring-1 ring-inset ring-white/70",
dot: "bg-blue shadow-[0_0_8px_rgba(60,80,224,0.45)]",
dotRing: "ring-2 ring-blue/15",
pingColor: "bg-blue/60",
pulse: "ping",
shimmer: false,
},
urgent: {
root: "border border-error-light-3/90 bg-gradient-to-br from-error-light-6 via-error-light-6 to-error-light-5 text-error-dark shadow-sm shadow-error/10 ring-1 ring-inset ring-white/70",
dot: "bg-error shadow-[0_0_8px_rgba(242,48,48,0.45)]",
dotRing: "ring-2 ring-error/20",
pingColor: "bg-error/60",
pulse: "pulse",
shimmer: false,
},
featured: {
root: "bg-gradient-to-br from-primary via-[#6B63F3] to-primary text-white shadow-[0_2px_12px_-2px_rgba(87,80,241,0.55)] ring-1 ring-inset ring-white/30",
dot: "bg-white shadow-[0_0_12px_rgba(255,255,255,0.9)]",
dotRing: "ring-1 ring-white/40",
pingColor: "bg-white/70",
pulse: "ping",
shimmer: true,
},
};
const Status = ({ status = "draft", children }: IProps) => {
const tone = resolveTone(status);
const { root, dot, dotRing } = toneStyles[tone];
const { root, dot, dotRing, pingColor, pulse, shimmer } = toneStyles[tone];
return (
<span
className={cn(
"inline-flex max-w-full items-center justify-center gap-2 rounded-full px-3.5 py-1.5 text-center text-xs font-semibold capitalize tracking-wide",
"group/status relative inline-flex max-w-full items-center justify-center gap-2 overflow-hidden rounded-full px-3.5 py-1.5 text-center text-xs font-semibold capitalize tracking-wide transition-all duration-300 ease-out",
"hover:-translate-y-[1px] hover:brightness-110 motion-reduce:hover:translate-y-0",
root,
)}
>
{/* Shimmer sweep */}
{shimmer && (
<span
aria-hidden
className="pointer-events-none absolute inset-y-0 -left-1/3 w-1/3 -skew-x-12 bg-gradient-to-r from-transparent via-white/30 to-transparent motion-safe:animate-[shimmer_3.2s_ease-in-out_infinite] motion-reduce:hidden"
/>
)}
{/* Dot + radar ping */}
<span
aria-hidden
className="relative inline-flex h-2 w-2 shrink-0 items-center justify-center"
>
{pulse === "ping" && pingColor && (
<span
className={cn(
"relative inline-flex h-2 w-2 shrink-0 rounded-full",
dot,
dotRing,
"absolute inline-flex h-full w-full rounded-full opacity-75 motion-safe:animate-ping motion-reduce:hidden",
pingColor,
)}
/>
<span className="min-w-0 truncate">{children}</span>
)}
<span
className={cn(
"relative inline-flex h-2 w-2 rounded-full",
dot,
dotRing,
pulse === "pulse" && "motion-safe:animate-pulse",
)}
/>
</span>
<span className="relative min-w-0 truncate">{children}</span>
</span>
);
};
+67 -5
View File
@@ -1,6 +1,58 @@
import { CSSProperties } from "react";
import { VariantType } from "react-tooltip";
type Palette = {
from: string;
to: string;
shadow: string;
border: string;
};
const variantPalettes: Record<string, Palette> = {
error: {
from: "#F23030",
to: "#B00B0B",
shadow:
"0 10px 30px -10px rgba(242,48,48,0.55), inset 0 1px 0 rgba(255,255,255,0.18)",
border: "rgba(255,255,255,0.14)",
},
info: {
from: "#5750F1",
to: "#3B36B3",
shadow:
"0 10px 30px -10px rgba(87,80,241,0.55), inset 0 1px 0 rgba(255,255,255,0.2)",
border: "rgba(255,255,255,0.16)",
},
success: {
from: "#22AD5C",
to: "#147F3D",
shadow:
"0 10px 30px -10px rgba(34,173,92,0.55), inset 0 1px 0 rgba(255,255,255,0.2)",
border: "rgba(255,255,255,0.16)",
},
warning: {
from: "#FBB040",
to: "#D97706",
shadow:
"0 10px 30px -10px rgba(217,119,6,0.55), inset 0 1px 0 rgba(255,255,255,0.2)",
border: "rgba(255,255,255,0.16)",
},
dark: {
from: "#1F2937",
to: "#0B1220",
shadow:
"0 10px 30px -10px rgba(0,0,0,0.65), inset 0 1px 0 rgba(255,255,255,0.08)",
border: "rgba(255,255,255,0.08)",
},
light: {
from: "#FFFFFF",
to: "#F1F5F9",
shadow:
"0 10px 30px -10px rgba(0,0,0,0.18), inset 0 1px 0 rgba(255,255,255,0.6)",
border: "rgba(15,23,42,0.06)",
},
};
export const _TooltipDefaultParams = ({
id,
variant = id === "delete" ? "error" : "info",
@@ -9,10 +61,20 @@ export const _TooltipDefaultParams = ({
id: string;
variant?: VariantType;
styles?: CSSProperties;
}) => ({
}) => {
const palette = variantPalettes[variant] ?? variantPalettes.info;
return {
id,
delayShow: 500,
delayHide: 500,
delayShow: 220,
delayHide: 120,
variant,
style: { backgroundColor: variant === "error" ? "#F23030" : "", ...styles },
});
className: "fancy-tooltip",
style: {
background: `linear-gradient(135deg, ${palette.from} 0%, ${palette.to} 100%)`,
boxShadow: palette.shadow,
border: `1px solid ${palette.border}`,
...styles,
} satisfies CSSProperties,
};
};
+33
View File
@@ -164,6 +164,39 @@
@apply !rounded-full !transition-opacity !duration-500;
}
/* ---- Fancy tooltip ---- */
.react-tooltip.fancy-tooltip {
@apply !rounded-md !px-2 !py-0.5 !font-medium !tracking-normal !text-white;
font-size: 10.5px !important;
line-height: 1.3 !important;
z-index: 99999;
transform: scale(0.85) translateY(4px);
transform-origin: center;
transition:
opacity var(--rt-transition-show-delay, 0.15s) ease-out,
transform 260ms cubic-bezier(0.34, 1.56, 0.64, 1) !important;
backdrop-filter: blur(6px);
-webkit-backdrop-filter: blur(6px);
}
.react-tooltip.fancy-tooltip.react-tooltip__show {
transform: scale(1) translateY(0);
}
.react-tooltip.fancy-tooltip.react-tooltip__closing {
transform: scale(0.92) translateY(2px);
transition:
opacity var(--rt-transition-closing-delay, 0.15s) ease-in,
transform 200ms cubic-bezier(0.4, 0, 1, 1) !important;
}
.react-tooltip.fancy-tooltip .react-tooltip-arrow {
background: inherit;
border: inherit;
width: 6px !important;
height: 6px !important;
}
.tableCheckbox:checked ~ div {
@apply border-primary bg-primary;
}
+1 -1
View File
@@ -66,7 +66,7 @@ export const customersService = {
}): Promise<any> => {
try {
return (
await api.post(
await api.patch(
API_ENDPOINTS.CUSTOMERS.ASSIGN_COMPANY_TO_CUSTOMER(id),
credentials,
)
+1 -1
View File
@@ -100,7 +100,7 @@ export const customersListResponse = z.object({
customers: z.array(customersSchema),
});
const assignCustomerToCompanySchema = z.object({
company_ids: z.array(z.string()),
companies: z.array(z.string()).min(1),
});
export const CustomerListResponseSchema = createApiResponseSchema(
customersListResponse,