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:
parent
e631cbba69
commit
af64f56399
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
)}
|
||||
>
|
||||
|
|
|
|||
Loading…
Reference in New Issue