Add onboarding tour for WhatsApp and Groups
Implement an onboarding tour that guides users through connecting WhatsApp and creating a group. The tour should only appear until the user has created at least one instance and one group.
This commit is contained in:
parent
dcdfa9ae9c
commit
6a9e57eb9a
@ -1,69 +1,75 @@
|
||||
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import Sidebar from '@/components/layout/Sidebar';
|
||||
import Header from '@/components/layout/Header';
|
||||
import HelpIcon from '@/components/help/HelpIcon';
|
||||
import { useToast } from "@/components/ui/use-toast";
|
||||
import { Menu } from 'lucide-react';
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Sheet, SheetContent, SheetTrigger } from "@/components/ui/sheet";
|
||||
import { useState } from 'react';
|
||||
import { useIsMobile } from '@/hooks/use-mobile';
|
||||
import Header from './Header';
|
||||
import Sidebar from './Sidebar';
|
||||
import OnboardingTour from '@/components/onboarding/OnboardingTour';
|
||||
import { useOnboardingTour } from '@/hooks/useOnboardingTour';
|
||||
|
||||
interface LayoutProps {
|
||||
children: React.ReactNode;
|
||||
}
|
||||
|
||||
const Layout: React.FC<LayoutProps> = ({ children }) => {
|
||||
const { toast } = useToast();
|
||||
const [isMobile, setIsMobile] = useState(false);
|
||||
export default function Layout({ children }: LayoutProps) {
|
||||
const [sidebarOpen, setSidebarOpen] = useState(false);
|
||||
const isMobile = useIsMobile();
|
||||
|
||||
useEffect(() => {
|
||||
const checkIfMobile = () => {
|
||||
setIsMobile(window.innerWidth < 768);
|
||||
};
|
||||
|
||||
checkIfMobile();
|
||||
window.addEventListener('resize', checkIfMobile);
|
||||
|
||||
return () => {
|
||||
window.removeEventListener('resize', checkIfMobile);
|
||||
};
|
||||
}, []);
|
||||
const {
|
||||
isOpen: tourOpen,
|
||||
currentStep,
|
||||
nextStep,
|
||||
skipTour,
|
||||
closeTour
|
||||
} = useOnboardingTour();
|
||||
|
||||
const handleCloseSidebar = () => {
|
||||
const handleSidebarClose = () => {
|
||||
setSidebarOpen(false);
|
||||
};
|
||||
|
||||
|
||||
const handleMenuToggle = () => {
|
||||
setSidebarOpen(!sidebarOpen);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="flex h-screen overflow-hidden">
|
||||
{isMobile ? (
|
||||
<Sheet open={sidebarOpen} onOpenChange={setSidebarOpen}>
|
||||
<SheetTrigger asChild>
|
||||
<Button variant="ghost" size="icon" className="md:hidden fixed top-4 left-4 z-20">
|
||||
<Menu className="h-5 w-5" />
|
||||
<span className="sr-only">Menu</span>
|
||||
</Button>
|
||||
</SheetTrigger>
|
||||
<SheetContent side="left" className="p-0 w-[250px]">
|
||||
<Sidebar isOpen={true} onClose={handleCloseSidebar} />
|
||||
</SheetContent>
|
||||
</Sheet>
|
||||
) : (
|
||||
<div className="hidden md:block w-64 flex-shrink-0">
|
||||
<Sidebar isOpen={true} onClose={() => {}} />
|
||||
</div>
|
||||
<div className="min-h-screen bg-background flex">
|
||||
{/* Mobile sidebar overlay */}
|
||||
{isMobile && sidebarOpen && (
|
||||
<div
|
||||
className="fixed inset-0 z-40 bg-black bg-opacity-50"
|
||||
onClick={handleSidebarClose}
|
||||
/>
|
||||
)}
|
||||
<div className="flex flex-col flex-1 overflow-hidden">
|
||||
<Header />
|
||||
<main className="flex-1 overflow-auto p-4 pt-16 md:pt-4 md:p-6">
|
||||
{children}
|
||||
|
||||
{/* Sidebar */}
|
||||
<div className={`
|
||||
${isMobile
|
||||
? `fixed inset-y-0 left-0 z-50 transform transition-transform duration-300 ${
|
||||
sidebarOpen ? 'translate-x-0' : '-translate-x-full'
|
||||
}`
|
||||
: 'relative'
|
||||
}
|
||||
`}>
|
||||
<Sidebar isOpen={sidebarOpen} onClose={handleSidebarClose} />
|
||||
</div>
|
||||
|
||||
{/* Main content */}
|
||||
<div className="flex-1 flex flex-col overflow-hidden">
|
||||
<Header onMenuToggle={handleMenuToggle} />
|
||||
<main className="flex-1 overflow-y-auto">
|
||||
<div className="container mx-auto px-4 py-6">
|
||||
{children}
|
||||
</div>
|
||||
</main>
|
||||
</div>
|
||||
|
||||
{/* Help Icon */}
|
||||
<HelpIcon />
|
||||
|
||||
{/* Tour de Onboarding */}
|
||||
<OnboardingTour
|
||||
isOpen={tourOpen}
|
||||
currentStep={currentStep}
|
||||
onNext={nextStep}
|
||||
onSkip={skipTour}
|
||||
onClose={closeTour}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default Layout;
|
||||
}
|
||||
|
||||
@ -112,6 +112,7 @@ export default function Sidebar({ isOpen, onClose }: SidebarProps) {
|
||||
to="/whatsapp"
|
||||
className={({ isActive }) => getNavLinkClass(isActive)}
|
||||
onClick={isMobile ? onClose : undefined}
|
||||
data-tour="whatsapp-menu"
|
||||
>
|
||||
<MessageSquareText className="mr-2 h-4 w-4 transition-transform group-hover:scale-110" />
|
||||
<span>Conectar WhatsApp</span>
|
||||
@ -120,6 +121,7 @@ export default function Sidebar({ isOpen, onClose }: SidebarProps) {
|
||||
to="/grupos-whatsapp"
|
||||
className={({ isActive }) => getNavLinkClass(isActive)}
|
||||
onClick={isMobile ? onClose : undefined}
|
||||
data-tour="grupos-menu"
|
||||
>
|
||||
<Users className="mr-2 h-4 w-4 transition-transform group-hover:scale-110" />
|
||||
<span>Grupos</span>
|
||||
|
||||
160
src/components/onboarding/OnboardingTour.tsx
Normal file
160
src/components/onboarding/OnboardingTour.tsx
Normal file
@ -0,0 +1,160 @@
|
||||
|
||||
import React from 'react';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Card, CardContent } from '@/components/ui/card';
|
||||
import { X } from 'lucide-react';
|
||||
|
||||
interface OnboardingTourProps {
|
||||
isOpen: boolean;
|
||||
currentStep: number;
|
||||
onNext: () => void;
|
||||
onSkip: () => void;
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
const OnboardingTour: React.FC<OnboardingTourProps> = ({
|
||||
isOpen,
|
||||
currentStep,
|
||||
onNext,
|
||||
onSkip,
|
||||
onClose
|
||||
}) => {
|
||||
if (!isOpen) return null;
|
||||
|
||||
const steps = [
|
||||
{
|
||||
title: "Passo 1 - Conecte seu WhatsApp",
|
||||
content: (
|
||||
<>
|
||||
<p className="mb-4">
|
||||
Para que o app funcione, você precisa integrar seu WhatsApp com nossa plataforma.
|
||||
</p>
|
||||
<p className="mb-4">
|
||||
Isso permitirá que suas transações feitas no WhatsApp apareçam automaticamente aqui no Finance Home.
|
||||
</p>
|
||||
<p className="font-semibold text-blue-600">
|
||||
👉 Clique em "Conectar WhatsApp", digite o código da cidade + seu número de WhatsApp e depois clique em "Criar Instância".
|
||||
</p>
|
||||
</>
|
||||
),
|
||||
spotlight: 'whatsapp-menu'
|
||||
},
|
||||
{
|
||||
title: "Passo 2 - Crie seu Grupo com a IA",
|
||||
content: (
|
||||
<>
|
||||
<p className="mb-4">
|
||||
Agora você precisa criar um grupo no WhatsApp com o nosso bot de IA chamado Angelina.
|
||||
</p>
|
||||
<p className="mb-4">
|
||||
Nele, você vai mandar mensagens, áudios ou comprovantes, e o sistema vai registrar automaticamente suas despesas e receitas.
|
||||
</p>
|
||||
<p className="font-semibold text-blue-600">
|
||||
👉 Clique em "Grupos", escolha o nome do grupo e clique em "Cadastrar Grupo".
|
||||
</p>
|
||||
</>
|
||||
),
|
||||
spotlight: 'grupos-menu'
|
||||
},
|
||||
{
|
||||
title: "Pronto! 🚀",
|
||||
content: (
|
||||
<>
|
||||
<p className="text-lg mb-4">
|
||||
Agora você pode começar a usar o Finance Home de forma automática.
|
||||
</p>
|
||||
<p className="text-base">
|
||||
Tudo que você enviar para o grupo vai ser registrado no app.
|
||||
</p>
|
||||
</>
|
||||
),
|
||||
spotlight: null
|
||||
}
|
||||
];
|
||||
|
||||
const currentStepData = steps[currentStep];
|
||||
|
||||
const getSpotlightStyle = () => {
|
||||
if (!currentStepData.spotlight) return {};
|
||||
|
||||
const element = document.querySelector(`[data-tour="${currentStepData.spotlight}"]`);
|
||||
if (!element) return {};
|
||||
|
||||
const rect = element.getBoundingClientRect();
|
||||
return {
|
||||
position: 'absolute' as const,
|
||||
left: rect.left - 10,
|
||||
top: rect.top - 10,
|
||||
width: rect.width + 20,
|
||||
height: rect.height + 20,
|
||||
borderRadius: '8px',
|
||||
boxShadow: '0 0 0 9999px rgba(0, 0, 0, 0.7), 0 0 20px rgba(59, 130, 246, 0.8)',
|
||||
pointerEvents: 'none' as const,
|
||||
zIndex: 9998
|
||||
};
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
{/* Overlay escuro */}
|
||||
<div
|
||||
className="fixed inset-0 bg-black bg-opacity-70 z-[9997]"
|
||||
style={{ zIndex: 9997 }}
|
||||
/>
|
||||
|
||||
{/* Spotlight */}
|
||||
{currentStepData.spotlight && (
|
||||
<div style={getSpotlightStyle()} />
|
||||
)}
|
||||
|
||||
{/* Card do tour */}
|
||||
<div className="fixed inset-0 flex items-center justify-center z-[9999] p-4">
|
||||
<Card className="w-full max-w-md bg-white shadow-2xl">
|
||||
<CardContent className="p-6">
|
||||
<div className="flex justify-between items-start mb-4">
|
||||
<h3 className="text-xl font-bold text-gray-800">
|
||||
{currentStepData.title}
|
||||
</h3>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={onClose}
|
||||
className="p-1 h-8 w-8"
|
||||
>
|
||||
<X className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div className="text-gray-600 mb-6">
|
||||
{currentStepData.content}
|
||||
</div>
|
||||
|
||||
<div className="flex justify-between items-center">
|
||||
<div className="flex space-x-1">
|
||||
{steps.map((_, index) => (
|
||||
<div
|
||||
key={index}
|
||||
className={`h-2 w-2 rounded-full ${
|
||||
index === currentStep ? 'bg-blue-600' : 'bg-gray-300'
|
||||
}`}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div className="flex space-x-2">
|
||||
<Button variant="outline" onClick={onSkip}>
|
||||
Pular Tour
|
||||
</Button>
|
||||
<Button onClick={onNext}>
|
||||
{currentStep === steps.length - 1 ? 'Finalizar' : 'Próximo'}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
export default OnboardingTour;
|
||||
3
src/components/onboarding/index.ts
Normal file
3
src/components/onboarding/index.ts
Normal file
@ -0,0 +1,3 @@
|
||||
|
||||
export { default as OnboardingTour } from './OnboardingTour';
|
||||
export { useOnboardingTour } from '@/hooks/useOnboardingTour';
|
||||
91
src/hooks/useOnboardingTour.ts
Normal file
91
src/hooks/useOnboardingTour.ts
Normal file
@ -0,0 +1,91 @@
|
||||
|
||||
import { useState, useEffect } from 'react';
|
||||
import { useLocation } from 'react-router-dom';
|
||||
import { useWhatsAppInstances } from '@/hooks/useWhatsAppInstances';
|
||||
import { listWhatsAppGroups } from '@/services/whatsAppGroupsService';
|
||||
|
||||
export const useOnboardingTour = () => {
|
||||
const [isOpen, setIsOpen] = useState(false);
|
||||
const [currentStep, setCurrentStep] = useState(0);
|
||||
const [shouldShowTour, setShouldShowTour] = useState(false);
|
||||
const location = useLocation();
|
||||
const { instances } = useWhatsAppInstances();
|
||||
|
||||
// Verificar se o tour deve ser exibido
|
||||
const checkTourConditions = async () => {
|
||||
try {
|
||||
// Verificar se há instâncias conectadas
|
||||
const hasConnectedInstance = instances.some(instance =>
|
||||
instance.status === 'connected' || instance.connectionState === 'open'
|
||||
);
|
||||
|
||||
// Verificar se há grupos cadastrados
|
||||
const groups = await listWhatsAppGroups();
|
||||
const hasGroups = groups.length > 0;
|
||||
|
||||
// Tour deve aparecer se NÃO tiver instância OU NÃO tiver grupos
|
||||
const shouldShow = !hasConnectedInstance || !hasGroups;
|
||||
|
||||
console.log('Tour conditions:', {
|
||||
hasConnectedInstance,
|
||||
hasGroups,
|
||||
shouldShow,
|
||||
instances: instances.length
|
||||
});
|
||||
|
||||
setShouldShowTour(shouldShow);
|
||||
|
||||
// Se deve mostrar o tour e não está aberto, abrir automaticamente
|
||||
if (shouldShow && !isOpen) {
|
||||
setIsOpen(true);
|
||||
setCurrentStep(0);
|
||||
} else if (!shouldShow && isOpen) {
|
||||
// Se as condições foram atendidas, fechar o tour
|
||||
setIsOpen(false);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Erro ao verificar condições do tour:', error);
|
||||
setShouldShowTour(false);
|
||||
}
|
||||
};
|
||||
|
||||
// Verificar condições quando instâncias mudarem
|
||||
useEffect(() => {
|
||||
if (instances.length >= 0) { // Verificar mesmo quando não há instâncias
|
||||
checkTourConditions();
|
||||
}
|
||||
}, [instances]);
|
||||
|
||||
// Verificar condições periodicamente
|
||||
useEffect(() => {
|
||||
const interval = setInterval(checkTourConditions, 5000); // A cada 5 segundos
|
||||
return () => clearInterval(interval);
|
||||
}, [instances]);
|
||||
|
||||
const nextStep = () => {
|
||||
if (currentStep < 2) {
|
||||
setCurrentStep(currentStep + 1);
|
||||
} else {
|
||||
closeTour();
|
||||
}
|
||||
};
|
||||
|
||||
const skipTour = () => {
|
||||
closeTour();
|
||||
};
|
||||
|
||||
const closeTour = () => {
|
||||
setIsOpen(false);
|
||||
setCurrentStep(0);
|
||||
};
|
||||
|
||||
return {
|
||||
isOpen,
|
||||
currentStep,
|
||||
shouldShowTour,
|
||||
nextStep,
|
||||
skipTour,
|
||||
closeTour,
|
||||
setCurrentStep
|
||||
};
|
||||
};
|
||||
Loading…
Reference in New Issue
Block a user