Checkout transparente Pix+Stripe e funis integrados
- Adiciona pagina /checkout com todos os 4 planos, seletor bento, Pix (QR + polling) e cartao (Stripe redirect) - Modo locked: plano fixo em starter quando source=landing/funil/campanha - Offer.tsx e WhatsAppFunnel.tsx redirecionam para /checkout?plan=starter - Adiciona wooviService (criar cobranca e polling status via n8n) - Rota /checkout registrada no App Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
parent
f240abaeb1
commit
356b990368
5 changed files with 731 additions and 93 deletions
|
|
@ -2,6 +2,7 @@ import { BrowserRouter, Routes, Route } from 'react-router-dom';
|
||||||
import LandingPage from './pages/LandingPage';
|
import LandingPage from './pages/LandingPage';
|
||||||
import SuccessPage from './pages/SuccessPage';
|
import SuccessPage from './pages/SuccessPage';
|
||||||
import WhatsAppFunnel from './pages/WhatsAppFunnel';
|
import WhatsAppFunnel from './pages/WhatsAppFunnel';
|
||||||
|
import Checkout from './pages/Checkout';
|
||||||
|
|
||||||
export default function App() {
|
export default function App() {
|
||||||
return (
|
return (
|
||||||
|
|
@ -10,6 +11,7 @@ export default function App() {
|
||||||
<Route path="/" element={<LandingPage />} />
|
<Route path="/" element={<LandingPage />} />
|
||||||
<Route path="/sucesso" element={<SuccessPage />} />
|
<Route path="/sucesso" element={<SuccessPage />} />
|
||||||
<Route path="/funil" element={<WhatsAppFunnel />} />
|
<Route path="/funil" element={<WhatsAppFunnel />} />
|
||||||
|
<Route path="/checkout" element={<Checkout />} />
|
||||||
</Routes>
|
</Routes>
|
||||||
</BrowserRouter>
|
</BrowserRouter>
|
||||||
);
|
);
|
||||||
|
|
|
||||||
|
|
@ -3,12 +3,6 @@ import { motion } from 'framer-motion';
|
||||||
import { Check, ShieldCheck, Gift, ArrowRight, Loader2 } from 'lucide-react';
|
import { Check, ShieldCheck, Gift, ArrowRight, Loader2 } from 'lucide-react';
|
||||||
import { cn } from '../lib/utils';
|
import { cn } from '../lib/utils';
|
||||||
|
|
||||||
function getCookie(name: string) {
|
|
||||||
const match = document.cookie.match(
|
|
||||||
new RegExp('(^| )' + name + '=([^;]+)')
|
|
||||||
);
|
|
||||||
return match ? match[2] : '';
|
|
||||||
}
|
|
||||||
|
|
||||||
export default function Offer() {
|
export default function Offer() {
|
||||||
const benefits = [
|
const benefits = [
|
||||||
|
|
@ -41,34 +35,7 @@ export default function Offer() {
|
||||||
setIsLoading(true);
|
setIsLoading(true);
|
||||||
setError('');
|
setError('');
|
||||||
|
|
||||||
const fbp = getCookie('_fbp');
|
window.location.href = `/checkout?plan=starter&source=landing&email=${encodeURIComponent(email)}`;
|
||||||
const fbc = getCookie('_fbc');
|
|
||||||
|
|
||||||
try {
|
|
||||||
const res = await fetch('https://n8n.seureview.com.br/webhook/festa-magica-stripe', {
|
|
||||||
method: 'POST',
|
|
||||||
headers: {
|
|
||||||
'Content-Type': 'application/json',
|
|
||||||
},
|
|
||||||
body: JSON.stringify({
|
|
||||||
source: 'landing',
|
|
||||||
userEmail: email,
|
|
||||||
fbp: fbp || '',
|
|
||||||
fbc: fbc || ''
|
|
||||||
}),
|
|
||||||
});
|
|
||||||
|
|
||||||
const data = await res.json();
|
|
||||||
if (data.success && data.url) {
|
|
||||||
window.location.href = data.url;
|
|
||||||
} else {
|
|
||||||
setError('Ocorreu um erro ao gerar o checkout. Tente novamente.');
|
|
||||||
setIsLoading(false);
|
|
||||||
}
|
|
||||||
} catch (err) {
|
|
||||||
setError('Erro de conexão. Verifique sua internet e tente novamente.');
|
|
||||||
setIsLoading(false);
|
|
||||||
}
|
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
|
|
||||||
625
src/pages/Checkout.tsx
Normal file
625
src/pages/Checkout.tsx
Normal file
|
|
@ -0,0 +1,625 @@
|
||||||
|
import { useState, useEffect, useRef, type FormEvent } from 'react';
|
||||||
|
import { useSearchParams, useNavigate } from 'react-router-dom';
|
||||||
|
import { motion, AnimatePresence } from 'framer-motion';
|
||||||
|
import {
|
||||||
|
ShieldCheck, Lock, QrCode, CreditCard, Copy, Check,
|
||||||
|
Loader2, Gift, Sparkles, ArrowRight, Star, Wand2, Zap,
|
||||||
|
} from 'lucide-react';
|
||||||
|
import { cn } from '../lib/utils';
|
||||||
|
import { createPixCharge, pollPixStatus, type PixCharge } from '../services/wooviService';
|
||||||
|
|
||||||
|
// ─── Planos (espelha src/utils/pricing.ts do app) ──────────────────────────
|
||||||
|
const PLANS = [
|
||||||
|
{
|
||||||
|
id: 'starter',
|
||||||
|
name: 'Starter',
|
||||||
|
credits: 10,
|
||||||
|
price: 999,
|
||||||
|
display: 'R$9,99',
|
||||||
|
perImg: 'R$1,00/img',
|
||||||
|
priceId: 'price_1TT5lpLjQo8Mvvq1HdDD7paN',
|
||||||
|
planName: 'Festa Magica IA - Kit Inicial',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'popular',
|
||||||
|
name: 'Kit Festa',
|
||||||
|
credits: 30,
|
||||||
|
price: 1999,
|
||||||
|
display: 'R$19,99',
|
||||||
|
perImg: 'R$0,67/img',
|
||||||
|
priceId: 'price_1TT5kTLjQo8Mvvq1Tus1RS5D',
|
||||||
|
planName: 'Festa Magica IA - Kit Festa',
|
||||||
|
badge: 'Mais vendido',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'pro',
|
||||||
|
name: 'Festa VIP',
|
||||||
|
credits: 80,
|
||||||
|
price: 4999,
|
||||||
|
display: 'R$49,99',
|
||||||
|
perImg: 'R$0,62/img',
|
||||||
|
priceId: 'price_1THThELjQo8Mvvq1uXkgh5jB',
|
||||||
|
planName: 'Festa Magica IA - Festa VIP',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'professional',
|
||||||
|
name: 'Profissional',
|
||||||
|
credits: 200,
|
||||||
|
price: 9999,
|
||||||
|
display: 'R$99,99',
|
||||||
|
perImg: 'R$0,50/img',
|
||||||
|
priceId: 'price_1TT5i9LjQo8Mvvq12yhPqe1G',
|
||||||
|
planName: 'Festa Magica IA - Profissional',
|
||||||
|
},
|
||||||
|
] as const;
|
||||||
|
|
||||||
|
type Plan = (typeof PLANS)[number];
|
||||||
|
type Method = 'pix' | 'card';
|
||||||
|
|
||||||
|
const STRIPE_URL = 'https://n8n.seureview.com.br/webhook/festa-magica-stripe';
|
||||||
|
|
||||||
|
function getCookie(name: string) {
|
||||||
|
const match = document.cookie.match(new RegExp('(^| )' + name + '=([^;]+)'));
|
||||||
|
return match ? match[2] : '';
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function Checkout() {
|
||||||
|
const [searchParams] = useSearchParams();
|
||||||
|
const navigate = useNavigate();
|
||||||
|
|
||||||
|
const source = searchParams.get('source') || 'landing';
|
||||||
|
const userId = searchParams.get('userId') || '';
|
||||||
|
const locked = searchParams.get('lock') === '1' || ['landing', 'funil', 'campanha'].includes(searchParams.get('source') || '');
|
||||||
|
const [email, setEmail] = useState(searchParams.get('email') || '');
|
||||||
|
const [method, setMethod] = useState<Method>('pix');
|
||||||
|
const [error, setError] = useState('');
|
||||||
|
|
||||||
|
// Plano inicial vindo da URL (?plan=popular) ou padrão = starter
|
||||||
|
const initialPlan = PLANS.find(p => p.id === searchParams.get('plan')) ?? PLANS[0];
|
||||||
|
const [plan, setPlan] = useState<Plan>(initialPlan);
|
||||||
|
|
||||||
|
// ── Pix ──────────────────────────────────────────────────────────────────
|
||||||
|
const [pixLoading, setPixLoading] = useState(false);
|
||||||
|
const [pix, setPix] = useState<PixCharge | null>(null);
|
||||||
|
const [copied, setCopied] = useState(false);
|
||||||
|
const [confirming, setConfirming] = useState(false);
|
||||||
|
const pollRef = useRef<ReturnType<typeof setInterval> | null>(null);
|
||||||
|
|
||||||
|
// ── Cartão ────────────────────────────────────────────────────────────────
|
||||||
|
const [cardLoading, setCardLoading] = useState(false);
|
||||||
|
|
||||||
|
// ── Skeleton de entrada ───────────────────────────────────────────────────
|
||||||
|
const [ready, setReady] = useState(false);
|
||||||
|
useEffect(() => {
|
||||||
|
const t = setTimeout(() => setReady(true), 1200);
|
||||||
|
return () => clearTimeout(t);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
useEffect(() => () => { if (pollRef.current) clearInterval(pollRef.current); }, []);
|
||||||
|
|
||||||
|
// Resetar Pix ao trocar de plano
|
||||||
|
useEffect(() => {
|
||||||
|
if (pollRef.current) clearInterval(pollRef.current);
|
||||||
|
setPix(null);
|
||||||
|
setConfirming(false);
|
||||||
|
setError('');
|
||||||
|
}, [plan.id]);
|
||||||
|
|
||||||
|
const validEmail = /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email);
|
||||||
|
|
||||||
|
const trackEvent = () => {
|
||||||
|
if (typeof window !== 'undefined' && 'fbq' in window) {
|
||||||
|
(window as any).fbq('track', 'InitiateCheckout');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// ── Pix ──────────────────────────────────────────────────────────────────
|
||||||
|
const handlePix = async (e?: FormEvent) => {
|
||||||
|
e?.preventDefault();
|
||||||
|
if (!validEmail) { setError('Informe um e-mail válido para continuar.'); return; }
|
||||||
|
setError('');
|
||||||
|
setPixLoading(true);
|
||||||
|
trackEvent();
|
||||||
|
try {
|
||||||
|
const charge = await createPixCharge({
|
||||||
|
userEmail: email,
|
||||||
|
source,
|
||||||
|
userId,
|
||||||
|
value: plan.price,
|
||||||
|
credits: plan.credits,
|
||||||
|
planName: plan.planName,
|
||||||
|
priceId: plan.priceId,
|
||||||
|
fbp: getCookie('_fbp'),
|
||||||
|
fbc: getCookie('_fbc'),
|
||||||
|
});
|
||||||
|
setPix(charge);
|
||||||
|
startPolling(charge.correlationID);
|
||||||
|
} catch (err: any) {
|
||||||
|
setError(err.message || 'Não foi possível gerar o Pix. Tente novamente.');
|
||||||
|
} finally {
|
||||||
|
setPixLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const startPolling = (correlationID: string) => {
|
||||||
|
setConfirming(true);
|
||||||
|
if (pollRef.current) clearInterval(pollRef.current);
|
||||||
|
pollRef.current = setInterval(async () => {
|
||||||
|
try {
|
||||||
|
const status = await pollPixStatus(correlationID);
|
||||||
|
if (status === 'COMPLETED') {
|
||||||
|
clearInterval(pollRef.current!);
|
||||||
|
navigate('/sucesso?status=approved', { replace: true });
|
||||||
|
} else if (status === 'EXPIRED') {
|
||||||
|
clearInterval(pollRef.current!);
|
||||||
|
setConfirming(false);
|
||||||
|
setPix(null);
|
||||||
|
setError('O Pix expirou. Gere um novo código para pagar.');
|
||||||
|
}
|
||||||
|
} catch { /* silencia; tenta no próximo tick */ }
|
||||||
|
}, 4000);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleCopy = async () => {
|
||||||
|
if (!pix) return;
|
||||||
|
try {
|
||||||
|
await navigator.clipboard.writeText(pix.brCode);
|
||||||
|
setCopied(true);
|
||||||
|
setTimeout(() => setCopied(false), 2000);
|
||||||
|
} catch { setError('Não consegui copiar. Copie o código manualmente.'); }
|
||||||
|
};
|
||||||
|
|
||||||
|
// ── Cartão ────────────────────────────────────────────────────────────────
|
||||||
|
const handleCard = async (e?: FormEvent) => {
|
||||||
|
e?.preventDefault();
|
||||||
|
if (!validEmail) { setError('Informe um e-mail válido para continuar.'); return; }
|
||||||
|
setError('');
|
||||||
|
setCardLoading(true);
|
||||||
|
trackEvent();
|
||||||
|
try {
|
||||||
|
const res = await fetch(STRIPE_URL, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({
|
||||||
|
source,
|
||||||
|
userEmail: email,
|
||||||
|
userId,
|
||||||
|
priceId: plan.priceId,
|
||||||
|
credits: plan.credits,
|
||||||
|
planName: plan.planName,
|
||||||
|
fbp: getCookie('_fbp'),
|
||||||
|
fbc: getCookie('_fbc'),
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
const data = await res.json();
|
||||||
|
if (data.success && data.url) {
|
||||||
|
window.location.href = data.url;
|
||||||
|
} else {
|
||||||
|
setCardLoading(false);
|
||||||
|
setError('Ocorreu um erro ao abrir o pagamento. Tente novamente.');
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
setCardLoading(false);
|
||||||
|
setError('Erro de conexão. Verifique sua internet e tente novamente.');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const features = [
|
||||||
|
'Personagem 3D exclusivo criado com IA',
|
||||||
|
`${plan.credits} créditos para itens do catálogo`,
|
||||||
|
'Arquivos em PDF prontos para impressão',
|
||||||
|
'Acesso imediato à plataforma',
|
||||||
|
];
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="min-h-screen bg-[#f7f7f9] font-sans text-gray-900">
|
||||||
|
|
||||||
|
{/* ── Topo ─────────────────────────────────────────────────────── */}
|
||||||
|
<header className="bg-white border-b border-gray-100">
|
||||||
|
<div className="max-w-5xl mx-auto px-5 sm:px-8 h-16 flex items-center justify-between">
|
||||||
|
<div className="flex items-center gap-2.5">
|
||||||
|
<div className="w-8 h-8 rounded-xl bg-gradient-to-br from-pink-500 to-violet-500 flex items-center justify-center shadow-sm shadow-pink-500/30">
|
||||||
|
<Wand2 className="w-4 h-4 text-white" strokeWidth={2.5} />
|
||||||
|
</div>
|
||||||
|
<span className="font-display font-bold text-lg tracking-tight text-gray-900">
|
||||||
|
Festa Mágica<span className="text-pink-500">IA</span>
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-1.5 text-xs text-emerald-700 font-semibold bg-emerald-50 border border-emerald-100 px-3 py-1.5 rounded-full">
|
||||||
|
<Lock className="w-3.5 h-3.5" strokeWidth={2.5} />
|
||||||
|
Ambiente seguro
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<div className="max-w-5xl mx-auto px-5 sm:px-8 py-8 sm:py-12">
|
||||||
|
|
||||||
|
{/* ── Skeleton ─────────────────────────────────────────────────────── */}
|
||||||
|
<AnimatePresence>
|
||||||
|
{!ready && (
|
||||||
|
<motion.div
|
||||||
|
key="skeleton"
|
||||||
|
initial={{ opacity: 1 }}
|
||||||
|
exit={{ opacity: 0 }}
|
||||||
|
transition={{ duration: 0.4 }}
|
||||||
|
className="grid lg:grid-cols-[1fr_380px] gap-6"
|
||||||
|
>
|
||||||
|
{/* Esquerda */}
|
||||||
|
<div className="space-y-5">
|
||||||
|
{!locked && (
|
||||||
|
<div className="bg-white rounded-2xl border border-gray-100 shadow-sm p-6 space-y-4">
|
||||||
|
<div className="h-3 w-24 bg-gray-100 rounded-full animate-pulse" />
|
||||||
|
<div className="grid grid-cols-2 gap-3">
|
||||||
|
{[...Array(4)].map((_, i) => (
|
||||||
|
<div key={i} className="h-24 bg-gray-100 rounded-xl animate-pulse" style={{ animationDelay: `${i * 80}ms` }} />
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<div className="bg-white rounded-2xl border border-gray-100 shadow-sm p-6 space-y-4">
|
||||||
|
<div className="h-3 w-32 bg-gray-100 rounded-full animate-pulse" />
|
||||||
|
{[...Array(4)].map((_, i) => (
|
||||||
|
<div key={i} className="h-3 bg-gray-100 rounded-full animate-pulse" style={{ width: `${75 + i * 5}%`, animationDelay: `${i * 60}ms` }} />
|
||||||
|
))}
|
||||||
|
<div className="pt-4 border-t border-gray-100 flex justify-between">
|
||||||
|
<div className="h-3 w-28 bg-gray-100 rounded-full animate-pulse" />
|
||||||
|
<div className="h-6 w-20 bg-gray-100 rounded-full animate-pulse" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{/* Direita */}
|
||||||
|
<div className="bg-white rounded-2xl border border-gray-100 shadow-sm p-6 space-y-5">
|
||||||
|
<div className="h-4 w-24 bg-gray-100 rounded-full animate-pulse" />
|
||||||
|
<div className="h-11 bg-gray-100 rounded-xl animate-pulse" />
|
||||||
|
<div className="grid grid-cols-2 gap-2">
|
||||||
|
<div className="h-16 bg-gray-100 rounded-xl animate-pulse" />
|
||||||
|
<div className="h-16 bg-gray-100 rounded-xl animate-pulse" style={{ animationDelay: '80ms' }} />
|
||||||
|
</div>
|
||||||
|
<div className="h-12 bg-gray-100 rounded-xl animate-pulse" />
|
||||||
|
<div className="pt-4 border-t border-gray-100 grid grid-cols-2 gap-2">
|
||||||
|
{[...Array(4)].map((_, i) => (
|
||||||
|
<div key={i} className="h-8 bg-gray-100 rounded-lg animate-pulse" style={{ animationDelay: `${i * 50}ms` }} />
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</motion.div>
|
||||||
|
)}
|
||||||
|
</AnimatePresence>
|
||||||
|
|
||||||
|
{/* ── Conteúdo real ────────────────────────────────────────────────── */}
|
||||||
|
<AnimatePresence>
|
||||||
|
{ready && (
|
||||||
|
<motion.div
|
||||||
|
key="content"
|
||||||
|
initial={{ opacity: 0 }}
|
||||||
|
animate={{ opacity: 1 }}
|
||||||
|
transition={{ duration: 0.5 }}
|
||||||
|
>
|
||||||
|
<div className="grid lg:grid-cols-[1fr_380px] gap-6 items-start">
|
||||||
|
|
||||||
|
{/* ── Coluna esquerda: seletor de plano + resumo ───────────── */}
|
||||||
|
<div className="space-y-5">
|
||||||
|
|
||||||
|
{/* Seletor de plano — oculto no modo locked (funil/campanha) */}
|
||||||
|
{!locked && (
|
||||||
|
<motion.div
|
||||||
|
initial={{ opacity: 0, y: 10 }}
|
||||||
|
animate={{ opacity: 1, y: 0 }}
|
||||||
|
className="bg-white rounded-2xl border border-gray-100 shadow-sm p-6"
|
||||||
|
>
|
||||||
|
<p className="text-xs font-bold uppercase tracking-widest text-pink-500 mb-4">
|
||||||
|
Escolha o plano
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<div className="grid grid-cols-2 gap-3">
|
||||||
|
{PLANS.map((p) => (
|
||||||
|
<button
|
||||||
|
key={p.id}
|
||||||
|
type="button"
|
||||||
|
onClick={() => setPlan(p as Plan)}
|
||||||
|
className={cn(
|
||||||
|
'relative text-left rounded-xl border-2 p-4 transition-all duration-200',
|
||||||
|
plan.id === p.id
|
||||||
|
? 'border-pink-500 bg-pink-50/60 shadow-sm shadow-pink-500/10'
|
||||||
|
: 'border-gray-100 bg-white hover:border-pink-200 hover:bg-pink-50/30',
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{'badge' in p && p.badge && (
|
||||||
|
<span className="absolute -top-2.5 left-3 bg-gradient-to-r from-pink-500 to-violet-500 text-white text-[9px] font-black uppercase tracking-wider px-2 py-0.5 rounded-full">
|
||||||
|
{p.badge}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
<p className={cn(
|
||||||
|
'text-sm font-bold mb-1',
|
||||||
|
plan.id === p.id ? 'text-pink-700' : 'text-gray-700',
|
||||||
|
)}>
|
||||||
|
{p.name}
|
||||||
|
</p>
|
||||||
|
<p className={cn(
|
||||||
|
'font-display font-black text-2xl leading-none mb-1',
|
||||||
|
plan.id === p.id ? 'text-pink-600' : 'text-gray-900',
|
||||||
|
)}>
|
||||||
|
{p.display}
|
||||||
|
</p>
|
||||||
|
<p className="text-[11px] font-semibold text-gray-400">
|
||||||
|
{p.credits} imagens · {p.perImg}
|
||||||
|
</p>
|
||||||
|
{plan.id === p.id && (
|
||||||
|
<span className="absolute top-3 right-3 w-4 h-4 rounded-full bg-pink-500 flex items-center justify-center">
|
||||||
|
<Check className="w-2.5 h-2.5 text-white" strokeWidth={3} />
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</motion.div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Resumo do pedido */}
|
||||||
|
<motion.div
|
||||||
|
initial={{ opacity: 0, y: 10 }}
|
||||||
|
animate={{ opacity: 1, y: 0 }}
|
||||||
|
transition={{ delay: 0.05 }}
|
||||||
|
className="bg-white rounded-2xl border border-gray-100 shadow-sm p-6"
|
||||||
|
>
|
||||||
|
<div className="flex items-center gap-2 mb-5">
|
||||||
|
<div className="w-8 h-8 rounded-lg bg-gradient-to-br from-pink-500 to-violet-500 flex items-center justify-center">
|
||||||
|
<Sparkles className="w-4 h-4 text-white" />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<p className="text-xs font-bold uppercase tracking-widest text-gray-400">Resumo</p>
|
||||||
|
<p className="font-display font-bold text-gray-900 leading-tight">{plan.name}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<ul className="space-y-3 mb-6">
|
||||||
|
{features.map((f, i) => (
|
||||||
|
<li key={i} className="flex items-start gap-3">
|
||||||
|
<span className="w-5 h-5 rounded-full bg-pink-100 flex items-center justify-center shrink-0 mt-0.5">
|
||||||
|
<Check className="w-3 h-3 text-pink-600" strokeWidth={3} />
|
||||||
|
</span>
|
||||||
|
<span className="text-sm font-medium text-gray-700 leading-snug">{f}</span>
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
|
||||||
|
{/* Primeira imagem grátis — só pro starter */}
|
||||||
|
{plan.id === 'starter' && (
|
||||||
|
<div className="rounded-xl bg-emerald-50 border border-emerald-100 p-3.5 flex items-start gap-2.5 mb-5">
|
||||||
|
<Gift className="w-4 h-4 text-emerald-600 shrink-0 mt-0.5" />
|
||||||
|
<p className="text-xs font-semibold text-emerald-800 leading-snug">
|
||||||
|
Sua <strong>primeira imagem por nossa conta</strong> — não consome nenhum crédito.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Valor final */}
|
||||||
|
<div className="pt-5 border-t border-gray-100 flex items-baseline justify-between">
|
||||||
|
<span className="text-sm font-semibold text-gray-500">Você paga hoje</span>
|
||||||
|
<span className="font-display font-black text-3xl text-gray-900">{plan.display}</span>
|
||||||
|
</div>
|
||||||
|
<p className="text-xs text-gray-400 text-right mt-1">Pagamento único · sem assinatura</p>
|
||||||
|
|
||||||
|
{/* Prova social */}
|
||||||
|
<div className="flex items-center gap-2 mt-5 pt-5 border-t border-gray-100">
|
||||||
|
<div className="flex -space-x-1">
|
||||||
|
{[...Array(5)].map((_, i) => (
|
||||||
|
<Star key={i} className="w-3.5 h-3.5 text-amber-400 fill-amber-400" />
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
<span className="text-xs text-gray-500 font-semibold">+10.000 mães já fizeram a festa</span>
|
||||||
|
</div>
|
||||||
|
</motion.div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* ── Coluna direita: pagamento ─────────────────────────────── */}
|
||||||
|
<motion.div
|
||||||
|
initial={{ opacity: 0, y: 10 }}
|
||||||
|
animate={{ opacity: 1, y: 0 }}
|
||||||
|
transition={{ delay: 0.1 }}
|
||||||
|
className="bg-white rounded-2xl border border-gray-100 shadow-sm p-6 lg:sticky lg:top-6"
|
||||||
|
>
|
||||||
|
<h2 className="font-display font-bold text-lg text-gray-900 mb-5">Pagamento</h2>
|
||||||
|
|
||||||
|
{/* E-mail */}
|
||||||
|
<div className="mb-5">
|
||||||
|
<label className="block text-xs font-bold text-gray-500 uppercase tracking-wider mb-1.5">
|
||||||
|
E-mail
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
type="email"
|
||||||
|
value={email}
|
||||||
|
onChange={(e) => { setEmail(e.target.value); if (error) setError(''); }}
|
||||||
|
placeholder="seu@email.com"
|
||||||
|
disabled={!!pix}
|
||||||
|
className="w-full h-11 px-4 rounded-xl border-2 border-gray-100 focus:border-pink-400 outline-none text-sm font-medium text-gray-900 transition-colors placeholder:text-gray-300 disabled:bg-gray-50 disabled:text-gray-400"
|
||||||
|
/>
|
||||||
|
<p className="text-[11px] text-gray-400 mt-1.5">
|
||||||
|
É aqui que enviamos o acesso ao painel.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Método */}
|
||||||
|
<div className="grid grid-cols-2 gap-2 mb-5">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => { setMethod('pix'); setError(''); }}
|
||||||
|
className={cn(
|
||||||
|
'flex flex-col items-center gap-1 py-3 rounded-xl border-2 text-sm font-bold transition-all',
|
||||||
|
method === 'pix'
|
||||||
|
? 'border-pink-500 bg-pink-50/60 text-pink-700'
|
||||||
|
: 'border-gray-100 text-gray-400 hover:border-gray-200',
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<QrCode className="w-4 h-4" />
|
||||||
|
Pix
|
||||||
|
<span className={cn(
|
||||||
|
'text-[9px] font-black uppercase tracking-wide px-2 py-0.5 rounded-full',
|
||||||
|
method === 'pix' ? 'bg-emerald-100 text-emerald-700' : 'bg-gray-100 text-gray-400',
|
||||||
|
)}>
|
||||||
|
Aprovação imediata
|
||||||
|
</span>
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => { setMethod('card'); setError(''); }}
|
||||||
|
className={cn(
|
||||||
|
'flex flex-col items-center gap-1 py-3 rounded-xl border-2 text-sm font-bold transition-all',
|
||||||
|
method === 'card'
|
||||||
|
? 'border-pink-500 bg-pink-50/60 text-pink-700'
|
||||||
|
: 'border-gray-100 text-gray-400 hover:border-gray-200',
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<CreditCard className="w-4 h-4" />
|
||||||
|
Cartão
|
||||||
|
<span className="text-[9px] font-black uppercase tracking-wide px-2 py-0.5 rounded-full bg-gray-100 text-gray-400">
|
||||||
|
Crédito
|
||||||
|
</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Erro */}
|
||||||
|
<AnimatePresence>
|
||||||
|
{error && (
|
||||||
|
<motion.div
|
||||||
|
initial={{ opacity: 0, height: 0 }}
|
||||||
|
animate={{ opacity: 1, height: 'auto' }}
|
||||||
|
exit={{ opacity: 0, height: 0 }}
|
||||||
|
className="mb-4 text-xs text-red-600 bg-red-50 border border-red-100 rounded-xl px-3.5 py-2.5 font-semibold"
|
||||||
|
>
|
||||||
|
{error}
|
||||||
|
</motion.div>
|
||||||
|
)}
|
||||||
|
</AnimatePresence>
|
||||||
|
|
||||||
|
{/* ── Pix ── */}
|
||||||
|
<AnimatePresence mode="wait">
|
||||||
|
{method === 'pix' && (
|
||||||
|
<motion.div
|
||||||
|
key="pix"
|
||||||
|
initial={{ opacity: 0 }}
|
||||||
|
animate={{ opacity: 1 }}
|
||||||
|
exit={{ opacity: 0 }}
|
||||||
|
>
|
||||||
|
{!pix ? (
|
||||||
|
<form onSubmit={handlePix}>
|
||||||
|
<button
|
||||||
|
type="submit"
|
||||||
|
disabled={pixLoading}
|
||||||
|
className="w-full h-13 py-3.5 rounded-xl bg-gradient-to-r from-pink-500 to-violet-500 text-white font-bold text-base flex items-center justify-center gap-2 shadow-md shadow-pink-500/20 hover:shadow-lg hover:shadow-pink-500/30 hover:scale-[1.01] transition-all disabled:opacity-60 disabled:hover:scale-100 disabled:hover:shadow-md"
|
||||||
|
>
|
||||||
|
{pixLoading ? (
|
||||||
|
<><Loader2 className="w-4 h-4 animate-spin" /> Gerando Pix...</>
|
||||||
|
) : (
|
||||||
|
<><QrCode className="w-4 h-4" /> Gerar Pix · {plan.display}</>
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
<p className="text-[11px] text-gray-400 mt-2.5 text-center flex items-center justify-center gap-1">
|
||||||
|
<Zap className="w-3 h-3 text-emerald-500" />
|
||||||
|
QR Code gerado na hora · pague pelo app do banco
|
||||||
|
</p>
|
||||||
|
</form>
|
||||||
|
) : (
|
||||||
|
<div className="text-center">
|
||||||
|
<div className="inline-block p-3 bg-white rounded-2xl border-2 border-gray-100 shadow-sm mb-4">
|
||||||
|
<img src={pix.qrCodeImage} alt="QR Code Pix" className="w-44 h-44 mx-auto" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<p className="text-xs font-bold text-gray-600 mb-2">
|
||||||
|
Escaneie o QR Code ou use o Pix Copia e Cola:
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<div className="flex items-center gap-2 mb-4">
|
||||||
|
<input
|
||||||
|
readOnly
|
||||||
|
value={pix.brCode}
|
||||||
|
className="flex-1 text-[10px] bg-gray-50 border border-gray-100 rounded-xl px-3 py-2.5 text-gray-400 truncate"
|
||||||
|
/>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={handleCopy}
|
||||||
|
className="shrink-0 h-9 px-3 rounded-xl bg-gray-900 text-white text-xs font-bold flex items-center gap-1.5 hover:bg-gray-700 transition-colors"
|
||||||
|
>
|
||||||
|
{copied
|
||||||
|
? <><Check className="w-3.5 h-3.5" /> Copiado</>
|
||||||
|
: <><Copy className="w-3.5 h-3.5" /> Copiar</>}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{confirming && (
|
||||||
|
<div className="flex items-center justify-center gap-2 text-pink-600 font-semibold text-xs mb-2">
|
||||||
|
<Loader2 className="w-3.5 h-3.5 animate-spin" />
|
||||||
|
Aguardando confirmação do pagamento...
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<p className="text-[11px] text-gray-400">
|
||||||
|
Assim que o Pix cair, liberamos seu acesso. Pode deixar esta tela aberta.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</motion.div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* ── Cartão ── */}
|
||||||
|
{method === 'card' && (
|
||||||
|
<motion.div
|
||||||
|
key="card"
|
||||||
|
initial={{ opacity: 0 }}
|
||||||
|
animate={{ opacity: 1 }}
|
||||||
|
exit={{ opacity: 0 }}
|
||||||
|
>
|
||||||
|
<form onSubmit={handleCard}>
|
||||||
|
<button
|
||||||
|
type="submit"
|
||||||
|
disabled={cardLoading}
|
||||||
|
className="w-full py-3.5 rounded-xl bg-gradient-to-r from-pink-500 to-violet-500 text-white font-bold text-base flex items-center justify-center gap-2 shadow-md shadow-pink-500/20 hover:shadow-lg hover:shadow-pink-500/30 hover:scale-[1.01] transition-all disabled:opacity-60 disabled:hover:scale-100 disabled:hover:shadow-md"
|
||||||
|
>
|
||||||
|
{cardLoading ? (
|
||||||
|
<><Loader2 className="w-4 h-4 animate-spin" /> Abrindo checkout...</>
|
||||||
|
) : (
|
||||||
|
<>Pagar {plan.display} com cartão <ArrowRight className="w-4 h-4" /></>
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
<p className="text-[11px] text-gray-400 mt-2.5 text-center flex items-center justify-center gap-1">
|
||||||
|
<Lock className="w-3 h-3" />
|
||||||
|
Você finaliza no ambiente seguro do Stripe
|
||||||
|
</p>
|
||||||
|
</form>
|
||||||
|
</motion.div>
|
||||||
|
)}
|
||||||
|
</AnimatePresence>
|
||||||
|
|
||||||
|
{/* ── Selos de confiança ── */}
|
||||||
|
<div className="mt-6 pt-5 border-t border-gray-100">
|
||||||
|
<div className="flex items-center justify-center gap-1.5 text-xs font-bold text-emerald-700 mb-4">
|
||||||
|
<ShieldCheck className="w-4 h-4" /> Pagamento 100% seguro e criptografado
|
||||||
|
</div>
|
||||||
|
<div className="grid grid-cols-2 gap-2">
|
||||||
|
{[
|
||||||
|
{ icon: QrCode, label: 'Pix · Banco Central' },
|
||||||
|
{ icon: CreditCard, label: 'Cartão via Stripe' },
|
||||||
|
{ icon: Lock, label: 'SSL 256-bit' },
|
||||||
|
{ icon: ShieldCheck, label: 'Garantia de 7 dias' },
|
||||||
|
].map(({ icon: Icon, label }) => (
|
||||||
|
<div key={label} className="flex items-center gap-2 bg-gray-50 rounded-lg px-3 py-2">
|
||||||
|
<Icon className="w-3.5 h-3.5 text-pink-400 shrink-0" />
|
||||||
|
<span className="text-[11px] font-semibold text-gray-500">{label}</span>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</motion.div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Rodapé */}
|
||||||
|
<p className="mt-8 text-center text-xs text-gray-400 font-medium">
|
||||||
|
Ao prosseguir você concorda com os Termos de Uso e Política de Privacidade.
|
||||||
|
Garantia de reembolso integral em até 7 dias, sem perguntas.
|
||||||
|
</p>
|
||||||
|
</motion.div>
|
||||||
|
)}
|
||||||
|
</AnimatePresence>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -112,13 +112,6 @@ function loadSavedFunnelState() {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
function getCookie(name: string) {
|
|
||||||
const match = document.cookie.match(
|
|
||||||
new RegExp('(^| )' + name + '=([^;]+)')
|
|
||||||
);
|
|
||||||
return match ? match[2] : '';
|
|
||||||
}
|
|
||||||
|
|
||||||
function playMessageSound() {
|
function playMessageSound() {
|
||||||
try {
|
try {
|
||||||
const ctx = getAudioContext();
|
const ctx = getAudioContext();
|
||||||
|
|
@ -301,68 +294,21 @@ export default function WhatsAppFunnel() {
|
||||||
id: 'loading-checkout',
|
id: 'loading-checkout',
|
||||||
sender: 'bot',
|
sender: 'bot',
|
||||||
type: 'text',
|
type: 'text',
|
||||||
content: 'Processando seu e-mail e gerando o link de pagamento ⏳...',
|
content: 'Perfeito! Abrindo o checkout seguro pra você 🎉',
|
||||||
}
|
}
|
||||||
]);
|
]);
|
||||||
|
|
||||||
setCurrentRequireEmail(false);
|
setCurrentRequireEmail(false);
|
||||||
setIsLoadingCheckout(true);
|
setIsLoadingCheckout(true);
|
||||||
scrollToBottom();
|
scrollToBottom();
|
||||||
|
|
||||||
// Fire Meta Pixel event
|
|
||||||
if (typeof window !== 'undefined' && 'fbq' in window) {
|
if (typeof window !== 'undefined' && 'fbq' in window) {
|
||||||
(window as any).fbq('track', 'InitiateCheckout');
|
(window as any).fbq('track', 'InitiateCheckout');
|
||||||
}
|
}
|
||||||
|
|
||||||
const fbp = getCookie('_fbp');
|
setTimeout(() => {
|
||||||
const fbc = getCookie('_fbc');
|
window.location.href = `/checkout?plan=starter&source=funil&email=${encodeURIComponent(emailInput)}`;
|
||||||
|
}, 800);
|
||||||
try {
|
|
||||||
const res = await fetch('https://n8n.seureview.com.br/webhook/festa-magica-stripe', {
|
|
||||||
method: 'POST',
|
|
||||||
headers: {
|
|
||||||
'Content-Type': 'application/json',
|
|
||||||
},
|
|
||||||
body: JSON.stringify({
|
|
||||||
source: 'funil',
|
|
||||||
userEmail: emailInput,
|
|
||||||
fbp: fbp || '',
|
|
||||||
fbc: fbc || ''
|
|
||||||
}),
|
|
||||||
});
|
|
||||||
|
|
||||||
const data = await res.json();
|
|
||||||
if (data.success && data.url) {
|
|
||||||
// Redireciona para o checkout do stripe
|
|
||||||
window.location.href = data.url;
|
|
||||||
} else {
|
|
||||||
setIsLoadingCheckout(false);
|
|
||||||
setCurrentRequireEmail(true);
|
|
||||||
setMessages((prev) => [
|
|
||||||
...prev.filter(m => m.id !== 'loading-checkout'),
|
|
||||||
{
|
|
||||||
id: Math.random().toString(),
|
|
||||||
sender: 'bot',
|
|
||||||
type: 'text',
|
|
||||||
content: 'Ops, tive um probleminha aqui pra gerar o seu link 😅 Pode tentar de novo?',
|
|
||||||
},
|
|
||||||
]);
|
|
||||||
scrollToBottom();
|
|
||||||
}
|
|
||||||
} catch (err) {
|
|
||||||
setIsLoadingCheckout(false);
|
|
||||||
setCurrentRequireEmail(true);
|
|
||||||
setMessages((prev) => [
|
|
||||||
...prev.filter(m => m.id !== 'loading-checkout'),
|
|
||||||
{
|
|
||||||
id: Math.random().toString(),
|
|
||||||
sender: 'bot',
|
|
||||||
type: 'text',
|
|
||||||
content: 'Hmm, parece que a conexão falhou. Confere sua internet e manda de novo, por favor 🙏',
|
|
||||||
},
|
|
||||||
]);
|
|
||||||
scrollToBottom();
|
|
||||||
}
|
|
||||||
};
|
};
|
||||||
|
|
||||||
const renderMessageContent = (m: RenderedMessage) => {
|
const renderMessageContent = (m: RenderedMessage) => {
|
||||||
|
|
|
||||||
98
src/services/wooviService.ts
Normal file
98
src/services/wooviService.ts
Normal file
|
|
@ -0,0 +1,98 @@
|
||||||
|
// ============================================================
|
||||||
|
// Festa Mágica IA — Woovi (Pix) Service
|
||||||
|
// ============================================================
|
||||||
|
// Fluxo Pix transparente:
|
||||||
|
// Front → n8n (cria cobrança Woovi) → QR/copia-e-cola na tela
|
||||||
|
// Front faz polling do status via n8n até COMPLETED
|
||||||
|
// Webhook Woovi → n8n credita / cria usuário / magic link / e-mail
|
||||||
|
//
|
||||||
|
// IMPORTANTE: o App ID da Woovi NUNCA passa pelo browser.
|
||||||
|
// Tanto a criação da cobrança quanto a consulta de status são
|
||||||
|
// feitas server-side pelo n8n. O front só fala com o n8n.
|
||||||
|
// ============================================================
|
||||||
|
|
||||||
|
const N8N_BASE = 'https://n8n.seureview.com.br/webhook';
|
||||||
|
|
||||||
|
const PIX_CREATE_URL = `${N8N_BASE}/festa-magica-pix`;
|
||||||
|
const PIX_STATUS_URL = `${N8N_BASE}/festa-magica-pix-status`;
|
||||||
|
|
||||||
|
export interface CreatePixParams {
|
||||||
|
userEmail: string;
|
||||||
|
source?: string; // 'landing' | 'funil' | 'default'
|
||||||
|
userId?: string;
|
||||||
|
value?: number; // em centavos BRL
|
||||||
|
credits?: number;
|
||||||
|
planName?: string;
|
||||||
|
priceId?: string;
|
||||||
|
fbp?: string;
|
||||||
|
fbc?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface PixCharge {
|
||||||
|
correlationID: string;
|
||||||
|
brCode: string; // código copia-e-cola
|
||||||
|
qrCodeImage: string; // URL da imagem do QR (PNG)
|
||||||
|
value: number; // em centavos
|
||||||
|
expiresDate?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export type PixStatus = 'ACTIVE' | 'COMPLETED' | 'EXPIRED';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Cria uma cobrança Pix na Woovi (via n8n) e retorna os dados
|
||||||
|
* para exibir o QR Code e o copia-e-cola na própria tela.
|
||||||
|
*/
|
||||||
|
export async function createPixCharge(params: CreatePixParams): Promise<PixCharge> {
|
||||||
|
const response = await fetch(PIX_CREATE_URL, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({
|
||||||
|
source: params.source || 'landing',
|
||||||
|
userEmail: params.userEmail,
|
||||||
|
userId: params.userId || '',
|
||||||
|
value: params.value,
|
||||||
|
credits: params.credits,
|
||||||
|
planName: params.planName,
|
||||||
|
priceId: params.priceId,
|
||||||
|
fbp: params.fbp || '',
|
||||||
|
fbc: params.fbc || '',
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error(`Erro ao gerar Pix: ${response.status}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const data = await response.json();
|
||||||
|
|
||||||
|
if (!data.success || !data.brCode) {
|
||||||
|
throw new Error(data.error || 'Não foi possível gerar o Pix.');
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
correlationID: data.correlationID,
|
||||||
|
brCode: data.brCode,
|
||||||
|
qrCodeImage: data.qrCodeImage,
|
||||||
|
value: data.value,
|
||||||
|
expiresDate: data.expiresDate,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Consulta o status de uma cobrança Pix (via n8n).
|
||||||
|
* Usado no polling enquanto o usuário paga.
|
||||||
|
*/
|
||||||
|
export async function pollPixStatus(correlationID: string): Promise<PixStatus> {
|
||||||
|
const response = await fetch(PIX_STATUS_URL, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ correlationID }),
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error(`Erro ao consultar status do Pix: ${response.status}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const data = await response.json();
|
||||||
|
return (data.status as PixStatus) || 'ACTIVE';
|
||||||
|
}
|
||||||
Loading…
Reference in a new issue