This commit is contained in:
David Arranz 2025-09-23 10:59:15 +02:00
parent 633f7cf6bd
commit 01ead56fff
17 changed files with 393 additions and 246 deletions

View File

@ -7,7 +7,7 @@ import { i18n } from "@/locales";
import { AuthProvider, createAuthService } from "@erp/auth/client"; import { AuthProvider, createAuthService } from "@erp/auth/client";
import { createAxiosDataSource, createAxiosInstance } from "@erp/core/client"; import { createAxiosDataSource, createAxiosInstance } from "@erp/core/client";
import { DataSourceProvider, UnsavedWarnProvider } from "@erp/core/hooks"; import { DataSourceProvider } from "@erp/core/hooks";
import DineroFactory from "dinero.js"; import DineroFactory from "dinero.js";
import { RouterProvider } from "react-router-dom"; import { RouterProvider } from "react-router-dom";
import { clearAccessToken, getAccessToken, setAccessToken } from "./lib"; import { clearAccessToken, getAccessToken, setAccessToken } from "./lib";
@ -55,12 +55,10 @@ export const App = () => {
}} }}
> >
<TooltipProvider delayDuration={0}> <TooltipProvider delayDuration={0}>
<UnsavedWarnProvider> {/* Fallback Route */}
{/* Fallback Route */} <Suspense fallback={<LoadingOverlay />}>
<Suspense fallback={<LoadingOverlay />}> <RouterProvider router={appRouter} />
<RouterProvider router={appRouter} /> </Suspense>
</Suspense>
</UnsavedWarnProvider>
</TooltipProvider> </TooltipProvider>
<Toaster <Toaster
toastOptions={ toastOptions={

View File

@ -30,6 +30,7 @@
"express": "^4.18.2", "express": "^4.18.2",
"http-status": "^2.1.0", "http-status": "^2.1.0",
"i18next": "^25.1.1", "i18next": "^25.1.1",
"lucide-react": "^0.503.0",
"react-hook-form": "^7.58.1", "react-hook-form": "^7.58.1",
"react-i18next": "^15.5.1", "react-i18next": "^15.5.1",
"react-router-dom": "^6.26.0", "react-router-dom": "^6.26.0",

View File

@ -1,6 +1,7 @@
{ {
"common": { "common": {
"cancel": "Cancel", "cancel": "Cancel",
"save": "Save",
"required": "•" "required": "•"
}, },
"components": { "components": {
@ -12,11 +13,11 @@
} }
}, },
"hooks": { "hooks": {
"use_unsaved_changes_notifier": { "unsaved_changes_dialog": {
"unsaved_changes": "You have unsaved changes", "title": "You have unsaved changes",
"unsaved_changes_explanation": "If you leave, your changes will be lost.", "description": "If you leave, your changes will be lost.",
"discard_changes": "Discard changes", "confirm": "Discard changes",
"stay_on_page": "Stay on page" "cancel": "Stay on page"
} }
} }
} }

View File

@ -1,6 +1,7 @@
{ {
"common": { "common": {
"cancel": "Cancelar", "cancel": "Cancelar",
"save": "Guardar",
"required": "•" "required": "•"
}, },
"components": { "components": {
@ -12,11 +13,11 @@
} }
}, },
"hooks": { "hooks": {
"use_unsaved_changes_notifier": { "unsaved_changes_dialog": {
"unsaved_changes": "Tienes cambios no guardados", "title": "Tienes cambios no guardados",
"unsaved_changes_explanation": "Si sales, tus cambios se perderán.", "description": "Si sales, tus cambios se perderán.",
"discard_changes": "Descartar cambios", "confirm": "Descartar cambios",
"stay_on_page": "Permanecer en la página" "cancel": "Permanecer en esta página"
} }
} }
} }

View File

@ -0,0 +1,66 @@
import { Button } from "@repo/shadcn-ui/components";
import * as React from "react";
import { useCallback } from "react";
import { useNavigate } from "react-router-dom";
import { useTranslation } from "../../../i18n.ts";
import { useUnsavedChangesContext } from "../use-unsaved-changes-notifier";
export type CancelFormButtonProps = {
to?: string; /// Ruta a la que navegar si no se pasa onCancel
onCancel?: () => void | Promise<void>; // Prioritaria sobre "to"
label?: string;
variant?: React.ComponentProps<typeof Button>["variant"];
size?: React.ComponentProps<typeof Button>["size"];
className?: string;
disabled?: boolean;
"data-testid"?: string;
};
export const CancelFormButton = ({
to,
onCancel,
label,
variant = "outline",
size = "default",
className,
disabled,
"data-testid": dataTestId = "cancel-button",
}: CancelFormButtonProps) => {
const navigate = useNavigate();
const { t } = useTranslation();
const defaultLabel = t ? t("common.cancel") : "Cancel";
const { requestConfirm } = useUnsavedChangesContext();
const handleClick = useCallback(async () => {
const ok = requestConfirm ? await requestConfirm() : true;
console.log("ok => ", ok);
if (!ok) return;
if (onCancel) {
await onCancel();
return;
}
if (to) {
console.log("navego => ", to);
navigate(to);
}
// si no hay ni onCancel ni to → no hace nada
}, [requestConfirm, onCancel, to, navigate]);
return (
<Button
type='button'
variant={variant}
size={size}
className={className}
onClick={handleClick}
disabled={disabled}
aria-disabled={disabled}
data-testid={dataTestId}
>
<span>{label ?? defaultLabel}</span>
</Button>
);
};

View File

@ -0,0 +1,47 @@
import { cn } from "@repo/shadcn-ui/lib/utils";
import { CancelFormButton, CancelFormButtonProps } from "./cancel-form-button";
import { SubmitButtonProps, SubmitFormButton } from "./submit-form-button";
type Align = "start" | "center" | "end" | "between";
export type FormCommitButtonGroupProps = {
className?: string;
align?: Align; // default "end"
gap?: string; // default "gap-2"
reverseOrderOnMobile?: boolean; // default true (Cancel debajo en móvil)
cancel?: CancelFormButtonProps & { show?: boolean };
submit?: SubmitButtonProps; // props directas a SubmitButton
};
const alignToJustify: Record<Align, string> = {
start: "justify-start",
center: "justify-center",
end: "justify-end",
between: "justify-between",
};
export const FormCommitButtonGroup = ({
className,
align = "end",
gap = "gap-2",
reverseOrderOnMobile = true,
cancel,
submit,
}: FormCommitButtonGroupProps) => {
const showCancel = cancel?.show ?? true;
return (
<div
className={cn(
"flex",
reverseOrderOnMobile ? "flex-col-reverse sm:flex-row" : "flex-row",
alignToJustify[align],
gap,
className
)}
>
{showCancel && <CancelFormButton {...cancel} />}
{submit && <SubmitFormButton {...submit} />}
</div>
);
};

View File

@ -0,0 +1,4 @@
export * from "./cancel-form-button";
export * from "./form-commit-button-group";
export * from "./submit-form-button";
export * from "./unsaved-changes-dialog";

View File

@ -0,0 +1,86 @@
import { Button } from "@repo/shadcn-ui/components";
import { LoaderCircleIcon } from "lucide-react";
import * as React from "react";
import { useFormContext } from "react-hook-form";
import { useTranslation } from "../../../i18n.ts";
export type SubmitButtonProps = {
formId?: string;
isLoading?: boolean;
label?: string;
variant?: React.ComponentProps<typeof Button>["variant"];
size?: React.ComponentProps<typeof Button>["size"];
className?: string;
preventDoubleSubmit?: boolean; // Evita múltiples submits mientras loading
onClick?: React.MouseEventHandler<HTMLButtonElement>;
disabled?: boolean;
children?: React.ReactNode;
"data-testid"?: string;
};
export const SubmitFormButton = ({
formId,
isLoading,
label,
variant = "default",
size = "default",
className,
preventDoubleSubmit = true,
onClick,
disabled,
children,
"data-testid": dataTestId = "submit-button",
}: SubmitButtonProps) => {
const { t } = useTranslation();
const defaultLabel = t ? t("common.save") : "Save";
// ⛳️ RHF opcional: auto-detectar isSubmitting si no se pasó isLoading
let rhfIsSubmitting = false;
try {
const ctx = useFormContext();
rhfIsSubmitting = !!ctx?.formState?.isSubmitting;
} catch {
// No hay provider de RHF; ignorar
}
const busy = isLoading ?? rhfIsSubmitting;
const computedDisabled = !!(disabled || (preventDoubleSubmit && busy));
const dataState = busy ? "loading" : "idle";
const handleClick: React.MouseEventHandler<HTMLButtonElement> = (e) => {
if (preventDoubleSubmit && busy) {
// Evitar submits duplicados durante loading
e.preventDefault();
e.stopPropagation();
return;
}
onClick?.(e);
};
return (
<Button
type='submit'
form={formId}
variant={variant}
size={size}
className={className}
disabled={computedDisabled}
aria-busy={busy}
aria-disabled={computedDisabled}
data-state={dataState}
onClick={handleClick}
data-testid={dataTestId}
>
{children ? (
children
) : (
<span className='inline-flex items-center gap-2'>
{busy && <LoaderCircleIcon className='h-4 w-4 animate-spin' aria-hidden='true' />}
<span>{label ?? defaultLabel}</span>
</span>
)}
</Button>
);
};

View File

@ -0,0 +1,29 @@
import { CustomDialog } from "@repo/rdx-ui/components";
import { useTranslation } from "../../../i18n";
type UnsavedChangesDialogProps = {
open: boolean;
onConfirm: (ok: boolean) => void;
};
export function UnsavedChangesDialog({ open, onConfirm }: UnsavedChangesDialogProps) {
const { t } = useTranslation();
const defaultTitle = t ? t("hooks.unsaved_changes_dialog.title") : "¿Descartar cambios?";
const defaultDescription = t
? t("hooks.unsaved_changes_dialog.description")
: "Tienes cambios sin guardar. Si sales de esta página, se perderán.";
const defaultCancelLabel = t ? t("hooks.unsaved_changes_dialog.cancel") : "Seguir en esta página";
const defaultConfirmLabel = t ? t("hooks.unsaved_changes_dialog.confirm") : "Descartar cambios";
return (
<CustomDialog
open={open}
onConfirm={onConfirm}
title={defaultTitle}
description={defaultDescription}
confirmLabel={defaultConfirmLabel}
cancelLabel={defaultCancelLabel}
/>
);
}

View File

@ -1,4 +1,2 @@
export * from "./components";
export * from "./use-unsaved-changes-notifier"; export * from "./use-unsaved-changes-notifier";
export * from "./use-warn-about-change";
export * from "./warn-about-change-context";
export * from "./warn-about-change-provider";

View File

@ -1,89 +1,94 @@
import { useCallback, useEffect, useMemo } from "react"; import { createContext, useCallback, useContext, useEffect, useState } from "react";
import { useBlocker } from "react-router-dom"; import { UNSAFE_NavigationContext, useBeforeUnload, useBlocker } from "react-router-dom";
import { useTranslation } from "../../i18n"; import { UnsavedChangesDialog } from "./components";
import { useWarnAboutChange } from "./use-warn-about-change";
export type UnsavedChangesNotifierProps = { type ContextValue = {
isDirty?: boolean; requestConfirm: () => Promise<boolean>;
title?: string;
subtitle?: string;
confirmText?: string;
cancelText?: string;
onConfirm?: () => void;
onCancel?: () => void;
type?: "success" | "error" | "warning" | "info";
}; };
export const useUnsavedChangesNotifier = ({ export const UnsavedChangesContext = createContext<ContextValue | null>(null);
isDirty = false,
title,
subtitle,
confirmText,
cancelText,
onConfirm,
onCancel,
type = "warning",
}: UnsavedChangesNotifierProps = {}) => {
const { t } = useTranslation();
const blocker = useBlocker(isDirty);
const { show } = useWarnAboutChange();
const texts = useMemo( export const useUnsavedChangesContext = () => {
() => ({ const context = useContext(UnsavedChangesContext);
title: title ?? t("hooks.use_unsaved_changes_notifier.unsaved_changes"), if (context === null) {
subtitle: subtitle ?? t("hooks.use_unsaved_changes_notifier.unsaved_changes_explanation"), throw new Error("useUnsavedChangesContext must be used within an UnsavedChangesProvider");
confirmText: confirmText ?? t("hooks.use_unsaved_changes_notifier.discard_changes"), }
cancelText: cancelText ?? t("hooks.use_unsaved_changes_notifier.stay_on_page"), return context;
}), };
[t, title, subtitle, confirmText, cancelText]
export function UnsavedChangesProvider({
isDirty,
children,
}: {
isDirty: boolean;
children: React.ReactNode;
}) {
const [resolver, setResolver] = useState<(ok: boolean) => void>();
const [open, setOpen] = useState(false);
// requestConfirm() devuelve una promesa que resuelve true/false
// según lo que haya pulsado el usuario
const requestConfirm = useCallback(() => {
return new Promise<boolean>((resolve) => {
setResolver(() => resolve);
setOpen(true);
});
}, []);
const handleConfirm = (ok: boolean) => {
resolver?.(ok);
setResolver(undefined);
setOpen(false);
};
// 🔹 bloquea F5/cierre de pestaña
useBeforeUnload(
(event) => {
if (isDirty) {
event.preventDefault();
}
},
{ capture: true }
); );
const confirm = useCallback(() => { // 🔹 bloquea navegación interna (react-router)
if (!isDirty) return Promise.resolve(true); const navigator = useContext(UNSAFE_NavigationContext).navigator as any;
return new Promise<boolean>((resolve) => { useEffect(() => {
show({ if (!isDirty) return;
title: texts.title,
subtitle: texts.subtitle, const push = navigator.push;
confirmText: texts.confirmText, navigator.push = async (...args: any[]) => {
cancelText: texts.cancelText, const ok = await requestConfirm();
type, if (ok) push.apply(navigator, args);
onConfirm: () => { };
resolve(true);
onConfirm?.(); return () => {
window.onbeforeunload = null; // limpirar de cambios navigator.push = push;
}, };
onCancel: () => { }, [isDirty, requestConfirm, navigator]);
resolve(false);
onCancel?.(); // 🔹 Bloquea navegación entre rutas
}, const blocker = useBlocker(() => isDirty);
});
});
}, [isDirty, show, texts, type, onConfirm, onCancel]);
useEffect(() => { useEffect(() => {
if (blocker.state === "blocked") { if (blocker.state === "blocked") {
confirm().then((result) => { (async () => {
if (result) blocker.proceed(); const ok = await requestConfirm();
else blocker.reset(); if (ok) {
}); blocker.proceed();
} else {
blocker.reset();
}
})();
} }
}, [blocker, confirm]); }, [blocker, requestConfirm]);
useEffect(() => { return (
if (isDirty) { <UnsavedChangesContext.Provider value={{ requestConfirm }}>
window.onbeforeunload = (e) => { <h1>hola</h1>
e.preventDefault(); {children}
return texts.subtitle; <UnsavedChangesDialog open={open} onConfirm={handleConfirm} />
}; </UnsavedChangesContext.Provider>
} else { );
window.onbeforeunload = null; }
}
return () => {
window.onbeforeunload = null;
};
}, [isDirty, texts.subtitle]);
return { confirm };
};

View File

@ -1,10 +0,0 @@
import { useContext } from "react";
import { UnsavedWarnContext } from "./warn-about-change-context";
export const useWarnAboutChange = () => {
const context = useContext(UnsavedWarnContext);
if (context === null) {
throw new Error("useWarnAboutChange must be used within an UnsavedWarnProvider");
}
return context;
};

View File

@ -1,10 +0,0 @@
import { NullOr } from "@repo/rdx-utils";
import { createContext } from "react";
import type { UnsavedChangesNotifierProps } from "./use-unsaved-changes-notifier";
export interface IUnsavedWarnContextState {
show: (options: NullOr<UnsavedChangesNotifierProps>) => void;
hide: () => void;
}
export const UnsavedWarnContext = createContext<NullOr<IUnsavedWarnContextState>>(null);

View File

@ -1,52 +0,0 @@
import { CustomDialog } from "@repo/rdx-ui/components";
import { NullOr } from "@repo/rdx-utils";
import { type PropsWithChildren, useCallback, useMemo, useState } from "react";
import type { UnsavedChangesNotifierProps } from "./use-unsaved-changes-notifier";
import { UnsavedWarnContext } from "./warn-about-change-context";
// Aseguramos que las props mínimas para el diálogo siempre estén presentes
type ConfirmOptions = Required<
Pick<UnsavedChangesNotifierProps, "title" | "subtitle" | "confirmText" | "cancelText">
> &
Omit<UnsavedChangesNotifierProps, "title" | "subtitle" | "confirmText" | "cancelText">;
export const UnsavedWarnProvider = ({ children }: PropsWithChildren) => {
const [confirm, setConfirm] = useState<NullOr<ConfirmOptions>>(null);
const [open, setOpen] = useState(false);
const show = useCallback((confirmOptions: NullOr<UnsavedChangesNotifierProps>) => {
if (confirmOptions) {
setConfirm(confirmOptions as ConfirmOptions);
setOpen(true);
}
}, []);
const hide = useCallback(() => setOpen(false), []);
const onConfirm = useCallback(() => {
confirm?.onConfirm?.();
hide();
}, [confirm, hide]);
const onCancel = useCallback(() => {
confirm?.onCancel?.();
hide();
}, [confirm, hide]);
const value = useMemo(() => ({ show, hide }), [show, hide]);
return (
<UnsavedWarnContext.Provider value={value}>
{children}
<CustomDialog
onCancel={onCancel}
onConfirm={onConfirm}
title={confirm?.title}
description={confirm?.subtitle}
confirmLabel={confirm?.confirmText}
cancelLabel={confirm?.cancelText}
isOpen={open}
/>
</UnsavedWarnContext.Provider>
);
};

View File

@ -1,8 +1,7 @@
import { AppBreadcrumb, AppContent, ButtonGroup } from "@repo/rdx-ui/components"; import { AppBreadcrumb, AppContent } from "@repo/rdx-ui/components";
import { Button } from "@repo/shadcn-ui/components";
import { useNavigate } from "react-router-dom"; import { useNavigate } from "react-router-dom";
import { useUnsavedChangesNotifier } from "@erp/core/hooks"; import { FormCommitButtonGroup, UnsavedChangesProvider } from "@erp/core/hooks";
import { showErrorToast, showSuccessToast } from "@repo/shadcn-ui/lib/utils"; import { showErrorToast, showSuccessToast } from "@repo/shadcn-ui/lib/utils";
import { useState } from "react"; import { useState } from "react";
import { FieldErrors } from "react-hook-form"; import { FieldErrors } from "react-hook-form";
@ -24,10 +23,6 @@ export const CustomerCreate = () => {
error: createError, error: createError,
} = useCreateCustomerMutation(); } = useCreateCustomerMutation();
const { confirm: confirmCancel } = useUnsavedChangesNotifier({
isDirty,
});
// 3) Submit con navegación condicionada por éxito // 3) Submit con navegación condicionada por éxito
const handleSubmit = async (formData: CustomerFormData) => { const handleSubmit = async (formData: CustomerFormData) => {
/*const changedData: Record<string, string> = {}; /*const changedData: Record<string, string> = {};
@ -56,68 +51,54 @@ export const CustomerCreate = () => {
// Aquí puedes manejar los errores, por ejemplo, mostrar un mensaje al usuario // Aquí puedes manejar los errores, por ejemplo, mostrar un mensaje al usuario
}; };
console.log("render");
return ( return (
<> <>
<AppBreadcrumb /> <AppBreadcrumb />
<AppContent> <AppContent>
<div className='flex items-center justify-between space-y-4 px-6'> <UnsavedChangesProvider isDirty={isDirty}>
<div className='space-y-2'> <div className='flex items-center justify-between space-y-4 px-6'>
<h2 className='text-2xl font-bold tracking-tight text-balance scroll-m-2'> <div className='space-y-2'>
{t("pages.create.title")} <h2 className='text-2xl font-bold tracking-tight text-balance scroll-m-2'>
</h2> {t("pages.create.title")}
<p className='text-muted-foreground scroll-m-20 tracking-tight text-balance'> </h2>
{t("pages.create.description")} <p className='text-muted-foreground scroll-m-20 tracking-tight text-balance'>
</p> {t("pages.create.description")}
</div> </p>
<ButtonGroup> </div>
<Button <FormCommitButtonGroup
variant={"outline"} cancel={{
className='cursor-pointer' to: "/customers/list",
onClick={async (e) => {
e.preventDefault();
const ok = await confirmCancel();
if (ok) {
console.log("Cambios descartados");
navigate("/customers/list");
}
}} }}
> submit={{
{t("common.cancel")} formId: "customer-create-form",
</Button> disabled: isCreating,
isLoading: isCreating,
}}
/>
</div>
{/* Alerta de error de actualización (si ha fallado el último intento) */}
{isCreateError && (
<ErrorAlert
title={t("pages.create.errorTitle", "No se pudo guardar los cambios")}
message={
(createError as Error)?.message ??
t("pages.create.errorMsg", "Revisa los datos e inténtalo de nuevo.")
}
/>
)}
<Button <div className='flex flex-1 flex-col gap-4 p-4'>
type='submit' <CustomerEditForm
form='customer-create-form' formId={"customer-create-form"} // para que el botón del header pueda hacer submit
className='cursor-pointer' initialValues={defaultCustomerFormData}
disabled={isCreating} onSubmit={handleSubmit}
aria-busy={isCreating} onError={handleError}
aria-disabled={isCreating} onDirtyChange={setIsDirty}
data-state={isCreating ? "loading" : "idle"} />
> </div>
{t("common.save")} </UnsavedChangesProvider>
</Button>
</ButtonGroup>
</div>
{/* Alerta de error de actualización (si ha fallado el último intento) */}
{isCreateError && (
<ErrorAlert
title={t("pages.create.errorTitle", "No se pudo guardar los cambios")}
message={
(createError as Error)?.message ??
t("pages.create.errorMsg", "Revisa los datos e inténtalo de nuevo.")
}
/>
)}
<div className='flex flex-1 flex-col gap-4 p-4'>
<CustomerEditForm
formId={"customer-create-form"} // para que el botón del header pueda hacer submit
initialValues={defaultCustomerFormData}
onSubmit={handleSubmit}
onError={handleError}
onDirtyChange={setIsDirty}
/>
</div>
</AppContent> </AppContent>
</> </>
); );

View File

@ -10,18 +10,16 @@ import {
} from "@repo/shadcn-ui/components"; } from "@repo/shadcn-ui/components";
interface CustomDialogProps { interface CustomDialogProps {
isOpen: boolean; open: boolean;
onCancel: () => void; onConfirm: (ok: boolean) => void;
onConfirm: () => void; title?: string;
title: React.ReactNode; description?: string;
description: React.ReactNode; cancelLabel?: string;
cancelLabel: React.ReactNode; confirmLabel?: string;
confirmLabel: React.ReactNode;
} }
export const CustomDialog = ({ export const CustomDialog = ({
isOpen, open,
onCancel,
onConfirm, onConfirm,
title, title,
description, description,
@ -29,15 +27,15 @@ export const CustomDialog = ({
confirmLabel, confirmLabel,
}: CustomDialogProps) => { }: CustomDialogProps) => {
return ( return (
<AlertDialog open={isOpen} onOpenChange={(open) => !open && onCancel()}> <AlertDialog open={open} onOpenChange={(open) => !open && onConfirm(false)}>
<AlertDialogContent> <AlertDialogContent>
<AlertDialogHeader> <AlertDialogHeader>
<AlertDialogTitle>{title}</AlertDialogTitle> <AlertDialogTitle>{title}</AlertDialogTitle>
<AlertDialogDescription>{description}</AlertDialogDescription> <AlertDialogDescription>{description}</AlertDialogDescription>
</AlertDialogHeader> </AlertDialogHeader>
<AlertDialogFooter> <AlertDialogFooter>
<AlertDialogCancel onClick={onCancel}>{cancelLabel}</AlertDialogCancel> <AlertDialogCancel onClick={() => onConfirm(false)}>{cancelLabel}</AlertDialogCancel>
<AlertDialogAction onClick={onConfirm}>{confirmLabel}</AlertDialogAction> <AlertDialogAction onClick={() => onConfirm(true)}>{confirmLabel}</AlertDialogAction>
</AlertDialogFooter> </AlertDialogFooter>
</AlertDialogContent> </AlertDialogContent>
</AlertDialog> </AlertDialog>

View File

@ -179,7 +179,7 @@ importers:
version: 29.7.0(@types/node@22.15.32)(babel-plugin-macros@3.1.0)(ts-node@10.9.2(@types/node@22.15.32)(typescript@5.8.3)) version: 29.7.0(@types/node@22.15.32)(babel-plugin-macros@3.1.0)(ts-node@10.9.2(@types/node@22.15.32)(typescript@5.8.3))
ts-jest: ts-jest:
specifier: ^29.2.5 specifier: ^29.2.5
version: 29.4.0(@babel/core@7.27.4)(@jest/transform@29.7.0)(@jest/types@29.6.3)(babel-jest@29.7.0(@babel/core@7.27.4))(jest-util@29.7.0)(jest@29.7.0(@types/node@22.15.32)(babel-plugin-macros@3.1.0)(ts-node@10.9.2(@types/node@22.15.32)(typescript@5.8.3)))(typescript@5.8.3) version: 29.4.0(@babel/core@7.27.4)(@jest/transform@29.7.0)(@jest/types@29.6.3)(babel-jest@29.7.0(@babel/core@7.27.4))(esbuild@0.25.5)(jest-util@29.7.0)(jest@29.7.0(@types/node@22.15.32)(babel-plugin-macros@3.1.0)(ts-node@10.9.2(@types/node@22.15.32)(typescript@5.8.3)))(typescript@5.8.3)
tsconfig-paths: tsconfig-paths:
specifier: ^4.2.0 specifier: ^4.2.0
version: 4.2.0 version: 4.2.0
@ -395,6 +395,9 @@ importers:
i18next: i18next:
specifier: ^25.1.1 specifier: ^25.1.1
version: 25.2.1(typescript@5.8.3) version: 25.2.1(typescript@5.8.3)
lucide-react:
specifier: ^0.503.0
version: 0.503.0(react@19.1.0)
react: react:
specifier: ^19.1.0 specifier: ^19.1.0
version: 19.1.0 version: 19.1.0
@ -12295,7 +12298,7 @@ snapshots:
ts-interface-checker@0.1.13: {} ts-interface-checker@0.1.13: {}
ts-jest@29.4.0(@babel/core@7.27.4)(@jest/transform@29.7.0)(@jest/types@29.6.3)(babel-jest@29.7.0(@babel/core@7.27.4))(jest-util@29.7.0)(jest@29.7.0(@types/node@22.15.32)(babel-plugin-macros@3.1.0)(ts-node@10.9.2(@types/node@22.15.32)(typescript@5.8.3)))(typescript@5.8.3): ts-jest@29.4.0(@babel/core@7.27.4)(@jest/transform@29.7.0)(@jest/types@29.6.3)(babel-jest@29.7.0(@babel/core@7.27.4))(esbuild@0.25.5)(jest-util@29.7.0)(jest@29.7.0(@types/node@22.15.32)(babel-plugin-macros@3.1.0)(ts-node@10.9.2(@types/node@22.15.32)(typescript@5.8.3)))(typescript@5.8.3):
dependencies: dependencies:
bs-logger: 0.2.6 bs-logger: 0.2.6
ejs: 3.1.10 ejs: 3.1.10
@ -12313,6 +12316,7 @@ snapshots:
'@jest/transform': 29.7.0 '@jest/transform': 29.7.0
'@jest/types': 29.6.3 '@jest/types': 29.6.3
babel-jest: 29.7.0(@babel/core@7.27.4) babel-jest: 29.7.0(@babel/core@7.27.4)
esbuild: 0.25.5
jest-util: 29.7.0 jest-util: 29.7.0
ts-node@10.9.2(@types/node@22.15.32)(typescript@5.8.3): ts-node@10.9.2(@types/node@22.15.32)(typescript@5.8.3):