feat(landing): redirect signed-in users (#429)

## Summary
- Show signed-in landing visitors a 3-second dashboard redirect modal.
- Add cancel action and animated progress bar.
- Keep dashboard link visible when signup/auth buttons are hidden.
- Add Spanish translations.

## Tests
- `npm test --
resources/js/components/landing/authenticated-redirect-dialog.test.tsx
resources/js/components/partials/header.test.tsx`
- `npx eslint
resources/js/components/landing/authenticated-redirect-dialog.tsx
resources/js/components/landing/authenticated-redirect-dialog.test.tsx`

## Screenshots
<img width="1207" height="797" alt="image"
src="https://github.com/user-attachments/assets/5954d083-54be-4820-b3f3-2c8454480a21"
/>
This commit is contained in:
Víctor Falcón 2026-05-25 20:28:45 +02:00 committed by GitHub
parent 1278a2b972
commit 4f42de74a1
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
6 changed files with 276 additions and 51 deletions

View File

@ -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.",

View File

@ -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(<AuthenticatedRedirectDialog open />);
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(<AuthenticatedRedirectDialog open />);
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(<AuthenticatedRedirectDialog open />);
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();
});
});

View File

@ -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 (
<Dialog open={isOpen}>
<DialogContent showCloseButton={false}>
<DialogHeader>
<DialogTitle>
{__('Redirecting to your dashboard')}
</DialogTitle>
<DialogDescription>
{__('You are being redirected in 3 seconds.')}
</DialogDescription>
</DialogHeader>
<div
role="progressbar"
aria-label={__('Redirect progress')}
aria-valuemin={0}
aria-valuemax={100}
aria-valuenow={progress}
className="h-2 overflow-hidden rounded-full bg-secondary"
>
<div
className="h-full rounded-full bg-primary transition-[width] duration-75 ease-linear motion-reduce:transition-none"
style={{ width: `${progress}%` }}
/>
</div>
<DialogFooter>
<Button
type="button"
variant="secondary"
onClick={cancelRedirect}
>
{__('Cancel')}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
}

View File

@ -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 };
}) => <a href={typeof href === 'string' ? href : href.url}>{children}</a>,
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(<Header hideAuthButtons />);
const dashboardLinks = screen.getAllByRole('link', {
name: 'Dashboard',
});
expect(dashboardLinks).toHaveLength(2);
expect(dashboardLinks[0]?.getAttribute('href')).toBe(dashboard().url);
});
});

View File

@ -106,42 +106,40 @@ export default function Header({
])}
/>
)}
{!hideAuthButtons && (
<>
{auth.user ? (
<Link href={dashboard()}>
{auth.user ? (
<Link href={dashboard()}>
<Button
size="sm"
className="cursor-pointer rounded-full"
>
{__('Dashboard')}
</Button>
</Link>
) : (
!hideAuthButtons && (
<>
<Link href={login({ query: { force: 1 } })}>
<Button
variant={'ghost'}
size="sm"
className="cursor-pointer rounded-full"
>
{__('Dashboard')}
{__('Log in')}
</Button>
</Link>
) : (
<>
<Link href={login({ query: { force: 1 } })}>
{canRegister && (
<Link href="/register">
<Button
variant={'ghost'}
variant="default"
size="sm"
className="cursor-pointer rounded-full"
>
{__('Log in')}
{__('Register')}
</Button>
</Link>
{canRegister && (
<Link href="/register">
<Button
variant="default"
size="sm"
className="cursor-pointer rounded-full"
>
{__('Register')}
</Button>
</Link>
)}
</>
)}
</>
)}
</>
)
)}
</nav>
</header>
@ -217,39 +215,35 @@ export default function Header({
])}
/>
)}
{!hideAuthButtons && (
<>
{auth.user ? (
<Link href={dashboard()}>
<Button className="cursor-pointer">
{__('Dashboard')}
{auth.user ? (
<Link href={dashboard()}>
<Button className="cursor-pointer">
{__('Dashboard')}
</Button>
</Link>
) : (
!hideAuthButtons && (
<>
<Link href={login({ query: { force: 1 } })}>
<Button
variant={'ghost'}
className="cursor-pointer"
>
{__('Log in')}
</Button>
</Link>
) : (
<>
<Link
href={login({ query: { force: 1 } })}
>
{canRegister && (
<Link href="/register">
<Button
variant={'ghost'}
variant="default"
className="cursor-pointer"
>
{__('Log in')}
{__('Register')}
</Button>
</Link>
{canRegister && (
<Link href="/register">
<Button
variant="default"
className="cursor-pointer"
>
{__('Register')}
</Button>
</Link>
)}
</>
)}
</>
)}
</>
)
)}
</nav>
</div>

View File

@ -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<SharedData>().props;
const planEntries = Object.entries(pricing.plans);
const { isMobile } = usePwaInstall();
@ -2096,6 +2097,7 @@ export default function Welcome({
})}
</script>
</Head>
<AuthenticatedRedirectDialog open={Boolean(auth.user)} />
<div className="flex min-h-screen flex-col bg-[#FDFDFC] text-[#1b1b18] dark:bg-[#0a0a0a] dark:text-[#EDEDEC]">
<Header
canRegister={canRegister}