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 { useState } from 'react';
|
||||||
import Sidebar from '@/components/layout/Sidebar';
|
import { useIsMobile } from '@/hooks/use-mobile';
|
||||||
import Header from '@/components/layout/Header';
|
import Header from './Header';
|
||||||
import HelpIcon from '@/components/help/HelpIcon';
|
import Sidebar from './Sidebar';
|
||||||
import { useToast } from "@/components/ui/use-toast";
|
import OnboardingTour from '@/components/onboarding/OnboardingTour';
|
||||||
import { Menu } from 'lucide-react';
|
import { useOnboardingTour } from '@/hooks/useOnboardingTour';
|
||||||
import { Button } from "@/components/ui/button";
|
|
||||||
import { Sheet, SheetContent, SheetTrigger } from "@/components/ui/sheet";
|
|
||||||
|
|
||||||
interface LayoutProps {
|
interface LayoutProps {
|
||||||
children: React.ReactNode;
|
children: React.ReactNode;
|
||||||
}
|
}
|
||||||
|
|
||||||
const Layout: React.FC<LayoutProps> = ({ children }) => {
|
export default function Layout({ children }: LayoutProps) {
|
||||||
const { toast } = useToast();
|
|
||||||
const [isMobile, setIsMobile] = useState(false);
|
|
||||||
const [sidebarOpen, setSidebarOpen] = useState(false);
|
const [sidebarOpen, setSidebarOpen] = useState(false);
|
||||||
|
const isMobile = useIsMobile();
|
||||||
|
|
||||||
useEffect(() => {
|
const {
|
||||||
const checkIfMobile = () => {
|
isOpen: tourOpen,
|
||||||
setIsMobile(window.innerWidth < 768);
|
currentStep,
|
||||||
};
|
nextStep,
|
||||||
|
skipTour,
|
||||||
|
closeTour
|
||||||
|
} = useOnboardingTour();
|
||||||
|
|
||||||
checkIfMobile();
|
const handleSidebarClose = () => {
|
||||||
window.addEventListener('resize', checkIfMobile);
|
|
||||||
|
|
||||||
return () => {
|
|
||||||
window.removeEventListener('resize', checkIfMobile);
|
|
||||||
};
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
const handleCloseSidebar = () => {
|
|
||||||
setSidebarOpen(false);
|
setSidebarOpen(false);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const handleMenuToggle = () => {
|
||||||
|
setSidebarOpen(!sidebarOpen);
|
||||||
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex h-screen overflow-hidden">
|
<div className="min-h-screen bg-background flex">
|
||||||
{isMobile ? (
|
{/* Mobile sidebar overlay */}
|
||||||
<Sheet open={sidebarOpen} onOpenChange={setSidebarOpen}>
|
{isMobile && sidebarOpen && (
|
||||||
<SheetTrigger asChild>
|
<div
|
||||||
<Button variant="ghost" size="icon" className="md:hidden fixed top-4 left-4 z-20">
|
className="fixed inset-0 z-40 bg-black bg-opacity-50"
|
||||||
<Menu className="h-5 w-5" />
|
onClick={handleSidebarClose}
|
||||||
<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="flex flex-col flex-1 overflow-hidden">
|
|
||||||
<Header />
|
{/* Sidebar */}
|
||||||
<main className="flex-1 overflow-auto p-4 pt-16 md:pt-4 md:p-6">
|
<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}
|
{children}
|
||||||
|
</div>
|
||||||
</main>
|
</main>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Help Icon */}
|
{/* Tour de Onboarding */}
|
||||||
<HelpIcon />
|
<OnboardingTour
|
||||||
|
isOpen={tourOpen}
|
||||||
|
currentStep={currentStep}
|
||||||
|
onNext={nextStep}
|
||||||
|
onSkip={skipTour}
|
||||||
|
onClose={closeTour}
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
};
|
}
|
||||||
|
|
||||||
export default Layout;
|
|
||||||
|
|||||||
@ -112,6 +112,7 @@ export default function Sidebar({ isOpen, onClose }: SidebarProps) {
|
|||||||
to="/whatsapp"
|
to="/whatsapp"
|
||||||
className={({ isActive }) => getNavLinkClass(isActive)}
|
className={({ isActive }) => getNavLinkClass(isActive)}
|
||||||
onClick={isMobile ? onClose : undefined}
|
onClick={isMobile ? onClose : undefined}
|
||||||
|
data-tour="whatsapp-menu"
|
||||||
>
|
>
|
||||||
<MessageSquareText className="mr-2 h-4 w-4 transition-transform group-hover:scale-110" />
|
<MessageSquareText className="mr-2 h-4 w-4 transition-transform group-hover:scale-110" />
|
||||||
<span>Conectar WhatsApp</span>
|
<span>Conectar WhatsApp</span>
|
||||||
@ -120,6 +121,7 @@ export default function Sidebar({ isOpen, onClose }: SidebarProps) {
|
|||||||
to="/grupos-whatsapp"
|
to="/grupos-whatsapp"
|
||||||
className={({ isActive }) => getNavLinkClass(isActive)}
|
className={({ isActive }) => getNavLinkClass(isActive)}
|
||||||
onClick={isMobile ? onClose : undefined}
|
onClick={isMobile ? onClose : undefined}
|
||||||
|
data-tour="grupos-menu"
|
||||||
>
|
>
|
||||||
<Users className="mr-2 h-4 w-4 transition-transform group-hover:scale-110" />
|
<Users className="mr-2 h-4 w-4 transition-transform group-hover:scale-110" />
|
||||||
<span>Grupos</span>
|
<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