feat(onboarding): clarify the "categorize at least 5" goal in the categorizer (#616)

## Why

Feedback from onboarding users: in the categorizer step people often
**don't realize they need to categorize at least 5 transactions** before
*Continue* unlocks, and can't tell why the button is disabled. Worse,
the old counter switched to **"N remaining"** after 5 (showing *every*
uncategorized transaction, e.g. "195 remaining"), which made several
users think they had to categorize **all** of them.

## What changed

### Categorizer clarity (`step-categorize-transactions.tsx`)
- **New full-width progress banner** above the transaction card,
replacing the cramped corner counter that was getting truncated on
mobile:
- explicit instruction: *"To continue, you need to categorize at least 5
transactions."* (reuses the existing intro string)
  - a **5-segment progress bar** that fills as you go
  - the live **X/5** count
  - a reassurance line: *"You do not need to categorize all of them."*
- When the minimum is reached (or the list is exhausted), the banner
turns **green** — *"Done! You can continue now, or keep categorizing if
you want."* — and the **Continue** button gets a ring so it's obvious
the step is unlocked.
- **Removed the misleading "N remaining"** display.

### Onboarding layout & toasts
- **Mobile top spacing** (`onboarding-layout.tsx`): reduced the large
top padding on the step content on mobile so it sits closer to the
progress dots (unchanged on desktop).
- **Toasts during onboarding** (`app.tsx`): onboarding has no bottom
navigation bar, so toasts now render **bottom-center, flush to the
bottom** instead of being lifted 110px to clear the (absent) mobile tab
bar. Behavior elsewhere in the app is unchanged.

Two new strings added to `lang/es.json`.

## Notes

- The `canContinue` / `minimumRequired` gating logic is unchanged.
- Verified: `lint` clean, `es.json` valid, `LocalizationTest` passes,
full CI green.
- Recorded mobile + desktop walkthroughs locally.
This commit is contained in:
Víctor Falcón 2026-07-01 08:34:51 +02:00 committed by GitHub
parent e631cbba69
commit af64f56399
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
4 changed files with 84 additions and 40 deletions

View File

@ -52,6 +52,8 @@
"Not doable": "No viable",
"In progress": "En proceso",
"Done": "Hecho",
"Done! You can continue now, or keep categorizing if you want.": "¡Listo! Ya puedes continuar, o seguir categorizando si quieres.",
"You do not need to categorize all of them.": "No necesitas categorizarlas todas.",
"Something went wrong.": "Algo salió mal.",
"Integration requests": "Solicitudes de integración",
"Request integration": "Solicitar integración",

View File

@ -11,7 +11,7 @@ import {
OctagonXIcon,
TriangleAlertIcon,
} from 'lucide-react';
import { StrictMode, useEffect } from 'react';
import { StrictMode, useEffect, useState } from 'react';
import { createRoot } from 'react-dom/client';
import { toast, Toaster } from 'sonner';
import { EncryptionKeyProvider } from './contexts/encryption-key-context';
@ -134,6 +134,35 @@ const getProgressBarColor = () => {
return isDark ? '#EEE' : '#4B5563'; // gray-400 for dark mode, gray-600 for light mode
};
const isOnboardingPath = () =>
typeof window !== 'undefined' &&
window.location.pathname.startsWith('/onboarding');
// Onboarding has no bottom navigation bar, so toasts sit flush at the bottom
// center instead of being lifted to clear the (absent) mobile tab bar.
function AppToaster() {
const [isOnboarding, setIsOnboarding] = useState(isOnboardingPath);
useEffect(() => {
return router.on('navigate', () => setIsOnboarding(isOnboardingPath()));
}, []);
return (
<Toaster
richColors
position={isOnboarding ? 'bottom-center' : undefined}
mobileOffset={{ bottom: isOnboarding ? '16px' : '110px' }}
icons={{
success: <CircleCheckIcon className="size-4" />,
info: <InfoIcon className="size-4" />,
warning: <TriangleAlertIcon className="size-4" />,
error: <OctagonXIcon className="size-4" />,
loading: <Loader2Icon className="size-4 animate-spin" />,
}}
/>
);
}
createInertiaApp({
title: (title) => (title ? `${title} - ${appName}` : appName),
resolve: (name) =>
@ -228,23 +257,7 @@ createInertiaApp({
initialExpiredConnections
}
/>
<Toaster
richColors
mobileOffset={{ bottom: '110px' }}
icons={{
success: (
<CircleCheckIcon className="size-4" />
),
info: <InfoIcon className="size-4" />,
warning: (
<TriangleAlertIcon className="size-4" />
),
error: <OctagonXIcon className="size-4" />,
loading: (
<Loader2Icon className="size-4 animate-spin" />
),
}}
/>
<AppToaster />
</SyncProvider>
</PrivacyModeProvider>
</EncryptionKeyProvider>

View File

@ -52,7 +52,6 @@ export function StepCategorizeTransactions({
isComplete,
uncategorizedTransactions,
currentTransaction,
remainingCount,
animationState,
lastSelectedCategory,
sortedCategories,
@ -79,8 +78,6 @@ export function StepCategorizeTransactions({
categorizedCount >= minimumRequired ||
totalAvailable === 0;
const hasReachedMinimum = categorizedCount >= minimumRequired;
// Show rules hint after first categorization, only once
useEffect(() => {
if (categorizedCount === 1 && !hasSeenHint) {
@ -262,6 +259,51 @@ export function StepCategorizeTransactions({
return (
<div className="flex w-full flex-col gap-4">
{/* Progress banner: makes the "categorize at least N" goal obvious */}
<div className="rounded-xl border bg-card p-4">
{canContinue ? (
<div className="flex items-center gap-3">
<CheckCircle2 className="size-5 shrink-0 text-emerald-600 dark:text-emerald-400" />
<p className="text-sm font-medium">
{__(
'Done! You can continue now, or keep categorizing if you want.',
)}
</p>
</div>
) : (
<div className="flex flex-col gap-2">
<div className="flex items-baseline justify-between gap-3">
<p className="text-sm font-medium">
{__(
'To continue, you need to categorize at least :count transactions.',
{ count: minimumRequired },
)}
</p>
<span className="shrink-0 text-sm font-semibold tabular-nums">
{categorizedCount}/{minimumRequired}
</span>
</div>
<div className="flex gap-1.5">
{Array.from({ length: minimumRequired }).map(
(_, i) => (
<div
key={i}
className={`h-2 flex-1 rounded-full transition-colors ${
i < categorizedCount
? 'bg-violet-600 dark:bg-violet-400'
: 'bg-muted'
}`}
/>
),
)}
</div>
<p className="text-xs text-muted-foreground">
{__('You do not need to categorize all of them.')}
</p>
</div>
)}
</div>
{/* Header row */}
<div className="flex items-center justify-between">
<Popover open={showRulesHint} onOpenChange={() => {}}>
@ -322,27 +364,14 @@ export function StepCategorizeTransactions({
size="sm"
onClick={onComplete}
disabled={!canContinue}
className={
canContinue
? 'ring-2 ring-primary ring-offset-2 ring-offset-background'
: undefined
}
>
{__('Continue')}
</Button>
<span className="text-sm text-muted-foreground">
{hasReachedMinimum ? (
<>
<span className="font-medium text-foreground">
{remainingCount}
</span>{' '}
{__('remaining')}
</>
) : (
<>
<span className="font-medium text-foreground">
{categorizedCount}
</span>
/{minimumRequired}
</>
)}
</span>
</div>
</div>

View File

@ -55,7 +55,7 @@ export default function OnboardingLayout({
<main
className={cn(
'flex flex-1 flex-col items-center justify-start px-4 pt-12 pb-12 md:px-6',
'flex flex-1 flex-col items-center justify-start px-4 pt-4 pb-12 md:px-6 md:pt-12',
align === 'center' && 'sm:justify-center',
)}
>