diff --git a/lang/es.json b/lang/es.json index d6717b9e..c786f028 100644 --- a/lang/es.json +++ b/lang/es.json @@ -1078,9 +1078,11 @@ "Recovery codes": "Códigos de recuperación", "Recovery codes let you regain access if you lose your 2FA device. Store them in a secure password manager.": "Los códigos de recuperación te permiten recuperar acceso si pierdes tu dispositivo 2FA. Guárdalos en un administrador de contraseñas seguro.", "Recovery codes let you regain access if you lose your 2FA\\n device. Store them in a secure password manager.": "Los códigos de recuperación te permiten recuperar el acceso si pierdes tu dispositivo 2FA. Guárdalos en un gestor de contraseñas seguro.", + "Redirecting to your dashboard": "Redirigiendo a tu panel", "Redirecting...": "Redirigiendo...", "Regenerate Codes": "Regenerar Códigos", "Register": "Registrarse", + "Redirect progress": "Progreso de redirección", "Regular Security Audits:": "Auditorías de Seguridad Regulares:", "Remaining": "Restante", "Remaining balance carries over to next period": "El saldo restante se acumula al siguiente período", @@ -1634,6 +1636,7 @@ "You are always the owner of your data": "Siempre eres el dueño de tus datos", "You are currently **#:position** in line for early access.": "Actualmente estás en el puesto **#:position** para el acceso anticipado.", "You are now **#:position** in line for early access.": "Ahora estás en el puesto **#:position** para el acceso anticipado.", + "You are being redirected in 3 seconds.": "Serás redirigido en 3 segundos.", "You are responsible for all taxes associated with your purchase": "Eres responsable de todos los impuestos asociados con tu compra", "You are responsible for all taxes associated\\n with your purchase": "Eres responsable de todos los impuestos asociados con tu compra", "You are responsible for maintaining backups of your data. While we implement reasonable backup procedures, we recommend you export and save copies of important data regularly.": "Eres responsable de mantener copias de seguridad de tus datos. Aunque implementamos procedimientos razonables de respaldo, recomendamos que exportes y guardes copias de datos importantes regularmente.", diff --git a/resources/js/components/landing/authenticated-redirect-dialog.test.tsx b/resources/js/components/landing/authenticated-redirect-dialog.test.tsx new file mode 100644 index 00000000..e60c1bbc --- /dev/null +++ b/resources/js/components/landing/authenticated-redirect-dialog.test.tsx @@ -0,0 +1,79 @@ +import { dashboard } from '@/routes'; +import { act, fireEvent, render, screen } from '@testing-library/react'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import AuthenticatedRedirectDialog from './authenticated-redirect-dialog'; + +const mocks = vi.hoisted(() => ({ + routerVisit: vi.fn(), +})); + +vi.mock('@inertiajs/react', () => ({ + router: { + visit: mocks.routerVisit, + }, +})); + +describe('AuthenticatedRedirectDialog', () => { + beforeEach(() => { + vi.useFakeTimers(); + mocks.routerVisit.mockClear(); + }); + + afterEach(() => { + vi.clearAllTimers(); + vi.useRealTimers(); + }); + + it('redirects to the dashboard after three seconds', () => { + render(); + + expect( + screen.getByText('You are being redirected in 3 seconds.'), + ).not.toBeNull(); + expect( + screen.getByRole('progressbar').getAttribute('aria-valuemax'), + ).toBe('100'); + + act(() => { + vi.advanceTimersByTime(2999); + }); + + expect(mocks.routerVisit).not.toHaveBeenCalled(); + + act(() => { + vi.advanceTimersByTime(1); + }); + + expect(mocks.routerVisit).toHaveBeenCalledWith(dashboard()); + }); + + it('fills the progress bar while waiting to redirect', () => { + render(); + + expect( + screen.getByRole('progressbar').getAttribute('aria-valuenow'), + ).toBe('0'); + + act(() => { + vi.advanceTimersByTime(1500); + }); + + expect( + screen.getByRole('progressbar').getAttribute('aria-valuenow'), + ).toBe('50'); + }); + + it('cancels the redirect and closes the modal', () => { + render(); + + fireEvent.click(screen.getByRole('button', { name: 'Cancel' })); + act(() => { + vi.advanceTimersByTime(3000); + }); + + expect(mocks.routerVisit).not.toHaveBeenCalled(); + expect( + screen.queryByText('You are being redirected in 3 seconds.'), + ).toBeNull(); + }); +}); diff --git a/resources/js/components/landing/authenticated-redirect-dialog.tsx b/resources/js/components/landing/authenticated-redirect-dialog.tsx new file mode 100644 index 00000000..5eb9d00b --- /dev/null +++ b/resources/js/components/landing/authenticated-redirect-dialog.tsx @@ -0,0 +1,99 @@ +import { Button } from '@/components/ui/button'; +import { + Dialog, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, +} from '@/components/ui/dialog'; +import { dashboard } from '@/routes'; +import { __ } from '@/utils/i18n'; +import { router } from '@inertiajs/react'; +import { useEffect, useState } from 'react'; + +type Props = { + open: boolean; + delayInMs?: number; +}; + +export default function AuthenticatedRedirectDialog({ + open, + delayInMs = 3000, +}: Props) { + const [isOpen, setIsOpen] = useState(open); + const [isCancelled, setIsCancelled] = useState(false); + const [progress, setProgress] = useState(0); + + useEffect(() => { + setIsOpen(open); + setIsCancelled(false); + setProgress(0); + }, [open]); + + useEffect(() => { + if (!isOpen || isCancelled) { + return; + } + + const startedAt = Date.now(); + const progressIntervalId = window.setInterval(() => { + const elapsedInMs = Date.now() - startedAt; + + setProgress( + Math.min(100, Math.round((elapsedInMs / delayInMs) * 100)), + ); + }, 50); + const redirectTimeoutId = window.setTimeout(() => { + setProgress(100); + router.visit(dashboard()); + }, delayInMs); + + return () => { + window.clearInterval(progressIntervalId); + window.clearTimeout(redirectTimeoutId); + }; + }, [delayInMs, isCancelled, isOpen]); + + function cancelRedirect(): void { + setIsCancelled(true); + setIsOpen(false); + } + + return ( + + + + + {__('Redirecting to your dashboard')} + + + {__('You are being redirected in 3 seconds.')} + + +
+
+
+ + + + +
+ ); +} diff --git a/resources/js/components/partials/header.test.tsx b/resources/js/components/partials/header.test.tsx new file mode 100644 index 00000000..bd09d568 --- /dev/null +++ b/resources/js/components/partials/header.test.tsx @@ -0,0 +1,48 @@ +import { dashboard } from '@/routes'; +import { render, screen } from '@testing-library/react'; +import type React from 'react'; +import { beforeEach, describe, expect, it, vi } from 'vitest'; +import Header from './header'; + +const mocks = vi.hoisted(() => ({ + pageProps: { + auth: { + user: { + id: 'user-1', + name: 'Test User', + email: 'test@example.com', + }, + }, + }, +})); + +vi.mock('@inertiajs/react', () => ({ + Link: ({ + children, + href, + }: { + children: React.ReactNode; + href: string | { url: string }; + }) => {children}, + usePage: () => ({ props: mocks.pageProps }), +})); + +describe('Header', () => { + beforeEach(() => { + vi.stubGlobal( + 'fetch', + vi.fn(() => new Promise(() => {})), + ); + }); + + it('shows dashboard links for authenticated users when auth buttons are hidden', () => { + render(
); + + const dashboardLinks = screen.getAllByRole('link', { + name: 'Dashboard', + }); + + expect(dashboardLinks).toHaveLength(2); + expect(dashboardLinks[0]?.getAttribute('href')).toBe(dashboard().url); + }); +}); diff --git a/resources/js/components/partials/header.tsx b/resources/js/components/partials/header.tsx index 3bd7f3bf..ac03dd66 100644 --- a/resources/js/components/partials/header.tsx +++ b/resources/js/components/partials/header.tsx @@ -106,42 +106,40 @@ export default function Header({ ])} /> )} - {!hideAuthButtons && ( - <> - {auth.user ? ( - + {auth.user ? ( + + + + ) : ( + !hideAuthButtons && ( + <> + - ) : ( - <> - + {canRegister && ( + - {canRegister && ( - - - - )} - - )} - + )} + + ) )}
@@ -217,39 +215,35 @@ export default function Header({ ])} /> )} - {!hideAuthButtons && ( - <> - {auth.user ? ( - - + + ) : ( + !hideAuthButtons && ( + <> + + - ) : ( - <> - + {canRegister && ( + - {canRegister && ( - - - - )} - - )} - + )} + + ) )} diff --git a/resources/js/pages/welcome.tsx b/resources/js/pages/welcome.tsx index 7fa7368e..5c8103f9 100644 --- a/resources/js/pages/welcome.tsx +++ b/resources/js/pages/welcome.tsx @@ -1,5 +1,6 @@ import { BankLogo } from '@/components/bank-logo'; import InputError from '@/components/input-error'; +import AuthenticatedRedirectDialog from '@/components/landing/authenticated-redirect-dialog'; import InstallAppButton from '@/components/landing/install-app-button'; import Header from '@/components/partials/header'; import { Button } from '@/components/ui/button'; @@ -1907,7 +1908,7 @@ export default function Welcome({ hideAuthButtons?: boolean; popularBanks: PopularBank[]; }) { - const { appUrl, subscriptionsEnabled, pricing, locale } = + const { appUrl, auth, subscriptionsEnabled, pricing, locale } = usePage().props; const planEntries = Object.entries(pricing.plans); const { isMobile } = usePwaInstall(); @@ -2096,6 +2097,7 @@ export default function Welcome({ })} +