Implement Phase 7: Spanish Translations & Phase 9: Guided Tours

This commit implements comprehensive Spanish translations for all new onboarding
components and creates a complete guided tour framework for post-setup feature
discovery.

## Phase 7: Spanish Translations

### Spanish Onboarding Translations Added

**BakeryTypeSelectionStep translations (onboarding.bakery_type):**
- Title and subtitle for bakery type selection
- Production bakery: features, examples, and selected info
- Retail bakery: features, examples, and selected info
- Mixed bakery: features, examples, and selected info
- Help text and continue button

**DataSourceChoiceStep translations (onboarding.data_source):**
- Title and subtitle for configuration method
- AI-assisted setup: benefits, ideal scenarios, estimated time
- Manual setup: benefits, ideal scenarios, estimated time
- Info panels for both options with detailed requirements

**ProductionProcessesStep translations (onboarding.processes):**
- Title and subtitle for production processes
- Process types: baking, decorating, finishing, assembly
- Form labels and placeholders
- Template section with quick start option
- Navigation buttons and help text

**Updated Wizard Steps:**
- Added all new step titles and descriptions
- Updated navigation labels
- Enhanced progress indicators

### Translation Coverage

Total new translation keys added: **150+ keys**
- bakery_type: 40+ keys
- data_source: 35+ keys
- processes: 25+ keys
- wizard updates: 15+ keys
- Comprehensive coverage for all user-facing text

## Phase 9: Guided Tours

### Tour Framework Created

**TourContext (`/frontend/src/contexts/TourContext.tsx`):**
- Complete state management for tours
- Tour step navigation (next, previous, skip, complete)
- localStorage persistence for completed/skipped tours
- beforeShow and afterShow hooks for each step
- Support for custom actions in tour steps

**Key Features:**
- Track which tours are completed or skipped
- Prevent showing tours that are already done
- Support async operations in step hooks
- Centralized tour state across the app

### Tour UI Components

**TourTooltip (`/frontend/src/components/ui/Tour/TourTooltip.tsx`):**
- Intelligent positioning (top, bottom, left, right)
- Auto-adjusts if tooltip goes off-screen
- Progress indicators with dots
- Navigation buttons (previous, next, finish)
- Close/skip button
- Arrow pointing to target element
- Responsive design with animations

**TourSpotlight (`/frontend/src/components/ui/Tour/TourSpotlight.tsx`):**
- SVG mask overlay to dim rest of page
- Highlighted border around target element
- Smooth animations (fade in, pulse)
- Auto-scroll target into view
- Adjusts on window resize/scroll

**Tour (`/frontend/src/components/ui/Tour/Tour.tsx`):**
- Main container component
- Portal rendering for overlay
- Disables body scroll during tour
- Combines tooltip and spotlight

**TourButton (`/frontend/src/components/ui/Tour/TourButton.tsx`):**
- Three variants: icon, button, menu
- Shows all available tours
- Displays completion status
- Dropdown menu with tour descriptions
- Number of steps for each tour

### Predefined Tours Created

**5 comprehensive tours defined (`/frontend/src/tours/tours.ts`):**

1. **Dashboard Tour** (5 steps):
   - Welcome and overview
   - Key statistics cards
   - AI forecast chart
   - Inventory alerts
   - Main navigation

2. **Inventory Tour** (5 steps):
   - Inventory management overview
   - Adding new ingredients
   - Search and filters
   - Inventory table view
   - Stock alerts

3. **Recipes Tour** (5 steps):
   - Recipe management intro
   - Creating recipes
   - Automatic cost calculation
   - Recipe yield settings
   - Batch multiplier

4. **Production Tour** (5 steps):
   - Production planning overview
   - Production schedule calendar
   - AI recommendations
   - Creating production batches
   - Batch status tracking

5. **Post-Onboarding Tour** (5 steps):
   - Congratulations message
   - Main navigation overview
   - Quick actions
   - Notifications
   - Help resources

### Tour Translations

**New Spanish locale: `/frontend/src/locales/es/tour.json`:**
- Navigation labels (previous, next, finish, skip)
- Progress indicators
- Tour trigger button text
- Completion messages
- Tour names and descriptions

### Technical Implementation

**Features:**
- `data-tour` attributes for targeting elements
- Portal rendering for proper z-index layering
- Smooth animations with CSS classes
- Responsive positioning algorithm
- Scroll handling for dynamic content
- Window resize listeners
- TypeScript interfaces for type safety

**Usage Pattern:**
```typescript
// In any component
import { useTour } from '../contexts/TourContext';
import { dashboardTour } from '../tours/tours';

const { startTour } = useTour();
startTour(dashboardTour);
```

## Files Added

**Translations:**
- frontend/src/locales/es/tour.json

**Tour Framework:**
- frontend/src/contexts/TourContext.tsx
- frontend/src/components/ui/Tour/Tour.tsx
- frontend/src/components/ui/Tour/TourTooltip.tsx
- frontend/src/components/ui/Tour/TourSpotlight.tsx
- frontend/src/components/ui/Tour/TourButton.tsx
- frontend/src/components/ui/Tour/index.ts
- frontend/src/tours/tours.ts

## Files Modified

- frontend/src/locales/es/onboarding.json (150+ new translation keys)

## Testing

 Build successful (23.12s)
 No TypeScript errors
 All translations properly structured
 Tour components render via portals
 Spanish locale complete for all new features

## Integration Requirements

To enable tours in the app:

1. Add TourProvider to app root (wrap with TourProvider)
2. Add Tour component to render active tours
3. Add TourButton where help is needed
4. Add data-tour attributes to tour target elements

Example:
```tsx
<TourProvider>
  <App />
  <Tour />
</TourProvider>
```

## Next Steps

- Add TourProvider to application root
- Add data-tour attributes to target elements in pages
- Integrate TourButton in navigation/help sections
- Auto-trigger post-onboarding tour after setup complete
- Track tour analytics (views, completions, skip rates)

## Benefits

**For Users:**
- Smooth onboarding experience in Spanish
- Interactive feature discovery
- Contextual help when needed
- Can skip or restart tours anytime
- Never see same tour twice (unless restarted)

**For Product:**
- Reduce support requests
- Increase feature adoption
- Improve user confidence
- Better user experience
- Track which features need improvement
This commit is contained in:
Claude
2025-11-06 12:45:31 +00:00
parent 470cb91b51
commit d42eadacc6
9 changed files with 1243 additions and 4 deletions

View File

@@ -0,0 +1,49 @@
import React, { useEffect } from 'react';
import { createPortal } from 'react-dom';
import { useTour } from '../../../contexts/TourContext';
import TourTooltip from './TourTooltip';
import TourSpotlight from './TourSpotlight';
export const Tour: React.FC = () => {
const { state, nextStep, previousStep, skipTour, completeTour } = useTour();
// Disable body scroll when tour is active
useEffect(() => {
if (state.isActive) {
const originalOverflow = document.body.style.overflow;
document.body.style.overflow = 'hidden';
return () => {
document.body.style.overflow = originalOverflow;
};
}
}, [state.isActive]);
if (!state.isActive || !state.currentTour) {
return null;
}
const currentStep = state.currentTour.steps[state.currentStepIndex];
if (!currentStep) return null;
return createPortal(
<>
{/* Spotlight highlight */}
<TourSpotlight target={currentStep.target} padding={8} />
{/* Tooltip with content and navigation */}
<TourTooltip
step={currentStep}
currentStep={state.currentStepIndex}
totalSteps={state.currentTour.steps.length}
onNext={nextStep}
onPrevious={previousStep}
onSkip={skipTour}
onComplete={completeTour}
/>
</>,
document.body
);
};
export default Tour;

View File

@@ -0,0 +1,164 @@
import React, { useState } from 'react';
import { useTranslation } from 'react-i18next';
import { HelpCircle, Play, CheckCircle } from 'lucide-react';
import Button from '../Button/Button';
import { useTour } from '../../../contexts/TourContext';
import { allTours } from '../../../tours/tours';
export interface TourButtonProps {
tourId?: keyof typeof allTours;
variant?: 'icon' | 'button' | 'menu';
className?: string;
}
export const TourButton: React.FC<TourButtonProps> = ({
tourId,
variant = 'button',
className = '',
}) => {
const { t } = useTranslation();
const { startTour, isTourCompleted } = useTour();
const [showMenu, setShowMenu] = useState(false);
const handleStartTour = (id: keyof typeof allTours) => {
const tour = allTours[id];
if (tour) {
startTour(tour);
setShowMenu(false);
}
};
// Icon variant - just a help icon that opens menu
if (variant === 'icon') {
return (
<div className="relative">
<button
onClick={() => setShowMenu(!showMenu)}
className={`p-2 rounded-lg hover:bg-gray-100 transition-colors ${className}`}
aria-label={t('tour:trigger.button', 'Tours Disponibles')}
>
<HelpCircle className="w-5 h-5 text-gray-600" />
</button>
{showMenu && (
<>
<div
className="fixed inset-0 z-40"
onClick={() => setShowMenu(false)}
/>
<div className="absolute right-0 top-full mt-2 w-72 bg-white rounded-lg shadow-xl border border-gray-200 z-50 animate-scale-in">
<div className="p-4 border-b border-gray-200">
<h3 className="font-semibold text-gray-900">
{t('tour:trigger.button', 'Tours Disponibles')}
</h3>
<p className="text-sm text-gray-600 mt-1">
{t('tour:trigger.tooltip', '¿Necesitas ayuda? Inicia un tour guiado')}
</p>
</div>
<div className="p-2">
{Object.entries(allTours).map(([key, tour]) => (
<button
key={tour.id}
onClick={() => handleStartTour(key as keyof typeof allTours)}
className="w-full flex items-center justify-between p-3 rounded-lg hover:bg-gray-50 transition-colors text-left"
>
<div className="flex-1">
<div className="font-medium text-gray-900">{tour.name}</div>
<div className="text-xs text-gray-500 mt-0.5">
{tour.steps.length} {t('common:steps', 'pasos')}
</div>
</div>
{isTourCompleted(tour.id) ? (
<CheckCircle className="w-5 h-5 text-green-500 flex-shrink-0" />
) : (
<Play className="w-5 h-5 text-primary-500 flex-shrink-0" />
)}
</button>
))}
</div>
</div>
</>
)}
</div>
);
}
// Single tour button variant
if (tourId) {
const tour = allTours[tourId];
if (!tour) return null;
const isCompleted = isTourCompleted(tour.id);
return (
<Button
onClick={() => handleStartTour(tourId)}
variant={isCompleted ? 'outline' : 'primary'}
size="sm"
leftIcon={isCompleted ? <CheckCircle /> : <Play />}
className={className}
>
{isCompleted
? t('tour:restart_tour', 'Reiniciar tour')
: t('tour:start_tour', 'Iniciar tour')}
</Button>
);
}
// Default button that shows menu
return (
<div className="relative">
<Button
onClick={() => setShowMenu(!showMenu)}
variant="outline"
size="sm"
leftIcon={<HelpCircle />}
className={className}
>
{t('tour:trigger.button', 'Tours Disponibles')}
</Button>
{showMenu && (
<>
<div
className="fixed inset-0 z-40"
onClick={() => setShowMenu(false)}
/>
<div className="absolute right-0 top-full mt-2 w-72 bg-white rounded-lg shadow-xl border border-gray-200 z-50 animate-scale-in">
<div className="p-4 border-b border-gray-200">
<h3 className="font-semibold text-gray-900">
{t('tour:trigger.button', 'Tours Disponibles')}
</h3>
<p className="text-sm text-gray-600 mt-1">
{t('tour:trigger.tooltip', '¿Necesitas ayuda? Inicia un tour guiado')}
</p>
</div>
<div className="p-2">
{Object.entries(allTours).map(([key, tour]) => (
<button
key={tour.id}
onClick={() => handleStartTour(key as keyof typeof allTours)}
className="w-full flex items-center justify-between p-3 rounded-lg hover:bg-gray-50 transition-colors text-left"
>
<div className="flex-1">
<div className="font-medium text-gray-900">{tour.name}</div>
<div className="text-xs text-gray-500 mt-0.5">
{tour.steps.length} {t('common:steps', 'pasos')}
</div>
</div>
{isTourCompleted(tour.id) ? (
<CheckCircle className="w-5 h-5 text-green-500 flex-shrink-0" />
) : (
<Play className="w-5 h-5 text-primary-500 flex-shrink-0" />
)}
</button>
))}
</div>
</div>
</>
)}
</div>
);
};
export default TourButton;

View File

@@ -0,0 +1,91 @@
import React, { useEffect, useState } from 'react';
export interface TourSpotlightProps {
target: string; // CSS selector
padding?: number;
}
export const TourSpotlight: React.FC<TourSpotlightProps> = ({ target, padding = 8 }) => {
const [rect, setRect] = useState<DOMRect | null>(null);
useEffect(() => {
const updateRect = () => {
const element = document.querySelector(target);
if (element) {
const elementRect = element.getBoundingClientRect();
setRect(elementRect);
// Scroll element into view if not fully visible
element.scrollIntoView({
behavior: 'smooth',
block: 'center',
inline: 'center',
});
}
};
updateRect();
// Update on resize or scroll
window.addEventListener('resize', updateRect);
window.addEventListener('scroll', updateRect, true);
return () => {
window.removeEventListener('resize', updateRect);
window.removeEventListener('scroll', updateRect, true);
};
}, [target]);
if (!rect) return null;
return (
<>
{/* Overlay with cutout */}
<svg
className="fixed inset-0 z-[10000] pointer-events-none"
style={{ width: '100vw', height: '100vh' }}
>
<defs>
<mask id="tour-spotlight-mask">
{/* White background */}
<rect x="0" y="0" width="100%" height="100%" fill="white" />
{/* Black cutout for target element */}
<rect
x={rect.left - padding}
y={rect.top - padding}
width={rect.width + padding * 2}
height={rect.height + padding * 2}
rx="8"
fill="black"
/>
</mask>
</defs>
{/* Semi-transparent overlay with mask */}
<rect
x="0"
y="0"
width="100%"
height="100%"
fill="rgba(0, 0, 0, 0.7)"
mask="url(#tour-spotlight-mask)"
className="animate-fade-in"
/>
</svg>
{/* Highlighted border around target */}
<div
className="fixed z-[10000] pointer-events-none rounded-lg animate-pulse"
style={{
top: `${rect.top - padding}px`,
left: `${rect.left - padding}px`,
width: `${rect.width + padding * 2}px`,
height: `${rect.height + padding * 2}px`,
boxShadow: '0 0 0 2px rgba(217, 119, 6, 0.8), 0 0 20px rgba(217, 119, 6, 0.3)',
}}
/>
</>
);
};
export default TourSpotlight;

View File

@@ -0,0 +1,206 @@
import React, { useEffect, useState, useRef } from 'react';
import { useTranslation } from 'react-i18next';
import { X, ArrowLeft, ArrowRight, Check } from 'lucide-react';
import Button from '../Button/Button';
import { TourStep } from '../../../contexts/TourContext';
export interface TourTooltipProps {
step: TourStep;
currentStep: number;
totalSteps: number;
onNext: () => void;
onPrevious: () => void;
onSkip: () => void;
onComplete: () => void;
}
export const TourTooltip: React.FC<TourTooltipProps> = ({
step,
currentStep,
totalSteps,
onNext,
onPrevious,
onSkip,
onComplete,
}) => {
const { t } = useTranslation();
const [position, setPosition] = useState({ top: 0, left: 0 });
const [placement, setPlacement] = useState(step.placement || 'bottom');
const tooltipRef = useRef<HTMLDivElement>(null);
useEffect(() => {
const calculatePosition = () => {
const target = document.querySelector(step.target);
if (!target || !tooltipRef.current) return;
const targetRect = target.getBoundingClientRect();
const tooltipRect = tooltipRef.current.getBoundingClientRect();
const viewportWidth = window.innerWidth;
const viewportHeight = window.innerHeight;
let top = 0;
let left = 0;
let finalPlacement = step.placement || 'bottom';
// Calculate initial position based on placement
switch (step.placement || 'bottom') {
case 'top':
top = targetRect.top - tooltipRect.height - 16;
left = targetRect.left + (targetRect.width - tooltipRect.width) / 2;
break;
case 'bottom':
top = targetRect.bottom + 16;
left = targetRect.left + (targetRect.width - tooltipRect.width) / 2;
break;
case 'left':
top = targetRect.top + (targetRect.height - tooltipRect.height) / 2;
left = targetRect.left - tooltipRect.width - 16;
break;
case 'right':
top = targetRect.top + (targetRect.height - tooltipRect.height) / 2;
left = targetRect.right + 16;
break;
}
// Adjust if tooltip goes off screen
if (left < 16) {
left = 16;
} else if (left + tooltipRect.width > viewportWidth - 16) {
left = viewportWidth - tooltipRect.width - 16;
}
if (top < 16) {
top = 16;
finalPlacement = 'bottom';
} else if (top + tooltipRect.height > viewportHeight - 16) {
top = viewportHeight - tooltipRect.height - 16;
finalPlacement = 'top';
}
setPosition({ top, left });
setPlacement(finalPlacement);
};
// Calculate position on mount and when step changes
calculatePosition();
// Recalculate on window resize or scroll
window.addEventListener('resize', calculatePosition);
window.addEventListener('scroll', calculatePosition, true);
return () => {
window.removeEventListener('resize', calculatePosition);
window.removeEventListener('scroll', calculatePosition, true);
};
}, [step]);
const isFirstStep = currentStep === 0;
const isLastStep = currentStep === totalSteps - 1;
return (
<div
ref={tooltipRef}
className="fixed z-[10001] bg-white rounded-lg shadow-2xl border border-gray-200 max-w-md animate-scale-in"
style={{
top: `${position.top}px`,
left: `${position.left}px`,
}}
>
{/* Header */}
<div className="flex items-center justify-between p-4 border-b border-gray-200">
<div className="flex-1">
<h3 className="text-lg font-semibold text-gray-900">{step.title}</h3>
<p className="text-xs text-gray-500 mt-1">
{t('tour:step_progress', 'Paso {{current}} de {{total}}', {
current: currentStep + 1,
total: totalSteps,
})}
</p>
</div>
<button
onClick={onSkip}
className="ml-4 text-gray-400 hover:text-gray-600 transition-colors"
aria-label={t('tour:close', 'Cerrar')}
>
<X className="w-5 h-5" />
</button>
</div>
{/* Content */}
<div className="p-4">
<p className="text-gray-700 leading-relaxed">{step.content}</p>
{/* Action button if provided */}
{step.action && (
<div className="mt-4">
<Button
onClick={step.action.onClick}
variant="outline"
size="sm"
className="w-full"
>
{step.action.label}
</Button>
</div>
)}
</div>
{/* Footer */}
<div className="flex items-center justify-between p-4 border-t border-gray-200 bg-gray-50">
{/* Progress dots */}
<div className="flex items-center gap-1.5">
{Array.from({ length: totalSteps }).map((_, index) => (
<div
key={index}
className={`w-2 h-2 rounded-full transition-all ${
index === currentStep
? 'bg-primary-500 w-4'
: index < currentStep
? 'bg-primary-300'
: 'bg-gray-300'
}`}
/>
))}
</div>
{/* Navigation buttons */}
<div className="flex items-center gap-2">
{!isFirstStep && (
<Button onClick={onPrevious} variant="ghost" size="sm" leftIcon={<ArrowLeft />}>
{t('tour:previous', 'Anterior')}
</Button>
)}
{isLastStep ? (
<Button onClick={onComplete} variant="primary" size="sm" rightIcon={<Check />}>
{t('tour:finish', 'Finalizar')}
</Button>
) : (
<Button onClick={onNext} variant="primary" size="sm" rightIcon={<ArrowRight />}>
{t('tour:next', 'Siguiente')}
</Button>
)}
</div>
</div>
{/* Arrow pointing to target */}
<div
className={`absolute w-4 h-4 bg-white border-gray-200 transform rotate-45 ${
placement === 'top'
? 'bottom-[-8px] border-b border-r'
: placement === 'bottom'
? 'top-[-8px] border-t border-l'
: placement === 'left'
? 'right-[-8px] border-t border-r'
: 'left-[-8px] border-b border-l'
}`}
style={{
[placement === 'top' || placement === 'bottom' ? 'left' : 'top']: '50%',
transform: `translate(-50%, 0) rotate(45deg)`,
}}
/>
</div>
);
};
export default TourTooltip;

View File

@@ -0,0 +1,3 @@
export { Tour, default } from './Tour';
export { TourTooltip } from './TourTooltip';
export { TourSpotlight } from './TourSpotlight';