Fix multiple onboarding and navigation issues

**1. Remove duplicate navigation buttons in SetupWizard**
- Removed external navigation footer from SetupWizard (lines 370-383)
- All setup wizard steps now have only internal navigation buttons
- Prevents confusion with double Continue buttons in onboarding

**2. Fix quality template API call failure**
- Fixed userId validation in QualitySetupStep:17
- Changed from defaulting to empty string to undefined
- Added validation check before API call to prevent UUID errors
- Disabled submit button when userId not available
- Added error message display for missing user

Related: frontend/src/components/domain/setup-wizard/steps/QualitySetupStep.tsx:17,51-54,376

**3. Delete regular tours implementation (keep demo tour)**
Removed custom tours system while preserving demo tour functionality:
- Deleted TourContext.tsx and TourProvider
- Deleted Tour UI components folder
- Deleted tours/tours.ts definitions
- Deleted tour.json translations
- Removed TourProvider from App.tsx
- Removed TourButton from Sidebar

Demo tour (useDemoTour, driver.js) remains intact and functional.

Files deleted:
- frontend/src/contexts/TourContext.tsx
- frontend/src/components/ui/Tour/* (all files)
- frontend/src/tours/tours.ts
- frontend/src/locales/es/tour.json

**4. Issues verified/confirmed:**
- Quality type select UI already working (callback setState pattern)
- Inventory lots UI confirmed present in InventorySetupStep:683,788,833
- Lots UI visible after adding ingredients in onboarding flow

**Build Status:** ✓ All changes verified, build successful in 21.95s
This commit is contained in:
Claude
2025-11-06 21:26:09 +00:00
parent b9914e9af3
commit 163d4ba60d
12 changed files with 16 additions and 1110 deletions

View File

@@ -11,10 +11,8 @@ import { ThemeProvider } from './contexts/ThemeContext';
import { AuthProvider } from './contexts/AuthContext';
import { SSEProvider } from './contexts/SSEContext';
import { SubscriptionEventsProvider } from './contexts/SubscriptionEventsContext';
import { TourProvider } from './contexts/TourContext';
import GlobalSubscriptionHandler from './components/auth/GlobalSubscriptionHandler';
import { CookieBanner } from './components/ui/CookieConsent';
import { Tour } from './components/ui/Tour';
import i18n from './i18n';
const queryClient = new QueryClient({
@@ -67,10 +65,7 @@ function App() {
<AuthProvider>
<SSEProvider>
<SubscriptionEventsProvider>
<TourProvider>
<AppContent />
<Tour />
</TourProvider>
<AppContent />
</SubscriptionEventsProvider>
</SSEProvider>
</AuthProvider>

View File

@@ -366,21 +366,6 @@ export const SetupWizard: React.FC = () => {
canContinue={canContinue}
/>
</CardBody>
{/* Navigation Footer */}
<div className="border-t border-[var(--border-primary)] px-6 py-4 bg-[var(--bg-secondary)]/30">
<StepNavigation
currentStep={currentStep}
currentStepIndex={currentStepIndex}
totalSteps={SETUP_STEPS.length}
canContinue={canContinue}
onPrevious={handlePrevious}
onNext={handleNext}
onSkip={handleSkip}
onComplete={handleStepComplete}
isLoading={markStepCompleted.isPending}
/>
</div>
</Card>
</div>
);

View File

@@ -14,7 +14,7 @@ export const QualitySetupStep: React.FC<SetupStepProps> = ({ onUpdate, onComplet
const currentTenant = useCurrentTenant();
const user = useAuthUser();
const tenantId = currentTenant?.id || user?.tenant_id || '';
const userId = user?.id || '';
const userId = user?.id; // Keep undefined if not available - backend requires valid UUID
// Fetch quality templates
const { data: templatesData, isLoading } = useQualityTemplates(tenantId);
@@ -48,6 +48,12 @@ export const QualitySetupStep: React.FC<SetupStepProps> = ({ onUpdate, onComplet
const validateForm = (): boolean => {
const newErrors: Record<string, string> = {};
if (!userId) {
newErrors.form = t('common:error_loading_user', 'User not loaded. Please wait or refresh the page.');
setErrors(newErrors);
return false;
}
if (!formData.name.trim()) {
newErrors.name = t('setup_wizard:quality.errors.name_required', 'Name is required');
}
@@ -358,10 +364,16 @@ export const QualitySetupStep: React.FC<SetupStepProps> = ({ onUpdate, onComplet
</div>
</div>
{errors.form && (
<div className="p-3 bg-[var(--color-error)]/10 border border-[var(--color-error)]/20 rounded-lg text-sm text-[var(--color-error)]">
{errors.form}
</div>
)}
<div className="flex gap-2 pt-2">
<button
type="submit"
disabled={createTemplateMutation.isPending}
disabled={createTemplateMutation.isPending || !userId}
className="px-4 py-2 bg-[var(--color-primary)] text-white rounded-lg hover:bg-[var(--color-primary-dark)] disabled:opacity-50 disabled:cursor-not-allowed transition-colors font-medium"
>
{createTemplateMutation.isPending ? (

View File

@@ -12,7 +12,6 @@ import { Button } from '../../ui';
import { Badge } from '../../ui';
import { Tooltip } from '../../ui';
import { Avatar } from '../../ui';
import { TourButton } from '../../ui/Tour/TourButton';
import {
LayoutDashboard,
Package,
@@ -597,16 +596,6 @@ export const Sidebar = forwardRef<SidebarRef, SidebarProps>(({
const isHovered = hoveredItem === item.id;
const ItemIcon = item.icon;
// Add tour data attributes for main navigation sections
const getTourAttribute = (itemPath: string) => {
if (itemPath === '/app/database') return 'sidebar-database';
if (itemPath === '/app/operations') return 'sidebar-operations';
if (itemPath === '/app/analytics') return 'sidebar-analytics';
return undefined;
};
const tourAttr = getTourAttribute(item.path);
const itemContent = (
<div
className={clsx(
@@ -726,7 +715,7 @@ export const Sidebar = forwardRef<SidebarRef, SidebarProps>(({
);
return (
<li key={item.id} className="relative" data-tour={tourAttr}>
<li key={item.id} className="relative">
{isCollapsed && !hasChildren && ItemIcon ? (
<Tooltip content={item.label} side="right">
{button}
@@ -812,13 +801,6 @@ export const Sidebar = forwardRef<SidebarRef, SidebarProps>(({
</div>
)}
{/* Tour Button */}
{!isCollapsed && (
<div className="px-4 pb-2">
<TourButton variant="button" />
</div>
)}
{/* Navigation */}
<nav className={clsx('flex-1 overflow-y-auto overflow-x-hidden', isCollapsed ? 'px-1 py-4' : 'p-4')}>
<ul className={clsx(isCollapsed ? 'space-y-1 flex flex-col items-center' : 'space-y-2')}>

View File

@@ -1,49 +0,0 @@
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

@@ -1,164 +0,0 @@
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

@@ -1,91 +0,0 @@
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

@@ -1,206 +0,0 @@
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

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

View File

@@ -1,238 +0,0 @@
import React, { createContext, useContext, useState, useEffect, ReactNode } from 'react';
export interface TourStep {
id: string;
target: string; // CSS selector
title: string;
content: string;
placement?: 'top' | 'bottom' | 'left' | 'right';
action?: {
label: string;
onClick: () => void;
};
beforeShow?: () => void | Promise<void>;
afterShow?: () => void | Promise<void>;
}
export interface Tour {
id: string;
name: string;
steps: TourStep[];
onComplete?: () => void;
onSkip?: () => void;
}
export interface TourState {
currentTour: Tour | null;
currentStepIndex: number;
isActive: boolean;
completedTours: string[];
skippedTours: string[];
}
export interface TourContextValue {
state: TourState;
startTour: (tour: Tour) => void;
nextStep: () => void;
previousStep: () => void;
skipTour: () => void;
completeTour: () => void;
resetTour: () => void;
isTourCompleted: (tourId: string) => boolean;
isTourSkipped: (tourId: string) => boolean;
markTourCompleted: (tourId: string) => void;
}
const initialState: TourState = {
currentTour: null,
currentStepIndex: 0,
isActive: false,
completedTours: [],
skippedTours: [],
};
const TourContext = createContext<TourContextValue | undefined>(undefined);
const STORAGE_KEY = 'bakery_ia_tours';
export interface TourProviderProps {
children: ReactNode;
}
export const TourProvider: React.FC<TourProviderProps> = ({ children }) => {
const [state, setState] = useState<TourState>(() => {
// Load persisted state from localStorage
try {
const stored = localStorage.getItem(STORAGE_KEY);
if (stored) {
const parsed = JSON.parse(stored);
return {
...initialState,
completedTours: parsed.completedTours || [],
skippedTours: parsed.skippedTours || [],
};
}
} catch (error) {
console.error('Failed to load tour state:', error);
}
return initialState;
});
// Persist completed and skipped tours to localStorage
useEffect(() => {
try {
const toStore = {
completedTours: state.completedTours,
skippedTours: state.skippedTours,
};
localStorage.setItem(STORAGE_KEY, JSON.stringify(toStore));
} catch (error) {
console.error('Failed to save tour state:', error);
}
}, [state.completedTours, state.skippedTours]);
const startTour = async (tour: Tour) => {
// Don't start if already completed or skipped
if (state.completedTours.includes(tour.id) || state.skippedTours.includes(tour.id)) {
console.log(`Tour ${tour.id} already completed or skipped`);
return;
}
// Execute beforeShow for first step
if (tour.steps[0]?.beforeShow) {
await tour.steps[0].beforeShow();
}
setState(prev => ({
...prev,
currentTour: tour,
currentStepIndex: 0,
isActive: true,
}));
// Execute afterShow for first step
if (tour.steps[0]?.afterShow) {
await tour.steps[0].afterShow();
}
};
const nextStep = async () => {
if (!state.currentTour) return;
const nextIndex = state.currentStepIndex + 1;
if (nextIndex >= state.currentTour.steps.length) {
// Tour completed
completeTour();
return;
}
const nextStep = state.currentTour.steps[nextIndex];
// Execute beforeShow
if (nextStep?.beforeShow) {
await nextStep.beforeShow();
}
setState(prev => ({
...prev,
currentStepIndex: nextIndex,
}));
// Execute afterShow
if (nextStep?.afterShow) {
await nextStep.afterShow();
}
};
const previousStep = () => {
if (!state.currentTour || state.currentStepIndex === 0) return;
setState(prev => ({
...prev,
currentStepIndex: prev.currentStepIndex - 1,
}));
};
const skipTour = () => {
if (!state.currentTour) return;
const tourId = state.currentTour.id;
state.currentTour.onSkip?.();
setState(prev => ({
...prev,
currentTour: null,
currentStepIndex: 0,
isActive: false,
skippedTours: [...prev.skippedTours, tourId],
}));
};
const completeTour = () => {
if (!state.currentTour) return;
const tourId = state.currentTour.id;
state.currentTour.onComplete?.();
setState(prev => ({
...prev,
currentTour: null,
currentStepIndex: 0,
isActive: false,
completedTours: [...prev.completedTours, tourId],
}));
};
const resetTour = () => {
setState(prev => ({
...prev,
currentTour: null,
currentStepIndex: 0,
isActive: false,
}));
};
const isTourCompleted = (tourId: string): boolean => {
return state.completedTours.includes(tourId);
};
const isTourSkipped = (tourId: string): boolean => {
return state.skippedTours.includes(tourId);
};
const markTourCompleted = (tourId: string) => {
setState(prev => ({
...prev,
completedTours: [...prev.completedTours, tourId],
}));
};
const value: TourContextValue = {
state,
startTour,
nextStep,
previousStep,
skipTour,
completeTour,
resetTour,
isTourCompleted,
isTourSkipped,
markTourCompleted,
};
return <TourContext.Provider value={value}>{children}</TourContext.Provider>;
};
/**
* Hook to access tour context
*/
export const useTour = (): TourContextValue => {
const context = useContext(TourContext);
if (!context) {
throw new Error('useTour must be used within a TourProvider');
}
return context;
};
export default TourContext;

View File

@@ -1,41 +0,0 @@
{
"step_progress": "Paso {{current}} de {{total}}",
"close": "Cerrar",
"previous": "Anterior",
"next": "Siguiente",
"finish": "Finalizar",
"skip": "Omitir tour",
"start_tour": "Iniciar tour",
"restart_tour": "Reiniciar tour",
"tours": {
"dashboard": {
"name": "Tour del Dashboard",
"description": "Conoce las funciones principales de tu dashboard"
},
"inventory": {
"name": "Tour de Inventario",
"description": "Aprende a gestionar tu inventario eficientemente"
},
"recipes": {
"name": "Tour de Recetas",
"description": "Descubre cómo crear y gestionar recetas"
},
"production": {
"name": "Tour de Producción",
"description": "Planifica tu producción con confianza"
},
"post_onboarding": {
"name": "Primeros Pasos",
"description": "Comienza a usar tu sistema de gestión"
}
},
"completed": {
"title": "¡Tour Completado!",
"message": "Has completado el tour de {{tourName}}",
"cta": "Entendido"
},
"trigger": {
"tooltip": "¿Necesitas ayuda? Inicia un tour guiado",
"button": "Tours Disponibles"
}
}

View File

@@ -1,276 +0,0 @@
import { Tour } from '../contexts/TourContext';
/**
* Dashboard Tour
* Guides users through the main dashboard features
*/
export const dashboardTour: Tour = {
id: 'dashboard-tour',
name: 'Tour del Dashboard',
steps: [
{
id: 'dashboard-welcome',
target: '[data-tour="dashboard-header"]',
title: '¡Bienvenido a tu Dashboard!',
content:
'Este es tu centro de control. Aquí verás un resumen de toda tu operación de panadería en tiempo real.',
placement: 'bottom',
},
{
id: 'dashboard-stats',
target: '[data-tour="stats-cards"]',
title: 'Estadísticas Clave',
content:
'Estas tarjetas muestran las métricas más importantes: ventas del día, inventario crítico, producción pendiente y alertas de calidad.',
placement: 'bottom',
},
{
id: 'dashboard-forecast',
target: '[data-tour="forecast-chart"]',
title: 'Pronóstico de Demanda con IA',
content:
'Nuestra IA analiza tus datos históricos para predecir la demanda futura. Úsalo para planificar tu producción.',
placement: 'top',
},
{
id: 'dashboard-inventory',
target: '[data-tour="inventory-alerts"]',
title: 'Alertas de Inventario',
content:
'Aquí verás alertas sobre ingredientes que están por agotarse o que han caducado. ¡Nunca te quedarás sin stock!',
placement: 'left',
},
{
id: 'dashboard-navigation',
target: '[data-tour="main-navigation"]',
title: 'Navegación Principal',
content:
'Usa este menú para acceder a todas las funciones: Inventario, Recetas, Producción, Ventas, Proveedores y más.',
placement: 'right',
},
],
};
/**
* Inventory Tour
* Guides users through inventory management features
*/
export const inventoryTour: Tour = {
id: 'inventory-tour',
name: 'Tour de Inventario',
steps: [
{
id: 'inventory-welcome',
target: '[data-tour="inventory-header"]',
title: 'Gestión de Inventario',
content:
'Aquí administras todos tus ingredientes y productos terminados. Mantén el control total de tu stock.',
placement: 'bottom',
},
{
id: 'inventory-add',
target: '[data-tour="add-item-button"]',
title: 'Agregar Ingredientes',
content:
'Haz clic aquí para agregar nuevos ingredientes. Puedes ingresar nombre, categoría, unidad de medida, precio y más.',
placement: 'left',
action: {
label: 'Ver formulario',
onClick: () => {
const button = document.querySelector('[data-tour="add-item-button"]') as HTMLButtonElement;
button?.click();
},
},
},
{
id: 'inventory-search',
target: '[data-tour="search-filter"]',
title: 'Buscar y Filtrar',
content:
'Usa la barra de búsqueda y filtros para encontrar rápidamente lo que necesitas. Filtra por categoría, estado de stock o proveedor.',
placement: 'bottom',
},
{
id: 'inventory-table',
target: '[data-tour="inventory-table"]',
title: 'Lista de Inventario',
content:
'Aquí ves todos tus ingredientes con stock actual, punto de reorden, costo y acciones rápidas para editar o eliminar.',
placement: 'top',
},
{
id: 'inventory-alerts',
target: '[data-tour="stock-alerts"]',
title: 'Alertas de Stock',
content:
'Los ingredientes con stock bajo aparecen resaltados. Configura puntos de reorden para recibir alertas automáticas.',
placement: 'left',
},
],
};
/**
* Recipes Tour
* Guides users through recipe management
*/
export const recipesTour: Tour = {
id: 'recipes-tour',
name: 'Tour de Recetas',
steps: [
{
id: 'recipes-welcome',
target: '[data-tour="recipes-header"]',
title: 'Gestión de Recetas',
content:
'Define tus recetas con ingredientes, cantidades y procesos. El sistema calculará costos automáticamente.',
placement: 'bottom',
},
{
id: 'recipes-add',
target: '[data-tour="add-recipe-button"]',
title: 'Crear Receta',
content:
'Haz clic para crear una nueva receta. Agrega ingredientes, define cantidades y establece el rendimiento esperado.',
placement: 'left',
},
{
id: 'recipes-cost',
target: '[data-tour="recipe-cost-column"]',
title: 'Cálculo de Costos',
content:
'El sistema calcula automáticamente el costo de cada receta basándose en los precios de los ingredientes.',
placement: 'top',
},
{
id: 'recipes-yield',
target: '[data-tour="recipe-yield"]',
title: 'Rendimiento de Receta',
content:
'Define cuántas unidades produce cada receta. Esto es crucial para planificar la producción correctamente.',
placement: 'bottom',
},
{
id: 'recipes-batch',
target: '[data-tour="batch-multiplier"]',
title: 'Multiplicador de Lote',
content:
'¿Necesitas hacer múltiples lotes? Usa el multiplicador para escalar automáticamente las cantidades de ingredientes.',
placement: 'left',
},
],
};
/**
* Production Tour
* Guides users through production planning
*/
export const productionTour: Tour = {
id: 'production-tour',
name: 'Tour de Producción',
steps: [
{
id: 'production-welcome',
target: '[data-tour="production-header"]',
title: 'Planificación de Producción',
content:
'Planifica qué y cuánto producir cada día. El sistema te ayudará basándose en el pronóstico de demanda.',
placement: 'bottom',
},
{
id: 'production-schedule',
target: '[data-tour="production-schedule"]',
title: 'Calendario de Producción',
content:
'Visualiza tu plan de producción por día, semana o mes. Arrastra y suelta para reorganizar fácilmente.',
placement: 'top',
},
{
id: 'production-forecast',
target: '[data-tour="production-forecast"]',
title: 'Recomendaciones de IA',
content:
'La IA sugiere cantidades óptimas basadas en el pronóstico de demanda, inventario actual y ventas históricas.',
placement: 'bottom',
},
{
id: 'production-batch',
target: '[data-tour="create-batch-button"]',
title: 'Crear Lote de Producción',
content:
'Crea un nuevo lote seleccionando la receta y cantidad. El sistema verificará que tengas suficiente inventario.',
placement: 'left',
},
{
id: 'production-status',
target: '[data-tour="production-status"]',
title: 'Estado de Lotes',
content:
'Rastrea el estado de cada lote: Planificado, En Proceso, Completado o Cancelado. Actualiza en tiempo real.',
placement: 'top',
},
],
};
/**
* Post-Onboarding Tour
* First tour shown after completing onboarding
*/
export const postOnboardingTour: Tour = {
id: 'post-onboarding-tour',
name: 'Primeros Pasos',
steps: [
{
id: 'welcome',
target: 'body',
title: '¡Configuración Completa! 🎉',
content:
'Tu panadería está lista. Ahora te mostraremos las funciones principales para que puedas empezar a trabajar.',
placement: 'bottom',
},
{
id: 'dashboard-overview',
target: '[data-tour="main-navigation"]',
title: 'Navegación Principal',
content:
'Desde aquí puedes acceder a todas las secciones: Dashboard, Inventario, Recetas, Producción, Ventas y más.',
placement: 'right',
},
{
id: 'quick-actions',
target: '[data-tour="quick-actions"]',
title: 'Acciones Rápidas',
content:
'Usa estos botones para acciones frecuentes: agregar ingrediente, crear receta, planificar producción.',
placement: 'bottom',
},
{
id: 'notifications',
target: '[data-tour="notifications-button"]',
title: 'Notificaciones',
content:
'Aquí verás alertas importantes: stock bajo, lotes pendientes, vencimientos próximos.',
placement: 'left',
},
{
id: 'help',
target: '[data-tour="help-button"]',
title: 'Ayuda Siempre Disponible',
content:
'Si necesitas ayuda, haz clic aquí para acceder a tutoriales, documentación o contactar soporte.',
placement: 'left',
},
],
};
/**
* All available tours
*/
export const allTours = {
dashboard: dashboardTour,
inventory: inventoryTour,
recipes: recipesTour,
production: productionTour,
postOnboarding: postOnboardingTour,
};
export default allTours;