feat: integracao asaas gateway e painel admin

This commit is contained in:
Sidney 2026-06-03 22:00:00 -03:00
commit 50bf3fe210
51 changed files with 9353 additions and 0 deletions

6
.dockerignore Normal file
View File

@ -0,0 +1,6 @@
node_modules
.next
.git
*.md
.env*
docker-compose*.yml

41
.gitignore vendored Normal file
View File

@ -0,0 +1,41 @@
# Dependencies
node_modules/
/.pnp
.pnp.js
# Testing
/coverage
# Next.js
.next/
/out/
# Production build
/build
# Debug logs
npm-debug.log*
yarn-debug.log*
yarn-error.log*
.pnpm-debug.log*
# Local env files
.env*.local
.env
.env.development
.env.production
# Vercel
.vercel
# IDE/OS files
.DS_Store
*.pem
Thumbs.db
.idea/
.vscode/
*.suo
*.ntvs*
*.njsproj
*.sln
*.sw?

30
Dockerfile Normal file
View File

@ -0,0 +1,30 @@
FROM node:20-alpine AS base
# --- Dependencies ---
FROM base AS deps
WORKDIR /app
COPY package.json package-lock.json* ./
RUN npm ci --only=production
# --- Builder ---
FROM base AS builder
WORKDIR /app
COPY package.json package-lock.json* ./
RUN npm ci
COPY . .
RUN npm run build
# --- Runner ---
FROM base AS runner
WORKDIR /app
ENV NODE_ENV=production
RUN addgroup --system --gid 1001 nodejs
RUN adduser --system --uid 1001 nextjs
COPY --from=builder /app/public ./public
COPY --from=builder --chown=nextjs:nodejs /app/.next/standalone ./
COPY --from=builder --chown=nextjs:nodejs /app/.next/static ./.next/static
USER nextjs
EXPOSE 3000
ENV PORT=3000
ENV HOSTNAME="0.0.0.0"
CMD ["node", "server.js"]

22
admin/Dockerfile Normal file
View File

@ -0,0 +1,22 @@
FROM node:20-alpine AS deps
WORKDIR /app
COPY package.json package-lock.json* ./
RUN npm ci --omit=dev
FROM node:20-alpine AS builder
WORKDIR /app
COPY --from=deps /app/node_modules ./node_modules
COPY . .
RUN npm run build
FROM node:20-alpine AS runner
WORKDIR /app
ENV NODE_ENV=production
RUN addgroup --system --gid 1001 nodejs && adduser --system --uid 1001 nextjs
COPY --from=builder /app/.next/standalone ./
COPY --from=builder /app/.next/static ./.next/static
COPY --from=builder /app/publi[c] ./public/
USER nextjs
EXPOSE 3001
ENV PORT=3001
CMD ["node", "server.js"]

6
admin/next-env.d.ts vendored Normal file
View File

@ -0,0 +1,6 @@
/// <reference types="next" />
/// <reference types="next/image-types/global" />
/// <reference path="./.next/types/routes.d.ts" />
// NOTE: This file should not be edited
// see https://nextjs.org/docs/app/api-reference/config/typescript for more information.

3
admin/next.config.ts Normal file
View File

@ -0,0 +1,3 @@
import type { NextConfig } from "next";
const nextConfig: NextConfig = { output: "standalone" };
export default nextConfig;

1112
admin/package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

21
admin/package.json Normal file
View File

@ -0,0 +1,21 @@
{
"name": "agendapro-admin",
"version": "1.0.0",
"private": true,
"scripts": {
"dev": "next dev -p 3001",
"build": "next build",
"start": "next start -p 3001"
},
"dependencies": {
"next": "^15.3.3",
"react": "^19.1.0",
"react-dom": "^19.1.0",
"typescript": "^5.8.3",
"@types/node": "^22.15.21",
"@types/react": "^19.1.4",
"@types/react-dom": "^19.1.5",
"lucide-react": "^0.511.0",
"pg": "^8.16.0"
}
}

178
admin/src/app/globals.css Normal file
View File

@ -0,0 +1,178 @@
@import url('https://fonts.googleapis.com/css2?family=Inter:wght@300;400;500;600;700;800;900&display=swap');
:root {
--bg-primary: #080810;
--bg-secondary: #0e0e1a;
--bg-card: #141425;
--bg-card-hover: #1a1a30;
--bg-elevated: #202038;
--border-color: rgba(255,255,255,0.06);
--border-active: rgba(245,158,11,0.5);
--text-primary: #f0f0f5;
--text-secondary: #8888a0;
--text-muted: #55556a;
--accent: #f59e0b;
--accent-hover: #d97706;
--accent-glow: rgba(245,158,11,0.15);
--success: #22c55e;
--success-bg: rgba(34,197,94,0.1);
--warning: #f59e0b;
--warning-bg: rgba(245,158,11,0.1);
--danger: #ef4444;
--danger-bg: rgba(239,68,68,0.1);
--info: #6C63FF;
--info-bg: rgba(108,99,255,0.1);
--gradient-primary: linear-gradient(135deg, #f59e0b 0%, #ef4444 100%);
--shadow-glow: 0 0 30px rgba(245,158,11,0.12);
--radius-sm: 8px;
--radius-md: 12px;
--radius-lg: 16px;
--radius-xl: 24px;
}
* { margin: 0; padding: 0; box-sizing: border-box; }
html, body { height: 100%; }
body {
font-family: 'Inter', -apple-system, sans-serif;
background: var(--bg-primary);
color: var(--text-primary);
line-height: 1.6;
-webkit-font-smoothing: antialiased;
}
::-webkit-scrollbar { width: 6px; }
::-webkit-scrollbar-track { background: transparent; }
::-webkit-scrollbar-thumb { background: var(--bg-elevated); border-radius: 3px; }
.app-layout { display: flex; min-height: 100vh; }
.sidebar {
width: 260px; background: var(--bg-secondary); border-right: 1px solid var(--border-color);
display: flex; flex-direction: column; position: fixed; top: 0; left: 0; bottom: 0; z-index: 100;
}
.sidebar-header { padding: 24px 20px; border-bottom: 1px solid var(--border-color); }
.sidebar-logo { display: flex; align-items: center; gap: 12px; }
.logo-icon {
width: 40px; height: 40px; background: var(--gradient-primary); border-radius: var(--radius-md);
display: flex; align-items: center; justify-content: center; font-size: 18px; color: white;
}
.logo-text { font-size: 18px; font-weight: 800; background: var(--gradient-primary); -webkit-background-clip: text; -webkit-text-fill-color: transparent; }
.logo-sub { font-size: 11px; color: var(--text-muted); font-weight: 500; }
.sidebar-nav { flex: 1; padding: 16px 12px; overflow-y: auto; }
.nav-section-title { font-size: 11px; font-weight: 600; color: var(--text-muted); text-transform: uppercase; letter-spacing: 1.2px; padding: 16px 12px 8px; }
.nav-item {
display: flex; align-items: center; gap: 12px; padding: 10px 12px; border-radius: var(--radius-sm);
color: var(--text-secondary); font-size: 14px; font-weight: 500; cursor: pointer; border: none;
background: none; width: 100%; text-align: left; transition: all 0.2s; text-decoration: none; font-family: inherit;
}
.nav-item:hover { background: var(--bg-card); color: var(--text-primary); }
.nav-item.active { background: var(--accent-glow); color: var(--accent); box-shadow: inset 3px 0 0 var(--accent); }
.nav-item svg { width: 20px; height: 20px; flex-shrink: 0; }
.main-content { flex: 1; margin-left: 260px; min-height: 100vh; }
.topbar {
display: flex; align-items: center; justify-content: space-between; padding: 16px 32px;
border-bottom: 1px solid var(--border-color); background: rgba(8,8,16,0.8); backdrop-filter: blur(20px);
position: sticky; top: 0; z-index: 50;
}
.topbar h1 { font-size: 22px; font-weight: 700; letter-spacing: -0.5px; }
.topbar p { font-size: 13px; color: var(--text-secondary); margin-top: 2px; }
.page-content { padding: 32px; }
.card {
background: var(--bg-card); border: 1px solid var(--border-color); border-radius: var(--radius-lg);
padding: 24px; transition: all 0.3s;
}
.card:hover { border-color: var(--border-active); box-shadow: var(--shadow-glow); }
.card-header { display: flex; align-items: center; justify-content: space-between; margin-bottom: 20px; }
.card-title { font-size: 16px; font-weight: 600; }
.stats-grid { display: grid; grid-template-columns: repeat(auto-fit, minmax(220px, 1fr)); gap: 20px; margin-bottom: 32px; }
.stat-card {
background: var(--bg-card); border: 1px solid var(--border-color); border-radius: var(--radius-lg);
padding: 24px; display: flex; align-items: flex-start; gap: 16px; transition: all 0.3s;
}
.stat-card:hover { transform: translateY(-2px); box-shadow: var(--shadow-glow); border-color: var(--border-active); }
.stat-icon { width: 48px; height: 48px; border-radius: var(--radius-md); display: flex; align-items: center; justify-content: center; flex-shrink: 0; }
.stat-icon.amber { background: var(--accent-glow); color: var(--accent); }
.stat-icon.green { background: var(--success-bg); color: var(--success); }
.stat-icon.red { background: var(--danger-bg); color: var(--danger); }
.stat-icon.purple { background: var(--info-bg); color: var(--info); }
.stat-value { font-size: 28px; font-weight: 800; letter-spacing: -1px; line-height: 1.2; }
.stat-label { font-size: 13px; color: var(--text-secondary); margin-top: 2px; }
.btn {
display: inline-flex; align-items: center; gap: 8px; padding: 10px 20px; border-radius: var(--radius-sm);
font-size: 14px; font-weight: 600; cursor: pointer; border: none; transition: all 0.2s; font-family: inherit;
}
.btn-primary { background: var(--gradient-primary); color: white; box-shadow: var(--shadow-glow); }
.btn-primary:hover { transform: translateY(-1px); }
.btn-secondary { background: var(--bg-elevated); color: var(--text-primary); border: 1px solid var(--border-color); }
.btn-danger { background: var(--danger-bg); color: var(--danger); border: 1px solid rgba(239,68,68,0.2); }
.btn-ghost { background: transparent; color: var(--text-secondary); padding: 8px 12px; border: none; cursor: pointer; font-family: inherit; }
.btn-ghost:hover { color: var(--text-primary); background: var(--bg-card); }
.btn-sm { padding: 6px 14px; font-size: 13px; }
.btn-icon { padding: 8px; border-radius: var(--radius-sm); }
.form-group { margin-bottom: 20px; }
.form-label { display: block; font-size: 13px; font-weight: 600; color: var(--text-secondary); margin-bottom: 6px; }
.form-input, .form-select, .form-textarea {
width: 100%; padding: 10px 14px; background: var(--bg-primary); border: 1px solid var(--border-color);
border-radius: var(--radius-sm); color: var(--text-primary); font-size: 14px; font-family: inherit; outline: none; transition: all 0.2s;
}
.form-input:focus, .form-select:focus { border-color: var(--accent); box-shadow: 0 0 0 3px var(--accent-glow); }
.form-textarea { resize: vertical; min-height: 80px; }
.table-container { overflow-x: auto; border-radius: var(--radius-lg); border: 1px solid var(--border-color); }
table { width: 100%; border-collapse: collapse; }
th { text-align: left; padding: 12px 16px; font-size: 12px; font-weight: 600; color: var(--text-muted); text-transform: uppercase; letter-spacing: 0.8px; background: var(--bg-secondary); border-bottom: 1px solid var(--border-color); }
td { padding: 14px 16px; font-size: 14px; border-bottom: 1px solid var(--border-color); vertical-align: middle; }
tr:last-child td { border-bottom: none; }
tr:hover td { background: var(--bg-card-hover); }
.badge { display: inline-flex; align-items: center; gap: 4px; padding: 4px 10px; border-radius: 20px; font-size: 12px; font-weight: 600; }
.badge-success { background: var(--success-bg); color: var(--success); }
.badge-warning { background: var(--warning-bg); color: var(--warning); }
.badge-danger { background: var(--danger-bg); color: var(--danger); }
.badge-info { background: var(--info-bg); color: var(--info); }
.badge-amber { background: var(--accent-glow); color: var(--accent); }
.modal-overlay { position: fixed; inset: 0; background: rgba(0,0,0,0.7); backdrop-filter: blur(4px); display: flex; align-items: center; justify-content: center; z-index: 1000; animation: fadeIn 0.2s ease; }
.modal { background: var(--bg-secondary); border: 1px solid var(--border-color); border-radius: var(--radius-xl); width: 90%; max-width: 560px; max-height: 85vh; overflow-y: auto; animation: slideUp 0.3s ease; }
.modal-header { display: flex; align-items: center; justify-content: space-between; padding: 24px; border-bottom: 1px solid var(--border-color); }
.modal-title { font-size: 18px; font-weight: 700; }
.modal-body { padding: 24px; }
.modal-footer { display: flex; justify-content: flex-end; gap: 12px; padding: 16px 24px; border-top: 1px solid var(--border-color); }
.avatar { width: 36px; height: 36px; border-radius: 50%; background: var(--gradient-primary); display: flex; align-items: center; justify-content: center; font-weight: 700; font-size: 14px; color: white; flex-shrink: 0; }
.flex-between { display: flex; align-items: center; justify-content: space-between; }
.flex-gap { display: flex; align-items: center; gap: 12px; }
.grid-2 { display: grid; grid-template-columns: 1fr 1fr; gap: 16px; }
.grid-3 { display: grid; grid-template-columns: repeat(3, 1fr); gap: 16px; }
.mb-4 { margin-bottom: 16px; }
.mb-6 { margin-bottom: 24px; }
.mt-4 { margin-top: 16px; }
.mt-6 { margin-top: 24px; }
.tabs { display: flex; gap: 4px; border-bottom: 1px solid var(--border-color); margin-bottom: 24px; }
.tab { padding: 10px 20px; font-size: 14px; font-weight: 500; color: var(--text-secondary); cursor: pointer; border: none; background: none; position: relative; transition: all 0.2s; font-family: inherit; }
.tab:hover { color: var(--text-primary); }
.tab.active { color: var(--accent); }
.tab.active::after { content: ''; position: absolute; bottom: -1px; left: 0; right: 0; height: 2px; background: var(--accent); border-radius: 2px 2px 0 0; }
.toggle { width: 44px; height: 24px; border-radius: 12px; background: var(--bg-elevated); border: 1px solid var(--border-color); cursor: pointer; position: relative; transition: all 0.3s; }
.toggle.active { background: var(--accent); border-color: var(--accent); }
.toggle::after { content: ''; width: 18px; height: 18px; border-radius: 50%; background: white; position: absolute; top: 2px; left: 2px; transition: all 0.3s; }
.toggle.active::after { transform: translateX(20px); }
@keyframes fadeIn { from { opacity: 0; } to { opacity: 1; } }
@keyframes slideUp { from { opacity: 0; transform: translateY(20px); } to { opacity: 1; transform: translateY(0); } }
.slide-up { animation: slideUp 0.4s ease; }
.fade-in { animation: fadeIn 0.3s ease; }
.revenue-chart { display: flex; align-items: flex-end; gap: 6px; height: 160px; padding: 16px 0; }
.chart-bar { flex: 1; border-radius: 4px 4px 0 0; background: var(--gradient-primary); transition: all 0.3s; min-width: 20px; cursor: pointer; position: relative; }
.chart-bar:hover { opacity: 0.8; }

15
admin/src/app/layout.tsx Normal file
View File

@ -0,0 +1,15 @@
import type { Metadata } from "next";
import "./globals.css";
export const metadata: Metadata = {
title: "AgendaPRO Admin — Gerenciador de Assinaturas",
description: "Painel administrativo para gerenciamento de assinaturas, tenants e planos do AgendaPRO SaaS.",
};
export default function RootLayout({ children }: { children: React.ReactNode }) {
return (
<html lang="pt-BR">
<body>{children}</body>
</html>
);
}

101
admin/src/app/page.tsx Normal file
View File

@ -0,0 +1,101 @@
'use client';
import { useState } from 'react';
import { LayoutDashboard, Users, CreditCard, Package, Settings, Shield, TrendingUp, Bell, LogOut } from 'lucide-react';
import AdminDashboard from '@/components/AdminDashboard';
import Tenants from '@/components/Tenants';
import Plans from '@/components/Plans';
import Payments from '@/components/Payments';
import AdminSettings from '@/components/AdminSettings';
const navItems = [
{ section: 'Visão Geral' },
{ id: 'dashboard', label: 'Dashboard', icon: LayoutDashboard },
{ section: 'Gestão' },
{ id: 'tenants', label: 'Assinantes', icon: Users },
{ id: 'plans', label: 'Planos', icon: Package },
{ id: 'payments', label: 'Pagamentos', icon: CreditCard },
{ section: 'Sistema' },
{ id: 'settings', label: 'Configurações', icon: Settings },
];
const pages: Record<string, { title: string; subtitle: string }> = {
dashboard: { title: 'Dashboard', subtitle: 'Visão geral das assinaturas' },
tenants: { title: 'Assinantes', subtitle: 'Gerenciar estabelecimentos' },
plans: { title: 'Planos', subtitle: 'Gerenciar planos de assinatura' },
payments: { title: 'Pagamentos', subtitle: 'Histórico de cobranças' },
settings: { title: 'Configurações', subtitle: 'Configurações do sistema' },
};
export default function AdminPage() {
const [currentPage, setCurrentPage] = useState('dashboard');
const renderPage = () => {
switch (currentPage) {
case 'dashboard': return <AdminDashboard onNavigate={setCurrentPage} />;
case 'tenants': return <Tenants />;
case 'plans': return <Plans />;
case 'payments': return <Payments />;
case 'settings': return <AdminSettings />;
default: return <AdminDashboard onNavigate={setCurrentPage} />;
}
};
return (
<div className="app-layout">
<aside className="sidebar">
<div className="sidebar-header">
<div className="sidebar-logo">
<div className="logo-icon"><Shield size={20} /></div>
<div>
<div className="logo-text">AgendaPRO</div>
<div className="logo-sub">ADMIN PANEL</div>
</div>
</div>
</div>
<nav className="sidebar-nav">
{navItems.map((item, i) => {
if ('section' in item && item.section) return <div key={`s-${i}`} className="nav-section-title">{item.section}</div>;
if ('id' in item && item.id) {
const Icon = item.icon!;
return (
<button key={item.id} className={`nav-item ${currentPage === item.id ? 'active' : ''}`}
onClick={() => setCurrentPage(item.id!)}>
<Icon size={20} /> {item.label}
</button>
);
}
return null;
})}
</nav>
<div style={{ padding: '16px', borderTop: '1px solid var(--border-color)' }}>
<div style={{ display: 'flex', alignItems: 'center', gap: '12px', padding: '10px', borderRadius: 'var(--radius-sm)', background: 'var(--bg-card)' }}>
<div className="avatar" style={{ width: 32, height: 32, fontSize: 12 }}>SA</div>
<div style={{ flex: 1 }}>
<div style={{ fontSize: '13px', fontWeight: 600 }}>Super Admin</div>
<div style={{ fontSize: '11px', color: 'var(--text-muted)' }}>admin@agendapro.com</div>
</div>
</div>
</div>
</aside>
<main className="main-content">
<header className="topbar">
<div>
<h1>{pages[currentPage]?.title}</h1>
<p>{pages[currentPage]?.subtitle}</p>
</div>
<div className="flex-gap">
<button className="btn-ghost btn-icon" style={{ position: 'relative' }}>
<Bell size={20} />
<span style={{ position: 'absolute', top: 4, right: 4, width: 8, height: 8, borderRadius: '50%', background: 'var(--danger)' }} />
</button>
<div className="avatar" style={{ width: 36, height: 36, fontSize: 13 }}>SA</div>
</div>
</header>
<div className="page-content fade-in" key={currentPage}>
{renderPage()}
</div>
</main>
</div>
);
}

View File

@ -0,0 +1,180 @@
import { useState, useEffect } from 'react';
import { DollarSign, Users, TrendingUp, AlertTriangle, ArrowUpRight, CreditCard, Building2 } from 'lucide-react';
interface Props { onNavigate: (page: string) => void; }
const recentTenants = [
{ id: '1', name: 'Barbearia Premium', plan: 'Professional', status: 'active', mrr: 99.90, date: '2026-05-20' },
{ id: '2', name: 'Studio Bella Nails', plan: 'Starter', status: 'active', mrr: 49.90, date: '2026-05-22' },
{ id: '3', name: 'Spa Relaxe', plan: 'Business', status: 'active', mrr: 199.90, date: '2026-05-18' },
{ id: '4', name: 'Barber Shop Elite', plan: 'Professional', status: 'trial', mrr: 0, date: '2026-05-28' },
{ id: '5', name: 'Salão Glamour', plan: 'Starter', status: 'past_due', mrr: 49.90, date: '2026-05-10' },
];
const revenueData = [65, 72, 58, 80, 95, 88, 102, 91, 110, 98, 115, 125];
const months = ['Jun', 'Jul', 'Ago', 'Set', 'Out', 'Nov', 'Dez', 'Jan', 'Fev', 'Mar', 'Abr', 'Mai'];
const statusMap: Record<string, { label: string; cls: string }> = {
active: { label: 'Ativo', cls: 'badge-success' },
trial: { label: 'Trial', cls: 'badge-info' },
past_due: { label: 'Inadimplente', cls: 'badge-danger' },
cancelled: { label: 'Cancelado', cls: 'badge-warning' },
suspended: { label: 'Suspenso', cls: 'badge-danger' },
};
export default function AdminDashboard({ onNavigate }: Props) {
const [tenants, setTenants] = useState(recentTenants);
const [mrrValue, setMrrValue] = useState(12480);
const maxRevenue = Math.max(...revenueData);
useEffect(() => {
const getCookie = (name: string): string => {
if (typeof document === 'undefined') return '';
const value = `; ${document.cookie}`;
const parts = value.split(`; ${name}=`);
if (parts.length === 2) return parts.pop()!.split(';').shift() || '';
return '';
};
const savedPlan = getCookie('agendapro_plan');
if (savedPlan) {
setTenants(prev => prev.map(t => {
if (t.id === '1') {
let mrr = 99.90;
if (savedPlan === 'Starter') mrr = 49.90;
else if (savedPlan === 'Business') mrr = 199.90;
else if (savedPlan === 'Enterprise') mrr = 399.90;
return { ...t, plan: savedPlan, mrr };
}
return t;
}));
let diff = 0;
if (savedPlan === 'Starter') diff = 49.90 - 99.90;
else if (savedPlan === 'Business') diff = 199.90 - 99.90;
else if (savedPlan === 'Enterprise') diff = 399.90 - 99.90;
setMrrValue(12480 + diff);
}
}, []);
return (
<div className="slide-up">
<div className="stats-grid">
<div className="stat-card">
<div className="stat-icon amber"><DollarSign size={24} /></div>
<div>
<div className="stat-value">R$ {mrrValue.toLocaleString('pt-BR', { minimumFractionDigits: 2, maximumFractionDigits: 2 })}</div>
<div className="stat-label">MRR (Receita Mensal)</div>
<div style={{ fontSize: '12px', fontWeight: 600, color: 'var(--success)', marginTop: 4 }}> 18% vs mês anterior</div>
</div>
</div>
<div className="stat-card">
<div className="stat-icon green"><Users size={24} /></div>
<div>
<div className="stat-value">87</div>
<div className="stat-label">Assinantes Ativos</div>
<div style={{ fontSize: '12px', fontWeight: 600, color: 'var(--success)', marginTop: 4 }}> 12 novos este mês</div>
</div>
</div>
<div className="stat-card">
<div className="stat-icon purple"><TrendingUp size={24} /></div>
<div>
<div className="stat-value">94.3%</div>
<div className="stat-label">Taxa de Retenção</div>
<div style={{ fontSize: '12px', fontWeight: 600, color: 'var(--success)', marginTop: 4 }}> 2.1% vs mês anterior</div>
</div>
</div>
<div className="stat-card">
<div className="stat-icon red"><AlertTriangle size={24} /></div>
<div>
<div className="stat-value">5</div>
<div className="stat-label">Inadimplentes</div>
<div style={{ fontSize: '12px', fontWeight: 600, color: 'var(--danger)', marginTop: 4 }}> 2 vs mês anterior</div>
</div>
</div>
</div>
<div style={{ display: 'grid', gridTemplateColumns: '1fr 340px', gap: '24px', marginBottom: '24px' }}>
<div className="card">
<div className="card-header">
<h2 className="card-title">Receita Mensal (MRR)</h2>
<span style={{ fontSize: '13px', color: 'var(--text-muted)' }}>Últimos 12 meses</span>
</div>
<div className="revenue-chart">
{revenueData.map((val, i) => (
<div key={i} style={{ flex: 1, display: 'flex', flexDirection: 'column', alignItems: 'center', gap: '6px' }}>
<div className="chart-bar" style={{ height: `${(val / maxRevenue) * 100}%` }}
title={`${months[i]}: R$ ${(val * 100).toLocaleString('pt-BR')}`} />
<span style={{ fontSize: '10px', color: 'var(--text-muted)' }}>{months[i]}</span>
</div>
))}
</div>
</div>
<div style={{ display: 'flex', flexDirection: 'column', gap: '16px' }}>
<div className="card">
<h3 className="card-title mb-4">Distribuição de Planos</h3>
{[
{ name: 'Starter', count: 35, color: '#3b82f6', pct: 40 },
{ name: 'Professional', count: 32, color: '#6C63FF', pct: 37 },
{ name: 'Business', count: 15, color: '#a855f7', pct: 17 },
{ name: 'Enterprise', count: 5, color: '#f59e0b', pct: 6 },
].map(p => (
<div key={p.name} style={{ marginBottom: '12px' }}>
<div className="flex-between" style={{ marginBottom: '4px' }}>
<span style={{ fontSize: '13px', fontWeight: 600 }}>{p.name}</span>
<span style={{ fontSize: '12px', color: 'var(--text-muted)' }}>{p.count} ({p.pct}%)</span>
</div>
<div style={{ height: '6px', background: 'var(--bg-elevated)', borderRadius: '3px', overflow: 'hidden' }}>
<div style={{ width: `${p.pct}%`, height: '100%', background: p.color, borderRadius: '3px' }} />
</div>
</div>
))}
</div>
<div className="card">
<h3 className="card-title mb-4">Métricas Rápidas</h3>
{[
{ label: 'Ticket Médio', value: 'R$ 143,45' },
{ label: 'Churn Rate', value: '5.7%' },
{ label: 'LTV Médio', value: 'R$ 1.723' },
{ label: 'Em Trial', value: '14' },
].map((m, i) => (
<div key={i} className="flex-between" style={{ padding: '8px 0', borderBottom: i < 3 ? '1px solid var(--border-color)' : 'none' }}>
<span style={{ fontSize: '13px', color: 'var(--text-secondary)' }}>{m.label}</span>
<span style={{ fontSize: '14px', fontWeight: 700 }}>{m.value}</span>
</div>
))}
</div>
</div>
</div>
<div className="card">
<div className="card-header">
<h2 className="card-title" style={{ display: 'flex', alignItems: 'center', gap: '8px' }}>
<Building2 size={18} /> Últimos Assinantes
</h2>
<button className="btn btn-sm btn-secondary" onClick={() => onNavigate('tenants')}>
Ver Todos <ArrowUpRight size={14} />
</button>
</div>
<div className="table-container">
<table>
<thead><tr><th>Estabelecimento</th><th>Plano</th><th>Status</th><th>MRR</th><th>Desde</th></tr></thead>
<tbody>
{tenants.map(t => (
<tr key={t.id}>
<td style={{ fontWeight: 600 }}><div className="flex-gap"><div className="avatar" style={{ width: 30, height: 30, fontSize: 11 }}>{t.name[0]}</div>{t.name}</div></td>
<td><span className="badge badge-amber">{t.plan}</span></td>
<td><span className={`badge ${statusMap[t.status].cls}`}>{statusMap[t.status].label}</span></td>
<td style={{ fontWeight: 600 }}>{t.mrr > 0 ? `R$ ${t.mrr.toFixed(2)}` : '-'}</td>
<td style={{ color: 'var(--text-secondary)', fontSize: '13px' }}>{new Date(t.date + 'T12:00:00').toLocaleDateString('pt-BR')}</td>
</tr>
))}
</tbody>
</table>
</div>
</div>
</div>
);
}

View File

@ -0,0 +1,151 @@
'use client';
import { useState } from 'react';
import { Save, Globe, Database, Shield, Key, Mail, Bell } from 'lucide-react';
export default function AdminSettings() {
const [activeTab, setActiveTab] = useState('general');
const [saved, setSaved] = useState(false);
const handleSave = () => { setSaved(true); setTimeout(() => setSaved(false), 2000); };
const tabs = [
{ id: 'general', label: 'Geral', icon: Globe },
{ id: 'database', label: 'Banco de Dados', icon: Database },
{ id: 'security', label: 'Segurança', icon: Shield },
{ id: 'email', label: 'E-mail / SMTP', icon: Mail },
{ id: 'webhooks', label: 'Webhooks', icon: Bell },
];
const [maintenanceMode, setMaintenanceMode] = useState(false);
const [openRegistration, setOpenRegistration] = useState(true);
const [autoTrial, setAutoTrial] = useState(true);
// Security Toggles
const [auth2FA, setAuth2FA] = useState(false);
const [autoBlock, setAutoBlock] = useState(true);
// Webhook Toggles
const [notifyNewTenant, setNotifyNewTenant] = useState(true);
const [alertFailure, setAlertFailure] = useState(true);
const [dailyReport, setDailyReport] = useState(false);
const toggles = [
{ label: 'Modo Manutenção', desc: 'Desabilita o acesso dos tenants à plataforma', value: maintenanceMode, setter: setMaintenanceMode },
{ label: 'Registro aberto', desc: 'Permitir que novos estabelecimentos se cadastrem', value: openRegistration, setter: setOpenRegistration },
{ label: 'Trial automático', desc: 'Novos cadastros iniciam com período trial gratuito', value: autoTrial, setter: setAutoTrial },
];
return (
<div className="slide-up">
<div className="tabs">
{tabs.map(t => {
const Icon = t.icon;
return (
<button key={t.id} className={`tab ${activeTab === t.id ? 'active' : ''}`} onClick={() => setActiveTab(t.id)}>
<span style={{ display: 'inline-flex', alignItems: 'center', gap: 6 }}><Icon size={14} /> {t.label}</span>
</button>
);
})}
</div>
{activeTab === 'general' && (
<div className="card">
<h3 className="card-title mb-4">Configurações Gerais da Plataforma</h3>
<div className="form-group"><label className="form-label">Nome da Plataforma</label><input className="form-input" defaultValue="AgendaPRO" /></div>
<div className="grid-2">
<div className="form-group"><label className="form-label">Domínio Principal</label><input className="form-input" defaultValue="app.agendapro.com.br" /></div>
<div className="form-group"><label className="form-label">Domínio Admin</label><input className="form-input" defaultValue="admin.agendapro.com.br" /></div>
</div>
<div className="grid-2">
<div className="form-group"><label className="form-label">Período de Trial (dias)</label><input className="form-input" type="number" defaultValue={30} /></div>
<div className="form-group"><label className="form-label">Tolerância inadimplência (dias)</label><input className="form-input" type="number" defaultValue={7} /></div>
</div>
{toggles.map((item, i) => (
<div key={i} className="flex-between" style={{ padding: '14px 0', borderBottom: i < 2 ? '1px solid var(--border-color)' : 'none' }}>
<div><div style={{ fontWeight: 600, fontSize: 14 }}>{item.label}</div><div style={{ fontSize: 13, color: 'var(--text-secondary)' }}>{item.desc}</div></div>
<div className={`toggle ${item.value ? 'active' : ''}`} onClick={() => item.setter(!item.value)} />
</div>
))}
<button className="btn btn-primary mt-6" onClick={handleSave}><Save size={16} /> {saved ? '✓ Salvo!' : 'Salvar'}</button>
</div>
)}
{activeTab === 'database' && (
<div className="card">
<h3 className="card-title mb-4">Configuração do Banco de Dados</h3>
<div className="grid-2">
<div className="form-group"><label className="form-label">Host</label><input className="form-input" defaultValue="postgres" /></div>
<div className="form-group"><label className="form-label">Porta</label><input className="form-input" defaultValue="5432" /></div>
</div>
<div className="grid-2">
<div className="form-group"><label className="form-label">Banco de Dados</label><input className="form-input" defaultValue="agendapro" /></div>
<div className="form-group"><label className="form-label">Usuário</label><input className="form-input" defaultValue="agendapro_user" /></div>
</div>
<div className="form-group"><label className="form-label">Senha</label><input className="form-input" type="password" defaultValue="secure_password" /></div>
<div className="form-group"><label className="form-label">CONNECTION STRING (somente leitura)</label><input className="form-input" readOnly value="postgresql://agendapro_user:****@postgres:5432/agendapro" style={{ color: 'var(--text-muted)', fontFamily: 'monospace', fontSize: 12 }} /></div>
<button className="btn btn-primary mt-4" onClick={handleSave}><Save size={16} /> {saved ? '✓ Salvo!' : 'Salvar'}</button>
</div>
)}
{activeTab === 'security' && (
<div className="card">
<h3 className="card-title mb-4">Segurança e Autenticação</h3>
<div className="form-group"><label className="form-label"><Key size={14} style={{ display: 'inline', marginRight: 4 }} />JWT Secret</label><input className="form-input" type="password" defaultValue="super_secret_jwt_key_here" /></div>
<div className="grid-2">
<div className="form-group"><label className="form-label">Expiração do Token (horas)</label><input className="form-input" type="number" defaultValue={24} /></div>
<div className="form-group"><label className="form-label">Max tentativas de login</label><input className="form-input" type="number" defaultValue={5} /></div>
</div>
{[
{ label: 'Autenticação 2FA', desc: 'Exigir segundo fator para admins', value: auth2FA, setter: setAuth2FA },
{ label: 'Bloqueio automático', desc: 'Bloquear conta após tentativas excessivas', value: autoBlock, setter: setAutoBlock },
].map((item, i) => (
<div key={i} className="flex-between" style={{ padding: '14px 0', borderBottom: i < 1 ? '1px solid var(--border-color)' : 'none' }}>
<div><div style={{ fontWeight: 600, fontSize: 14 }}>{item.label}</div><div style={{ fontSize: 13, color: 'var(--text-secondary)' }}>{item.desc}</div></div>
<div className={`toggle ${item.value ? 'active' : ''}`} onClick={() => item.setter(!item.value)} />
</div>
))}
<button className="btn btn-primary mt-6" onClick={handleSave}><Save size={16} /> {saved ? '✓ Salvo!' : 'Salvar'}</button>
</div>
)}
{activeTab === 'email' && (
<div className="card">
<h3 className="card-title mb-4">Configurações de E-mail (SMTP)</h3>
<div className="grid-2">
<div className="form-group"><label className="form-label">Servidor SMTP</label><input className="form-input" placeholder="smtp.gmail.com" /></div>
<div className="form-group"><label className="form-label">Porta</label><input className="form-input" type="number" defaultValue={587} /></div>
</div>
<div className="grid-2">
<div className="form-group"><label className="form-label">Usuário</label><input className="form-input" placeholder="noreply@agendapro.com.br" /></div>
<div className="form-group"><label className="form-label">Senha</label><input className="form-input" type="password" /></div>
</div>
<div className="form-group"><label className="form-label">Remetente (From)</label><input className="form-input" defaultValue="AgendaPRO <noreply@agendapro.com.br>" /></div>
<div className="flex-between" style={{ padding: '14px 0' }}>
<div><div style={{ fontWeight: 600, fontSize: 14 }}>TLS/SSL</div><div style={{ fontSize: 13, color: 'var(--text-secondary)' }}>Usar conexão segura</div></div>
<div className="toggle active" onClick={e => (e.currentTarget as HTMLElement).classList.toggle('active')} />
</div>
<button className="btn btn-primary mt-4" onClick={handleSave}><Save size={16} /> {saved ? '✓ Salvo!' : 'Salvar'}</button>
</div>
)}
{activeTab === 'webhooks' && (
<div className="card">
<h3 className="card-title mb-4">Webhooks e Integrações</h3>
<div className="form-group"><label className="form-label">URL Webhook de Pagamento (Asaas/Stripe)</label><input className="form-input" placeholder="https://admin.agendapro.com.br/api/webhooks/payment" /></div>
<div className="form-group"><label className="form-label">Webhook Secret</label><input className="form-input" type="password" placeholder="whsec_..." /></div>
<div className="form-group"><label className="form-label">URL Webhook WhatsApp</label><input className="form-input" placeholder="https://admin.agendapro.com.br/api/webhooks/whatsapp" /></div>
{[
{ label: 'Notificação de novo assinante', desc: 'Enviar alerta quando novo tenant se cadastra', value: notifyNewTenant, setter: setNotifyNewTenant },
{ label: 'Alerta de inadimplência', desc: 'Notificar quando pagamento falhar', value: alertFailure, setter: setAlertFailure },
{ label: 'Relatório diário', desc: 'Enviar resumo diário de métricas', value: dailyReport, setter: setDailyReport },
].map((item, i) => (
<div key={i} className="flex-between" style={{ padding: '14px 0', borderBottom: i < 2 ? '1px solid var(--border-color)' : 'none' }}>
<div><div style={{ fontWeight: 600, fontSize: 14 }}>{item.label}</div><div style={{ fontSize: 13, color: 'var(--text-secondary)' }}>{item.desc}</div></div>
<div className={`toggle ${item.value ? 'active' : ''}`} onClick={() => item.setter(!item.value)} />
</div>
))}
<button className="btn btn-primary mt-6" onClick={handleSave}><Save size={16} /> {saved ? '✓ Salvo!' : 'Salvar'}</button>
</div>
)}
</div>
);
}

View File

@ -0,0 +1,95 @@
'use client';
import { useState } from 'react';
import { Search, Download, Eye, CheckCircle, AlertCircle, XCircle, DollarSign, Filter } from 'lucide-react';
interface Payment {
id: string; tenant: string; plan: string; amount: number; status: string; method: string; date: string; invoiceId: string;
}
const statusMap: Record<string, { label: string; cls: string; icon: typeof CheckCircle }> = {
paid: { label: 'Pago', cls: 'badge-success', icon: CheckCircle },
pending: { label: 'Pendente', cls: 'badge-warning', icon: AlertCircle },
failed: { label: 'Falhou', cls: 'badge-danger', icon: XCircle },
refunded: { label: 'Estornado', cls: 'badge-info', icon: DollarSign },
};
const initialPayments: Payment[] = [
{ id: '1', tenant: 'Barbearia Premium', plan: 'Professional', amount: 99.90, status: 'paid', method: 'Cartão de Crédito', date: '2026-05-31', invoiceId: 'INV-2026-001' },
{ id: '2', tenant: 'Studio Bella Nails', plan: 'Starter', amount: 49.90, status: 'paid', method: 'PIX', date: '2026-05-30', invoiceId: 'INV-2026-002' },
{ id: '3', tenant: 'Spa Relaxe', plan: 'Business', amount: 199.90, status: 'paid', method: 'Boleto', date: '2026-05-29', invoiceId: 'INV-2026-003' },
{ id: '4', tenant: 'Salão Glamour', plan: 'Starter', amount: 49.90, status: 'failed', method: 'Cartão de Crédito', date: '2026-05-28', invoiceId: 'INV-2026-004' },
{ id: '5', tenant: 'Estética Renova', plan: 'Business', amount: 199.90, status: 'paid', method: 'PIX', date: '2026-05-28', invoiceId: 'INV-2026-005' },
{ id: '6', tenant: 'Barbearia Premium', plan: 'Professional', amount: 99.90, status: 'paid', method: 'Cartão de Crédito', date: '2026-04-30', invoiceId: 'INV-2026-006' },
{ id: '7', tenant: 'Studio Bella Nails', plan: 'Starter', amount: 49.90, status: 'paid', method: 'PIX', date: '2026-04-30', invoiceId: 'INV-2026-007' },
{ id: '8', tenant: 'Spa Relaxe', plan: 'Business', amount: 199.90, status: 'paid', method: 'Boleto', date: '2026-04-29', invoiceId: 'INV-2026-008' },
{ id: '9', tenant: 'Salão Glamour', plan: 'Starter', amount: 49.90, status: 'pending', method: 'Boleto', date: '2026-05-15', invoiceId: 'INV-2026-009' },
{ id: '10', tenant: 'Barbearia do Zé', plan: 'Starter', amount: 49.90, status: 'refunded', method: 'PIX', date: '2026-04-20', invoiceId: 'INV-2026-010' },
];
export default function Payments() {
const [search, setSearch] = useState('');
const [filterStatus, setFilterStatus] = useState('all');
const [filterMonth, setFilterMonth] = useState('all');
const filtered = initialPayments.filter(p => {
const matchSearch = p.tenant.toLowerCase().includes(search.toLowerCase()) || p.invoiceId.toLowerCase().includes(search.toLowerCase());
const matchStatus = filterStatus === 'all' || p.status === filterStatus;
const matchMonth = filterMonth === 'all' || p.date.startsWith(filterMonth);
return matchSearch && matchStatus && matchMonth;
});
const totalPaid = filtered.filter(p => p.status === 'paid').reduce((s, p) => s + p.amount, 0);
const totalPending = filtered.filter(p => p.status === 'pending').reduce((s, p) => s + p.amount, 0);
const totalFailed = filtered.filter(p => p.status === 'failed').reduce((s, p) => s + p.amount, 0);
return (
<div className="slide-up">
<div className="stats-grid" style={{ gridTemplateColumns: 'repeat(3, 1fr)' }}>
<div className="stat-card"><div className="stat-icon green"><CheckCircle size={24} /></div><div><div className="stat-value" style={{ fontSize: 22 }}>R$ {totalPaid.toFixed(2)}</div><div className="stat-label">Total Recebido</div></div></div>
<div className="stat-card"><div className="stat-icon amber"><AlertCircle size={24} /></div><div><div className="stat-value" style={{ fontSize: 22 }}>R$ {totalPending.toFixed(2)}</div><div className="stat-label">Pendente</div></div></div>
<div className="stat-card"><div className="stat-icon red"><XCircle size={24} /></div><div><div className="stat-value" style={{ fontSize: 22 }}>R$ {totalFailed.toFixed(2)}</div><div className="stat-label">Falhas</div></div></div>
</div>
<div className="flex-between mb-6">
<div className="flex-gap" style={{ gap: 12 }}>
<div style={{ position: 'relative' }}>
<Search size={18} style={{ position: 'absolute', left: 14, top: '50%', transform: 'translateY(-50%)', color: 'var(--text-muted)' }} />
<input className="form-input" placeholder="Buscar por tenant ou fatura..." style={{ width: 300, paddingLeft: 42 }} value={search} onChange={e => setSearch(e.target.value)} />
</div>
<select className="form-select" style={{ width: 140 }} value={filterStatus} onChange={e => setFilterStatus(e.target.value)}>
<option value="all">Todos</option><option value="paid">Pagos</option><option value="pending">Pendentes</option><option value="failed">Falhas</option><option value="refunded">Estornados</option>
</select>
<select className="form-select" style={{ width: 140 }} value={filterMonth} onChange={e => setFilterMonth(e.target.value)}>
<option value="all">Todo período</option><option value="2026-05">Maio 2026</option><option value="2026-04">Abril 2026</option>
</select>
</div>
<button className="btn btn-secondary"><Download size={16} /> Exportar CSV</button>
</div>
<div className="card" style={{ padding: 0 }}>
<div className="table-container">
<table>
<thead><tr><th>Fatura</th><th>Estabelecimento</th><th>Plano</th><th>Valor</th><th>Status</th><th>Método</th><th>Data</th></tr></thead>
<tbody>
{filtered.map(p => {
const st = statusMap[p.status];
return (
<tr key={p.id}>
<td><span style={{ fontFamily: 'monospace', fontSize: 13, color: 'var(--text-muted)' }}>{p.invoiceId}</span></td>
<td style={{ fontWeight: 600 }}>{p.tenant}</td>
<td><span className="badge badge-amber">{p.plan}</span></td>
<td style={{ fontWeight: 700 }}>R$ {p.amount.toFixed(2)}</td>
<td><span className={`badge ${st.cls}`}>{st.label}</span></td>
<td style={{ fontSize: 13, color: 'var(--text-secondary)' }}>{p.method}</td>
<td style={{ fontSize: 13, color: 'var(--text-secondary)' }}>{new Date(p.date + 'T12:00:00').toLocaleDateString('pt-BR')}</td>
</tr>
);
})}
{filtered.length === 0 && <tr><td colSpan={7} style={{ textAlign: 'center', padding: 40, color: 'var(--text-muted)' }}>Nenhum pagamento encontrado</td></tr>}
</tbody>
</table>
</div>
</div>
</div>
);
}

View File

@ -0,0 +1,134 @@
'use client';
import { useState } from 'react';
import { Plus, X, Edit, Trash2, Check, Users, DollarSign, AlertTriangle } from 'lucide-react';
interface Plan { id: string; name: string; slug: string; priceMonthly: number; priceYearly: number; maxProfessionals: number; maxServices: number; features: string[]; active: boolean; subscribers: number; }
const initialPlans: Plan[] = [
{ id: '1', name: 'Starter', slug: 'starter', priceMonthly: 49.90, priceYearly: 479, maxProfessionals: 1, maxServices: 10, features: ['Agendamento online', 'Página de reservas', 'Lembretes por e-mail', 'Relatórios básicos'], active: true, subscribers: 35 },
{ id: '2', name: 'Professional', slug: 'professional', priceMonthly: 99.90, priceYearly: 959, maxProfessionals: 5, maxServices: 30, features: ['Tudo do Starter', 'Múltiplos profissionais', 'Comissionamento', 'Relatórios avançados', 'Personalização da marca'], active: true, subscribers: 32 },
{ id: '3', name: 'Business', slug: 'business', priceMonthly: 199.90, priceYearly: 1919, maxProfessionals: 15, maxServices: 100, features: ['Tudo do Professional', 'API de integração', 'Suporte prioritário', 'Multi-unidades', 'Automações avançadas'], active: true, subscribers: 15 },
{ id: '4', name: 'Enterprise', slug: 'enterprise', priceMonthly: 399.90, priceYearly: 3839, maxProfessionals: 999, maxServices: 999, features: ['Tudo do Business', 'Gerente dedicado', 'SLA garantido', 'Integrações customizadas', 'White-label'], active: true, subscribers: 5 },
];
const emptyPlan: Partial<Plan> = { name: '', slug: '', priceMonthly: 0, priceYearly: 0, maxProfessionals: 1, maxServices: 10, features: [], active: true };
export default function Plans() {
const [plans, setPlans] = useState<Plan[]>(initialPlans);
const [showModal, setShowModal] = useState(false);
const [editing, setEditing] = useState<Plan | null>(null);
const [form, setForm] = useState<Partial<Plan>>(emptyPlan);
const [featureInput, setFeatureInput] = useState('');
const [deleteConfirm, setDeleteConfirm] = useState<string | null>(null);
const openAdd = () => { setEditing(null); setForm({ ...emptyPlan, id: Date.now().toString(), features: [] }); setShowModal(true); };
const openEdit = (p: Plan) => { setEditing(p); setForm({ ...p }); setShowModal(true); };
const handleSave = () => {
if (!form.name?.trim()) return;
if (editing) { setPlans(prev => prev.map(p => p.id === editing.id ? { ...p, ...form } as Plan : p)); }
else { setPlans(prev => [...prev, { ...form, subscribers: 0 } as Plan]); }
setShowModal(false);
};
const handleDelete = (id: string) => { setPlans(prev => prev.filter(p => p.id !== id)); setDeleteConfirm(null); };
const addFeature = () => { if (featureInput.trim()) { setForm(p => ({ ...p, features: [...(p.features || []), featureInput.trim()] })); setFeatureInput(''); } };
const removeFeature = (idx: number) => { setForm(p => ({ ...p, features: (p.features || []).filter((_, i) => i !== idx) })); };
const colors = ['#3b82f6', '#6C63FF', '#a855f7', '#f59e0b'];
return (
<div className="slide-up">
<div className="flex-between mb-6">
<p style={{ color: 'var(--text-secondary)', fontSize: '14px' }}>{plans.length} planos configurados</p>
<button className="btn btn-primary" onClick={openAdd}><Plus size={16} /> Novo Plano</button>
</div>
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(auto-fill, minmax(280px, 1fr))', gap: '20px' }}>
{plans.map((p, idx) => (
<div key={p.id} className="card" style={{ position: 'relative', borderTop: `3px solid ${colors[idx % colors.length]}` }}>
{deleteConfirm === p.id && (
<div style={{ position: 'absolute', inset: 0, background: 'rgba(0,0,0,0.9)', borderRadius: 'var(--radius-lg)', display: 'flex', flexDirection: 'column', alignItems: 'center', justifyContent: 'center', gap: 16, zIndex: 10 }}>
<AlertTriangle size={32} color="var(--warning)" />
<p style={{ fontWeight: 600 }}>Excluir plano {p.name}?</p>
<p style={{ fontSize: 12, color: 'var(--text-secondary)' }}>{p.subscribers} assinantes serão afetados</p>
<div style={{ display: 'flex', gap: 8 }}><button className="btn btn-sm btn-danger" onClick={() => handleDelete(p.id)}>Excluir</button><button className="btn btn-sm btn-secondary" onClick={() => setDeleteConfirm(null)}>Cancelar</button></div>
</div>
)}
<div className="flex-between mb-4">
<h3 style={{ fontSize: '20px', fontWeight: 800 }}>{p.name}</h3>
<div style={{ display: 'flex', gap: 4 }}>
<button className="btn-ghost btn-icon" onClick={() => openEdit(p)}><Edit size={14} /></button>
<button className="btn-ghost btn-icon" style={{ color: 'var(--danger)' }} onClick={() => setDeleteConfirm(p.id)}><Trash2 size={14} /></button>
</div>
</div>
<div style={{ marginBottom: '16px' }}>
<span style={{ fontSize: '32px', fontWeight: 900 }}>R$ {p.priceMonthly.toFixed(2).replace('.', ',')}</span>
<span style={{ fontSize: '14px', color: 'var(--text-muted)' }}>/mês</span>
<div style={{ fontSize: '12px', color: 'var(--text-muted)', marginTop: 2 }}>ou R$ {p.priceYearly.toFixed(2).replace('.', ',')} /ano</div>
</div>
<div className="grid-2 mb-4">
<div style={{ padding: '10px', background: 'var(--bg-primary)', borderRadius: 'var(--radius-sm)', textAlign: 'center' }}>
<div style={{ fontSize: '16px', fontWeight: 800, color: colors[idx % colors.length] }}>{p.maxProfessionals === 999 ? '∞' : p.maxProfessionals}</div>
<div style={{ fontSize: '10px', color: 'var(--text-muted)' }}>Profissionais</div>
</div>
<div style={{ padding: '10px', background: 'var(--bg-primary)', borderRadius: 'var(--radius-sm)', textAlign: 'center' }}>
<div style={{ fontSize: '16px', fontWeight: 800, color: colors[idx % colors.length] }}><Users size={14} style={{ display: 'inline' }} /> {p.subscribers}</div>
<div style={{ fontSize: '10px', color: 'var(--text-muted)' }}>Assinantes</div>
</div>
</div>
<ul style={{ listStyle: 'none', fontSize: '13px' }}>
{p.features.map((f, i) => (
<li key={i} style={{ padding: '4px 0', color: 'var(--text-secondary)', display: 'flex', alignItems: 'center', gap: 6 }}>
<Check size={14} color="var(--success)" /> {f}
</li>
))}
</ul>
</div>
))}
</div>
{showModal && (
<div className="modal-overlay" onClick={() => setShowModal(false)}>
<div className="modal" onClick={e => e.stopPropagation()}>
<div className="modal-header">
<h2 className="modal-title">{editing ? 'Editar Plano' : 'Novo Plano'}</h2>
<button className="btn-ghost btn-icon" onClick={() => setShowModal(false)}><X size={20} /></button>
</div>
<div className="modal-body">
<div className="grid-2">
<div className="form-group"><label className="form-label">Nome *</label><input className="form-input" value={form.name || ''} onChange={e => setForm(p => ({ ...p, name: e.target.value }))} /></div>
<div className="form-group"><label className="form-label">Slug</label><input className="form-input" value={form.slug || ''} onChange={e => setForm(p => ({ ...p, slug: e.target.value }))} /></div>
</div>
<div className="grid-2">
<div className="form-group"><label className="form-label">Preço Mensal (R$)</label><input className="form-input" type="number" step="0.01" value={form.priceMonthly || 0} onChange={e => setForm(p => ({ ...p, priceMonthly: Number(e.target.value) }))} /></div>
<div className="form-group"><label className="form-label">Preço Anual (R$)</label><input className="form-input" type="number" step="0.01" value={form.priceYearly || 0} onChange={e => setForm(p => ({ ...p, priceYearly: Number(e.target.value) }))} /></div>
</div>
<div className="grid-2">
<div className="form-group"><label className="form-label">Max Profissionais</label><input className="form-input" type="number" value={form.maxProfessionals || 1} onChange={e => setForm(p => ({ ...p, maxProfessionals: Number(e.target.value) }))} /></div>
<div className="form-group"><label className="form-label">Max Serviços</label><input className="form-input" type="number" value={form.maxServices || 10} onChange={e => setForm(p => ({ ...p, maxServices: Number(e.target.value) }))} /></div>
</div>
<div className="form-group">
<label className="form-label">Recursos</label>
<div style={{ display: 'flex', gap: 8, marginBottom: 8 }}>
<input className="form-input" placeholder="Adicionar recurso..." value={featureInput} onChange={e => setFeatureInput(e.target.value)} onKeyDown={e => e.key === 'Enter' && addFeature()} />
<button className="btn btn-sm btn-secondary" onClick={addFeature}>+</button>
</div>
<div style={{ display: 'flex', flexDirection: 'column', gap: 4 }}>
{(form.features || []).map((f, i) => (
<div key={i} className="flex-between" style={{ padding: '6px 10px', background: 'var(--bg-primary)', borderRadius: 'var(--radius-sm)', fontSize: 13 }}>
<span>{f}</span>
<button className="btn-ghost btn-icon" onClick={() => removeFeature(i)} style={{ color: 'var(--danger)' }}><X size={12} /></button>
</div>
))}
</div>
</div>
</div>
<div className="modal-footer">
<button className="btn btn-secondary" onClick={() => setShowModal(false)}>Cancelar</button>
<button className="btn btn-primary" onClick={handleSave}>{editing ? 'Salvar' : 'Criar Plano'}</button>
</div>
</div>
</div>
)}
</div>
);
}

View File

@ -0,0 +1,219 @@
'use client';
import { useState, useEffect } from 'react';
import { Search, Plus, X, Edit, Trash2, Eye, AlertTriangle, Ban, CheckCircle, Building2 } from 'lucide-react';
interface Tenant {
id: string; name: string; slug: string; type: string; plan: string; status: string; email: string; phone: string; mrr: number; createdAt: string; trialEnds: string;
}
const statusMap: Record<string, { label: string; cls: string }> = {
active: { label: 'Ativo', cls: 'badge-success' }, trial: { label: 'Trial', cls: 'badge-info' },
past_due: { label: 'Inadimplente', cls: 'badge-danger' }, cancelled: { label: 'Cancelado', cls: 'badge-warning' },
suspended: { label: 'Suspenso', cls: 'badge-danger' },
};
const initialTenants: Tenant[] = [
{ id: '1', name: 'Barbearia Premium', slug: 'barbearia-premium', type: 'Barbearia', plan: 'Professional', status: 'active', email: 'contato@barbeariapremium.com.br', phone: '(11) 99999-1234', mrr: 99.90, createdAt: '2026-01-15', trialEnds: '' },
{ id: '2', name: 'Studio Bella Nails', slug: 'bella-nails', type: 'Manicure', plan: 'Starter', status: 'active', email: 'bella@nails.com', phone: '(21) 98888-5555', mrr: 49.90, createdAt: '2026-02-20', trialEnds: '' },
{ id: '3', name: 'Spa Relaxe', slug: 'spa-relaxe', type: 'Massagem', plan: 'Business', status: 'active', email: 'contato@sparelaxe.com', phone: '(11) 97777-3333', mrr: 199.90, createdAt: '2026-01-05', trialEnds: '' },
{ id: '4', name: 'Barber Shop Elite', slug: 'barber-elite', type: 'Barbearia', plan: 'Professional', status: 'trial', email: 'elite@barber.com', phone: '(31) 96666-4444', mrr: 0, createdAt: '2026-05-28', trialEnds: '2026-06-27' },
{ id: '5', name: 'Salão Glamour', slug: 'salao-glamour', type: 'Salão', plan: 'Starter', status: 'past_due', email: 'glamour@salao.com', phone: '(41) 95555-7777', mrr: 49.90, createdAt: '2026-03-10', trialEnds: '' },
{ id: '6', name: 'Estética Renova', slug: 'estetica-renova', type: 'Estética', plan: 'Business', status: 'active', email: 'renova@estetica.com', phone: '(51) 94444-8888', mrr: 199.90, createdAt: '2026-04-01', trialEnds: '' },
{ id: '7', name: 'Barbearia do Zé', slug: 'barbearia-ze', type: 'Barbearia', plan: 'Starter', status: 'cancelled', email: 'ze@barbearia.com', phone: '(61) 93333-9999', mrr: 0, createdAt: '2026-02-15', trialEnds: '' },
];
export default function Tenants() {
const [tenants, setTenants] = useState<Tenant[]>(initialTenants);
const [search, setSearch] = useState('');
const [filterStatus, setFilterStatus] = useState('all');
const [showModal, setShowModal] = useState(false);
const [editing, setEditing] = useState<Tenant | null>(null);
const [deleteConfirm, setDeleteConfirm] = useState<string | null>(null);
const [form, setForm] = useState<Partial<Tenant>>({});
useEffect(() => {
// Busca dados reais do banco do Barbearia Premium para mostrar com transparência
fetch('/api/tenant/subscription?slug=barbearia-premium')
.then(res => res.json())
.then(data => {
if (data && data.status) {
setTenants(prev => prev.map(t => {
if (t.id === '1') {
let mrr = 99.90;
if (data.subscription?.amount) {
mrr = parseFloat(data.subscription.amount);
}
return {
...t,
plan: data.subscription?.plan_name || t.plan,
status: data.status,
mrr
};
}
return t;
}));
}
})
.catch(err => {
console.log('Não foi possível carregar dados em tempo real da API principal. Usando cookie fallback.');
const getCookie = (name: string): string => {
if (typeof document === 'undefined') return '';
const value = `; ${document.cookie}`;
const parts = value.split(`; ${name}=`);
if (parts.length === 2) return parts.pop()!.split(';').shift() || '';
return '';
};
const savedPlan = getCookie('agendapro_plan');
if (savedPlan) {
setTenants(prev => prev.map(t => {
if (t.id === '1') {
let mrr = 99.90;
if (savedPlan === 'Starter') mrr = 49.90;
else if (savedPlan === 'Business') mrr = 199.90;
else if (savedPlan === 'Enterprise') mrr = 399.90;
return { ...t, plan: savedPlan, mrr };
}
return t;
}));
}
});
}, []);
const filtered = tenants.filter(t => {
const matchSearch = t.name.toLowerCase().includes(search.toLowerCase()) || t.email.toLowerCase().includes(search.toLowerCase());
const matchStatus = filterStatus === 'all' || t.status === filterStatus;
return matchSearch && matchStatus;
});
const openEdit = (t: Tenant) => { setEditing(t); setForm({ ...t }); setShowModal(true); };
const openAdd = () => { setEditing(null); setForm({ id: Date.now().toString(), status: 'trial', plan: 'Starter', mrr: 0, createdAt: new Date().toISOString().split('T')[0] }); setShowModal(true); };
const handleSave = () => {
if (!form.name?.trim()) return;
if (editing) { setTenants(prev => prev.map(t => t.id === editing.id ? { ...t, ...form } as Tenant : t)); }
else { setTenants(prev => [...prev, form as Tenant]); }
setShowModal(false);
};
const handleDelete = (id: string) => { setTenants(prev => prev.filter(t => t.id !== id)); setDeleteConfirm(null); };
const toggleStatus = async (id: string, newStatus: string) => {
if (id === '1') {
try {
const res = await fetch('/api/tenant/subscription/simulate', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
slug: 'barbearia-premium',
status: newStatus === 'active' ? 'active' : 'suspended'
})
});
if (!res.ok) {
alert('Erro ao sincronizar status no banco de dados do AgendaPRO.');
return;
}
} catch (err) {
console.error('Falha ao conectar na API do AgendaPRO:', err);
}
}
setTenants(prev => prev.map(t => t.id === id ? { ...t, status: newStatus } : t));
};
return (
<div className="slide-up">
<div className="flex-between mb-6">
<div className="flex-gap" style={{ gap: '12px' }}>
<div style={{ position: 'relative' }}>
<Search size={18} style={{ position: 'absolute', left: 14, top: '50%', transform: 'translateY(-50%)', color: 'var(--text-muted)' }} />
<input className="form-input" placeholder="Buscar assinante..." style={{ width: 300, paddingLeft: 42 }} value={search} onChange={e => setSearch(e.target.value)} />
</div>
<select className="form-select" style={{ width: 160 }} value={filterStatus} onChange={e => setFilterStatus(e.target.value)}>
<option value="all">Todos</option>
<option value="active">Ativos</option>
<option value="trial">Em Trial</option>
<option value="past_due">Inadimplentes</option>
<option value="cancelled">Cancelados</option>
<option value="suspended">Suspensos</option>
</select>
</div>
<button className="btn btn-primary" onClick={openAdd}><Plus size={16} /> Novo Assinante</button>
</div>
<div className="card" style={{ padding: 0 }}>
<div className="table-container">
<table>
<thead><tr><th>Estabelecimento</th><th>Tipo</th><th>Plano</th><th>Status</th><th>MRR</th><th>Desde</th><th style={{ width: 120 }}>Ações</th></tr></thead>
<tbody>
{filtered.map(t => (
<tr key={t.id}>
<td><div className="flex-gap"><div className="avatar" style={{ width: 32, height: 32, fontSize: 12 }}>{t.name[0]}</div><div><div style={{ fontWeight: 600 }}>{t.name}</div><div style={{ fontSize: 11, color: 'var(--text-muted)' }}>{t.email}</div></div></div></td>
<td style={{ fontSize: 13 }}>{t.type}</td>
<td><span className="badge badge-amber">{t.plan}</span></td>
<td><span className={`badge ${statusMap[t.status]?.cls}`}>{statusMap[t.status]?.label}</span></td>
<td style={{ fontWeight: 600 }}>{t.mrr > 0 ? `R$ ${t.mrr.toFixed(2)}` : '-'}</td>
<td style={{ fontSize: 13, color: 'var(--text-secondary)' }}>{new Date(t.createdAt + 'T12:00:00').toLocaleDateString('pt-BR')}</td>
<td>
{deleteConfirm === t.id ? (
<div style={{ display: 'flex', gap: 4 }}>
<button className="btn btn-sm btn-danger" onClick={() => handleDelete(t.id)}>Sim</button>
<button className="btn btn-sm btn-secondary" onClick={() => setDeleteConfirm(null)}>Não</button>
</div>
) : (
<div style={{ display: 'flex', gap: 4 }}>
<button className="btn-ghost btn-icon" title="Editar" onClick={() => openEdit(t)}><Edit size={14} /></button>
{t.status === 'active' && <button className="btn-ghost btn-icon" title="Suspender" style={{ color: 'var(--warning)' }} onClick={() => toggleStatus(t.id, 'suspended')}><Ban size={14} /></button>}
{t.status === 'suspended' && <button className="btn-ghost btn-icon" title="Reativar" style={{ color: 'var(--success)' }} onClick={() => toggleStatus(t.id, 'active')}><CheckCircle size={14} /></button>}
<button className="btn-ghost btn-icon" title="Excluir" style={{ color: 'var(--danger)' }} onClick={() => setDeleteConfirm(t.id)}><Trash2 size={14} /></button>
</div>
)}
</td>
</tr>
))}
</tbody>
</table>
</div>
</div>
{showModal && (
<div className="modal-overlay" onClick={() => setShowModal(false)}>
<div className="modal" onClick={e => e.stopPropagation()}>
<div className="modal-header">
<h2 className="modal-title">{editing ? 'Editar Assinante' : 'Novo Assinante'}</h2>
<button className="btn-ghost btn-icon" onClick={() => setShowModal(false)}><X size={20} /></button>
</div>
<div className="modal-body">
<div className="form-group"><label className="form-label">Nome do Estabelecimento *</label><input className="form-input" value={form.name || ''} onChange={e => setForm(p => ({ ...p, name: e.target.value }))} /></div>
<div className="grid-2">
<div className="form-group"><label className="form-label">Slug</label><input className="form-input" value={form.slug || ''} onChange={e => setForm(p => ({ ...p, slug: e.target.value }))} /></div>
<div className="form-group"><label className="form-label">Tipo</label>
<select className="form-select" value={form.type || ''} onChange={e => setForm(p => ({ ...p, type: e.target.value }))}>
<option value="Barbearia">Barbearia</option><option value="Manicure">Manicure</option><option value="Massagem">Massagem</option><option value="Estética">Estética</option><option value="Salão">Salão</option>
</select>
</div>
</div>
<div className="grid-2">
<div className="form-group"><label className="form-label">E-mail</label><input className="form-input" value={form.email || ''} onChange={e => setForm(p => ({ ...p, email: e.target.value }))} /></div>
<div className="form-group"><label className="form-label">Telefone</label><input className="form-input" value={form.phone || ''} onChange={e => setForm(p => ({ ...p, phone: e.target.value }))} /></div>
</div>
<div className="grid-2">
<div className="form-group"><label className="form-label">Plano</label>
<select className="form-select" value={form.plan || ''} onChange={e => setForm(p => ({ ...p, plan: e.target.value }))}>
<option>Starter</option><option>Professional</option><option>Business</option><option>Enterprise</option>
</select>
</div>
<div className="form-group"><label className="form-label">Status</label>
<select className="form-select" value={form.status || ''} onChange={e => setForm(p => ({ ...p, status: e.target.value }))}>
<option value="active">Ativo</option><option value="trial">Trial</option><option value="past_due">Inadimplente</option><option value="suspended">Suspenso</option><option value="cancelled">Cancelado</option>
</select>
</div>
</div>
</div>
<div className="modal-footer">
<button className="btn btn-secondary" onClick={() => setShowModal(false)}>Cancelar</button>
<button className="btn btn-primary" onClick={handleSave}>{editing ? 'Salvar' : 'Criar Assinante'}</button>
</div>
</div>
</div>
)}
</div>
);
}

40
admin/tsconfig.json Normal file
View File

@ -0,0 +1,40 @@
{
"compilerOptions": {
"lib": [
"dom",
"dom.iterable",
"esnext"
],
"allowJs": true,
"skipLibCheck": true,
"strict": true,
"noEmit": true,
"esModuleInterop": true,
"module": "esnext",
"moduleResolution": "bundler",
"resolveJsonModule": true,
"isolatedModules": true,
"jsx": "preserve",
"incremental": true,
"plugins": [
{
"name": "next"
}
],
"paths": {
"@/*": [
"./src/*"
]
},
"target": "ES2017"
},
"include": [
"next-env.d.ts",
"**/*.ts",
"**/*.tsx",
".next/types/**/*.ts"
],
"exclude": [
"node_modules"
]
}

File diff suppressed because one or more lines are too long

195
asaas_skill.md Normal file
View File

@ -0,0 +1,195 @@
# Integração Asaas Payment Gateway - AgendaPRO
Este documento serve como guia técnico ("Skill") para a integração do gateway de pagamento **Asaas** na plataforma SaaS multi-tenant **AgendaPRO**. Ele detalha os fluxos de trabalho, estruturas de API, segurança do Webhook e regras de conciliação financeira em banco de dados PostgreSQL.
---
## 1. Visão Geral da Arquitetura
A integração é baseada no modelo **SQL-First**, onde o banco de dados PostgreSQL é a única fonte da verdade. O mapeamento é feito associando os IDs internos do sistema (`UUID`) ao campo `externalReference` fornecido pela API do Asaas.
```mermaid
sequenceDiagram
participant Cliente/Tenant as App/Painel (AgendaPRO)
participant Lib as Serviço Asaas (src/lib/asaas.ts)
participant Asaas as Asaas API (Sandbox/Prod)
participant DB as PostgreSQL
Cliente/Tenant->>Lib: Solicita Cobrança/Assinatura
Lib->>DB: Busca Configuração do Tenant (API Key)
DB-->>Lib: Retorna Configuração do Tenant
Lib->>Asaas: POST /v3/payments (ou /subscriptions) com externalReference
Asaas-->>Lib: Retorna Dados da Transação (ID do Asaas)
Lib-->>Cliente/Tenant: Retorna Link/QR Code Pix para o Cliente
```
---
## 2. Ambientes e Autenticação
O Asaas possui dois ambientes distintos. As chaves de API (`API Keys`) geradas no Sandbox não funcionam em Produção e vice-versa.
| Ambiente | URL Base da API v3 | Variável de Ambiente |
| :--- | :--- | :--- |
| **Sandbox (Homologação)** | `https://api-sandbox.asaas.com/v3` | `ASAAS_API_KEY` (Prefixo de teste) |
| **Produção** | `https://api.asaas.com/v3` | `ASAAS_API_KEY` |
### Cabeçalhos HTTP Obrigatórios
Todas as chamadas à API devem conter:
* `access_token`: Sua chave de API do Asaas (específica para o tenant ou plataforma).
* `Content-Type`: `application/json`.
* `User-Agent`: Nome da aplicação (ex: `AgendaPRO-Integration`).
---
## 3. Fluxo de Clientes (Customers)
Antes de emitir qualquer cobrança ou assinatura, o cliente (consumidor final) deve ser cadastrado no Asaas. O ID do cliente retornado (`cus_...`) deve ser salvo no banco local (ex: no cadastro de inquilino/tenant ou no cadastro de cliente do estabelecimento).
* **Endpoint:** `POST /v3/customers`
* **Payload Exemplo:**
```json
{
"name": "João Mendes",
"cpfCnpj": "00000000000",
"email": "joao@email.com",
"phone": "11999991234",
"mobilePhone": "11999991234",
"externalReference": "local-client-uuid",
"notificationDisabled": false
}
```
* **Response de Sucesso:** Retorna o objeto do cliente contendo o `"id": "cus_000005219613"`.
---
## 4. Fluxo de Cobranças Avulsas (Payments - Pix/Boleto/Cartão)
Usado para pagamentos individuais, como a cobrança por um agendamento específico.
* **Endpoint:** `POST /v3/payments`
* **Payload Exemplo (Pix/Boleto):**
```json
{
"customer": "cus_000005219613",
"billingType": "PIX",
"value": 55.00,
"dueDate": "2026-06-10",
"description": "Corte Masculino - Barbearia Premium",
"externalReference": "local-payment-uuid"
}
```
* **Response de Sucesso:** Retorna o ID da transação (ex: `"id": "pay_080225913252"`).
### Obtenção do QR Code Dinâmico Pix
Se a cobrança for do tipo `PIX`, você pode buscar a imagem em Base64 e a linha digitável ("copia e cola"):
* **Endpoint:** `GET /v3/payments/{id}/pixQrCode` (Sem corpo na requisição)
* **Response Exemplo:**
```json
{
"encodedImage": "iVBORw0KGgoAAAANSUhEUgAA...",
"payload": "00020101021126660014br.gov.bcb.pix...",
"expirationDate": "2026-06-10T23:59:59Z"
}
```
---
## 5. Fluxo de Assinaturas Recorrentes (Subscriptions)
Usado pelo SaaS para cobrar a mensalidade do próprio inquilino (Tenant) ou pelos inquilinos para oferecer planos recorrentes.
* **Endpoint:** `POST /v3/subscriptions`
* **Payload Exemplo (Cartão de Crédito):**
```json
{
"customer": "cus_0T1mdomVMi39",
"billingType": "CREDIT_CARD",
"nextDueDate": "2026-07-03",
"value": 99.90,
"cycle": "MONTHLY",
"description": "Assinatura AgendaPRO - Plano Professional",
"externalReference": "local-subscription-uuid",
"creditCard": {
"holderName": "TITULAR DO CARTAO",
"number": "0000000000000000",
"expiryMonth": "12",
"expiryYear": "2030",
"ccv": "123"
},
"creditCardHolderInfo": {
"name": "TITULAR DO CARTAO",
"email": "titular@email.com",
"cpfCnpj": "00000000000",
"postalCode": "01310000",
"addressNumber": "123",
"phone": "11999991234"
}
}
```
---
## 6. Webhook e Segurança
O webhook do Asaas garante a conciliação automática do status de pagamentos de forma assíncrona.
### Validação de Autenticidade (Signature)
Diferente de gateways que usam assinaturas HMAC, o Asaas utiliza um **Token de Acesso Simples** configurado na interface do webhook.
1. Configure um Token Secreto no Painel do Asaas (ou salve em `settings` no banco local do tenant).
2. O Asaas enviará este token no cabeçalho `asaas-access-token` em cada requisição de Webhook.
3. A aplicação deve validar: `req.headers.get('asaas-access-token') === webhookSecret`.
### Estrutura de Payload do Webhook
```json
{
"id": "evt_05b708f961d739ea7eba7e4db318f621",
"event": "PAYMENT_RECEIVED",
"dateCreated": "2026-06-03 20:20:00",
"payment": {
"id": "pay_080225913252",
"customer": "cus_000005219613",
"subscription": "sub_VXJBYgP2u0eO",
"value": 100.00,
"netValue": 95.00,
"externalReference": "local-payment-uuid",
"billingType": "PIX",
"confirmedDate": "2026-06-03"
}
}
```
### Eventos Críticos a Processar
* `PAYMENT_RECEIVED` / `PAYMENT_CONFIRMED`: Pagamento efetuado com sucesso.
* `PAYMENT_REFUNDED`: Pagamento estornado pelo painel ou pelo banco.
* `PAYMENT_OVERDUE`: Vencimento expirado sem quitação.
* `PAYMENT_DELETED`: Cobrança removida manualmente.
* `SUBSCRIPTION_DELETED` / `SUBSCRIPTION_CANCELLED`: Cancelamento de recorrência.
---
## 7. Estrutura do Código Implementado
### A. Camada de Serviço (`src/lib/asaas.ts`)
Fornece funções isoladas e parametrizadas que realizam as chamadas seguras para a API, respeitando a separação de chaves por inquilino (`tenant_id`).
### B. Handler de Webhook (`src/app/api/webhooks/asaas/route.ts`)
Endpoint público que recebe requisições do Asaas. Executa os seguintes passos:
1. Valida o token `asaas-access-token` contra o token do Tenant (se enviado com `?tenant_id=...`) ou contra o token mestre do sistema.
2. Cria e utiliza a tabela `asaas_webhook_events` para garantir **idempotência** (ignora eventos duplicados).
3. Atualiza o status financeiro local em uma transação segura (`BEGIN/COMMIT`).
---
## 8. Guia de Teste no Sandbox
1. **Configuração de Chaves:**
* Vá em [Asaas Sandbox](https://sandbox.asaas.com/) > Minha Conta > Integrações.
* Gere uma chave de API e insira o webhook apontando para a sua URL pública exposta pelo Ngrok/Cloudflare Tunnel (ex: `https://seu-dominio.trycloudflare.com/api/webhooks/asaas?tenant_id=UUID-DO-TENANT`).
2. **Simulação de Pagamentos:**
* Crie uma cobrança do tipo Pix/Boleto pela API.
* Acesse o painel de Sandbox do Asaas.
* Procure a cobrança emitida e clique em **Confirmar Recebimento** (Botão de teste que simula o pagamento do cliente).
3. **Validação de Banco de Dados:**
* Execute: `SELECT status, paid_at FROM payments WHERE id = 'seu-uuid';`
* Verifique se o status mudou para `completed` e a coluna `paid_at` foi preenchida corretamente.

413
database/init.sql Normal file
View File

@ -0,0 +1,413 @@
-- =============================================
-- AgendaPRO SaaS - Database Schema
-- PostgreSQL 16+
-- =============================================
CREATE EXTENSION IF NOT EXISTS "uuid-ossp";
CREATE EXTENSION IF NOT EXISTS "pgcrypto";
-- =============================================
-- 1. PLANOS DE ASSINATURA
-- =============================================
CREATE TABLE IF NOT EXISTS plans (
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
name VARCHAR(100) NOT NULL,
slug VARCHAR(100) UNIQUE NOT NULL,
description TEXT,
price_monthly NUMERIC(10,2) NOT NULL DEFAULT 0,
price_yearly NUMERIC(10,2) NOT NULL DEFAULT 0,
max_professionals INT NOT NULL DEFAULT 1,
max_services INT NOT NULL DEFAULT 5,
max_appointments_month INT DEFAULT NULL, -- NULL = unlimited
features JSONB DEFAULT '[]',
is_active BOOLEAN DEFAULT true,
created_at TIMESTAMPTZ DEFAULT NOW(),
updated_at TIMESTAMPTZ DEFAULT NOW()
);
-- =============================================
-- 2. TENANTS (ESTABELECIMENTOS)
-- =============================================
CREATE TABLE IF NOT EXISTS tenants (
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
plan_id UUID REFERENCES plans(id),
name VARCHAR(200) NOT NULL,
slug VARCHAR(200) UNIQUE NOT NULL,
business_type VARCHAR(50) NOT NULL DEFAULT 'barbearia', -- barbearia, manicure, massagem, estetica, outro
logo_url TEXT,
cover_url TEXT,
phone VARCHAR(20),
email VARCHAR(200),
address TEXT,
city VARCHAR(100),
state VARCHAR(2),
zip_code VARCHAR(10),
description TEXT,
settings JSONB DEFAULT '{
"timezone": "America/Sao_Paulo",
"currency": "BRL",
"booking_advance_min": 60,
"booking_advance_max": 43200,
"cancellation_limit_min": 120,
"allow_online_booking": true,
"require_confirmation": false,
"send_reminders": true,
"reminder_minutes_before": 60,
"theme_color": "#6C63FF",
"working_days": [1,2,3,4,5,6]
}',
subscription_status VARCHAR(20) DEFAULT 'trial', -- trial, active, past_due, cancelled, suspended
trial_ends_at TIMESTAMPTZ,
subscription_ends_at TIMESTAMPTZ,
is_active BOOLEAN DEFAULT true,
created_at TIMESTAMPTZ DEFAULT NOW(),
updated_at TIMESTAMPTZ DEFAULT NOW()
);
-- =============================================
-- 3. USUÁRIOS (OWNERS, ADMINS, PROFISSIONAIS)
-- =============================================
CREATE TABLE IF NOT EXISTS users (
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
tenant_id UUID REFERENCES tenants(id) ON DELETE CASCADE,
name VARCHAR(200) NOT NULL,
email VARCHAR(200) NOT NULL,
password_hash VARCHAR(255) NOT NULL,
phone VARCHAR(20),
avatar_url TEXT,
role VARCHAR(20) NOT NULL DEFAULT 'professional', -- owner, admin, professional
is_active BOOLEAN DEFAULT true,
last_login_at TIMESTAMPTZ,
created_at TIMESTAMPTZ DEFAULT NOW(),
updated_at TIMESTAMPTZ DEFAULT NOW(),
UNIQUE(email, tenant_id)
);
-- =============================================
-- 4. CATEGORIAS DE SERVIÇO
-- =============================================
CREATE TABLE IF NOT EXISTS service_categories (
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
tenant_id UUID REFERENCES tenants(id) ON DELETE CASCADE,
name VARCHAR(100) NOT NULL,
description TEXT,
icon VARCHAR(50),
sort_order INT DEFAULT 0,
is_active BOOLEAN DEFAULT true,
created_at TIMESTAMPTZ DEFAULT NOW()
);
-- =============================================
-- 5. SERVIÇOS
-- =============================================
CREATE TABLE IF NOT EXISTS services (
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
tenant_id UUID REFERENCES tenants(id) ON DELETE CASCADE,
category_id UUID REFERENCES service_categories(id) ON DELETE SET NULL,
name VARCHAR(200) NOT NULL,
description TEXT,
duration_minutes INT NOT NULL DEFAULT 30,
price NUMERIC(10,2) NOT NULL DEFAULT 0,
price_type VARCHAR(20) DEFAULT 'fixed', -- fixed, starting_at, per_hour
image_url TEXT,
is_active BOOLEAN DEFAULT true,
sort_order INT DEFAULT 0,
created_at TIMESTAMPTZ DEFAULT NOW(),
updated_at TIMESTAMPTZ DEFAULT NOW()
);
-- =============================================
-- 6. PROFISSIONAIS <-> SERVIÇOS
-- =============================================
CREATE TABLE IF NOT EXISTS professional_services (
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
professional_id UUID REFERENCES users(id) ON DELETE CASCADE,
service_id UUID REFERENCES services(id) ON DELETE CASCADE,
custom_price NUMERIC(10,2),
custom_duration INT,
commission_percent NUMERIC(5,2) DEFAULT 0,
UNIQUE(professional_id, service_id)
);
-- =============================================
-- 7. HORÁRIOS DE TRABALHO
-- =============================================
CREATE TABLE IF NOT EXISTS working_hours (
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
tenant_id UUID REFERENCES tenants(id) ON DELETE CASCADE,
professional_id UUID REFERENCES users(id) ON DELETE CASCADE,
day_of_week INT NOT NULL CHECK (day_of_week BETWEEN 0 AND 6), -- 0=domingo
start_time TIME NOT NULL,
end_time TIME NOT NULL,
break_start TIME,
break_end TIME,
is_active BOOLEAN DEFAULT true,
UNIQUE(professional_id, day_of_week)
);
-- =============================================
-- 8. BLOQUEIOS / FOLGAS
-- =============================================
CREATE TABLE IF NOT EXISTS schedule_blocks (
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
tenant_id UUID REFERENCES tenants(id) ON DELETE CASCADE,
professional_id UUID REFERENCES users(id) ON DELETE CASCADE,
title VARCHAR(200),
start_at TIMESTAMPTZ NOT NULL,
end_at TIMESTAMPTZ NOT NULL,
is_all_day BOOLEAN DEFAULT false,
recurrence VARCHAR(20) DEFAULT 'none', -- none, daily, weekly, monthly
created_at TIMESTAMPTZ DEFAULT NOW()
);
-- =============================================
-- 9. CLIENTES
-- =============================================
CREATE TABLE IF NOT EXISTS clients (
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
tenant_id UUID REFERENCES tenants(id) ON DELETE CASCADE,
name VARCHAR(200) NOT NULL,
email VARCHAR(200),
phone VARCHAR(20),
cpf VARCHAR(14),
birth_date DATE,
gender VARCHAR(20),
notes TEXT,
avatar_url TEXT,
total_visits INT DEFAULT 0,
total_spent NUMERIC(10,2) DEFAULT 0,
last_visit_at TIMESTAMPTZ,
tags JSONB DEFAULT '[]',
is_active BOOLEAN DEFAULT true,
created_at TIMESTAMPTZ DEFAULT NOW(),
updated_at TIMESTAMPTZ DEFAULT NOW()
);
-- =============================================
-- 10. AGENDAMENTOS
-- =============================================
CREATE TABLE IF NOT EXISTS appointments (
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
tenant_id UUID REFERENCES tenants(id) ON DELETE CASCADE,
client_id UUID REFERENCES clients(id) ON DELETE SET NULL,
professional_id UUID REFERENCES users(id) ON DELETE SET NULL,
service_id UUID REFERENCES services(id) ON DELETE SET NULL,
status VARCHAR(20) NOT NULL DEFAULT 'pending', -- pending, confirmed, in_progress, completed, cancelled, no_show
date DATE NOT NULL,
start_time TIME NOT NULL,
end_time TIME NOT NULL,
price NUMERIC(10,2) NOT NULL DEFAULT 0,
discount NUMERIC(10,2) DEFAULT 0,
notes TEXT,
client_name VARCHAR(200), -- for walk-ins
client_phone VARCHAR(20),
source VARCHAR(20) DEFAULT 'manual', -- manual, online, whatsapp
cancelled_at TIMESTAMPTZ,
cancellation_reason TEXT,
completed_at TIMESTAMPTZ,
reminder_sent BOOLEAN DEFAULT false,
created_at TIMESTAMPTZ DEFAULT NOW(),
updated_at TIMESTAMPTZ DEFAULT NOW()
);
-- =============================================
-- 11. PAGAMENTOS
-- =============================================
CREATE TABLE IF NOT EXISTS payments (
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
tenant_id UUID REFERENCES tenants(id) ON DELETE CASCADE,
appointment_id UUID REFERENCES appointments(id) ON DELETE SET NULL,
client_id UUID REFERENCES clients(id) ON DELETE SET NULL,
amount NUMERIC(10,2) NOT NULL,
method VARCHAR(30) NOT NULL DEFAULT 'cash', -- cash, credit, debit, pix, transfer
status VARCHAR(20) DEFAULT 'completed', -- pending, completed, refunded
notes TEXT,
paid_at TIMESTAMPTZ DEFAULT NOW(),
created_at TIMESTAMPTZ DEFAULT NOW()
);
-- =============================================
-- 12. ASSINATURAS SaaS
-- =============================================
CREATE TABLE IF NOT EXISTS subscriptions (
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
tenant_id UUID REFERENCES tenants(id) ON DELETE CASCADE,
plan_id UUID REFERENCES plans(id),
status VARCHAR(20) DEFAULT 'active', -- active, past_due, cancelled, suspended
billing_cycle VARCHAR(10) DEFAULT 'monthly', -- monthly, yearly
current_period_start TIMESTAMPTZ,
current_period_end TIMESTAMPTZ,
payment_method VARCHAR(30),
payment_gateway VARCHAR(30), -- stripe, asaas, manual
gateway_subscription_id VARCHAR(200),
amount NUMERIC(10,2),
last_payment_at TIMESTAMPTZ,
next_payment_at TIMESTAMPTZ,
cancelled_at TIMESTAMPTZ,
created_at TIMESTAMPTZ DEFAULT NOW(),
updated_at TIMESTAMPTZ DEFAULT NOW()
);
-- =============================================
-- 13. HISTÓRICO DE PAGAMENTOS SaaS
-- =============================================
CREATE TABLE IF NOT EXISTS subscription_payments (
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
subscription_id UUID REFERENCES subscriptions(id) ON DELETE CASCADE,
tenant_id UUID REFERENCES tenants(id) ON DELETE CASCADE,
amount NUMERIC(10,2) NOT NULL,
status VARCHAR(20) DEFAULT 'paid', -- paid, failed, refunded
gateway_payment_id VARCHAR(200),
paid_at TIMESTAMPTZ,
created_at TIMESTAMPTZ DEFAULT NOW()
);
-- =============================================
-- 14. NOTIFICAÇÕES
-- =============================================
CREATE TABLE IF NOT EXISTS notifications (
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
tenant_id UUID REFERENCES tenants(id) ON DELETE CASCADE,
user_id UUID REFERENCES users(id) ON DELETE CASCADE,
type VARCHAR(50) NOT NULL,
title VARCHAR(200) NOT NULL,
message TEXT,
data JSONB DEFAULT '{}',
is_read BOOLEAN DEFAULT false,
created_at TIMESTAMPTZ DEFAULT NOW()
);
-- =============================================
-- INDEXES
-- =============================================
CREATE INDEX IF NOT EXISTS idx_appointments_tenant_date ON appointments(tenant_id, date);
CREATE INDEX IF NOT EXISTS idx_appointments_professional ON appointments(professional_id, date);
CREATE INDEX IF NOT EXISTS idx_appointments_client ON appointments(client_id);
CREATE INDEX IF NOT EXISTS idx_appointments_status ON appointments(status);
CREATE INDEX IF NOT EXISTS idx_clients_tenant ON clients(tenant_id);
CREATE INDEX IF NOT EXISTS idx_clients_phone ON clients(phone);
CREATE INDEX IF NOT EXISTS idx_services_tenant ON services(tenant_id);
CREATE INDEX IF NOT EXISTS idx_payments_tenant ON payments(tenant_id);
CREATE INDEX IF NOT EXISTS idx_notifications_user ON notifications(user_id, is_read);
CREATE INDEX IF NOT EXISTS idx_users_tenant ON users(tenant_id);
CREATE INDEX IF NOT EXISTS idx_tenants_slug ON tenants(slug);
-- =============================================
-- SEED: DEFAULT PLANS
-- =============================================
INSERT INTO plans (name, slug, description, price_monthly, price_yearly, max_professionals, max_services, max_appointments_month, features) VALUES
('Starter', 'starter', 'Ideal para profissionais autônomos', 49.90, 479.00, 1, 10, 200, '["Agendamento online", "Página de reservas", "Lembretes por e-mail", "Relatórios básicos"]'),
('Professional', 'professional', 'Para pequenos estabelecimentos', 99.90, 959.00, 5, 30, NULL, '["Tudo do Starter", "Múltiplos profissionais", "Comissionamento", "Relatórios avançados", "Personalização da marca"]'),
('Business', 'business', 'Para estabelecimentos em crescimento', 199.90, 1919.00, 15, 100, NULL, '["Tudo do Professional", "API de integração", "Suporte prioritário", "Multi-unidades", "Automações avançadas"]'),
('Enterprise', 'enterprise', 'Solução completa e personalizada', 399.90, 3839.00, 999, 999, NULL, '["Tudo do Business", "Gerente de conta dedicado", "SLA garantido", "Integrações customizadas", "White-label"]')
ON CONFLICT (slug) DO NOTHING;
-- =============================================
-- SEED: DEMO TENANT
-- =============================================
DO $$
DECLARE
v_plan_id UUID;
v_tenant_id UUID;
v_owner_id UUID;
v_cat_barba_id UUID;
v_cat_cabelo_id UUID;
v_cat_combo_id UUID;
v_svc1_id UUID;
v_svc2_id UUID;
v_svc3_id UUID;
v_svc4_id UUID;
v_svc5_id UUID;
v_prof1_id UUID;
v_prof2_id UUID;
BEGIN
SELECT id INTO v_plan_id FROM plans WHERE slug = 'professional';
INSERT INTO tenants (id, plan_id, name, slug, business_type, phone, email, description, subscription_status, trial_ends_at)
VALUES (
uuid_generate_v4(), v_plan_id, 'Barbearia Premium', 'barbearia-premium', 'barbearia',
'(11) 99999-1234', 'contato@barbeariapremium.com.br',
'A melhor barbearia da cidade. Cortes modernos, barbas impecáveis e ambiente premium.',
'active', NOW() + INTERVAL '30 days'
)
ON CONFLICT (slug) DO NOTHING
RETURNING id INTO v_tenant_id;
IF v_tenant_id IS NOT NULL THEN
-- Owner
INSERT INTO users (id, tenant_id, name, email, password_hash, role)
VALUES (uuid_generate_v4(), v_tenant_id, 'Admin Demo', 'admin@demo.com', crypt('admin123', gen_salt('bf')), 'owner')
RETURNING id INTO v_owner_id;
-- Professionals
INSERT INTO users (id, tenant_id, name, email, password_hash, role, phone)
VALUES (uuid_generate_v4(), v_tenant_id, 'Carlos Silva', 'carlos@demo.com', crypt('prof123', gen_salt('bf')), 'professional', '(11) 98888-1111')
RETURNING id INTO v_prof1_id;
INSERT INTO users (id, tenant_id, name, email, password_hash, role, phone)
VALUES (uuid_generate_v4(), v_tenant_id, 'Rafael Santos', 'rafael@demo.com', crypt('prof123', gen_salt('bf')), 'professional', '(11) 98888-2222')
RETURNING id INTO v_prof2_id;
-- Categories
INSERT INTO service_categories (id, tenant_id, name, icon, sort_order) VALUES
(uuid_generate_v4(), v_tenant_id, 'Cabelo', 'scissors', 1) RETURNING id INTO v_cat_cabelo_id;
INSERT INTO service_categories (id, tenant_id, name, icon, sort_order) VALUES
(uuid_generate_v4(), v_tenant_id, 'Barba', 'sparkles', 2) RETURNING id INTO v_cat_barba_id;
INSERT INTO service_categories (id, tenant_id, name, icon, sort_order) VALUES
(uuid_generate_v4(), v_tenant_id, 'Combos', 'star', 3) RETURNING id INTO v_cat_combo_id;
-- Services
INSERT INTO services (id, tenant_id, category_id, name, description, duration_minutes, price, sort_order) VALUES
(uuid_generate_v4(), v_tenant_id, v_cat_cabelo_id, 'Corte Masculino', 'Corte moderno com lavagem e finalização', 40, 55.00, 1) RETURNING id INTO v_svc1_id;
INSERT INTO services (id, tenant_id, category_id, name, description, duration_minutes, price, sort_order) VALUES
(uuid_generate_v4(), v_tenant_id, v_cat_cabelo_id, 'Corte Infantil', 'Corte para crianças até 12 anos', 30, 40.00, 2) RETURNING id INTO v_svc2_id;
INSERT INTO services (id, tenant_id, category_id, name, description, duration_minutes, price, sort_order) VALUES
(uuid_generate_v4(), v_tenant_id, v_cat_barba_id, 'Barba Completa', 'Aparar, modelar e hidratação', 30, 40.00, 3) RETURNING id INTO v_svc3_id;
INSERT INTO services (id, tenant_id, category_id, name, description, duration_minutes, price, sort_order) VALUES
(uuid_generate_v4(), v_tenant_id, v_cat_barba_id, 'Design de Sobrancelha', 'Alinhamento com navalha', 15, 25.00, 4) RETURNING id INTO v_svc4_id;
INSERT INTO services (id, tenant_id, category_id, name, description, duration_minutes, price, sort_order) VALUES
(uuid_generate_v4(), v_tenant_id, v_cat_combo_id, 'Combo Corte + Barba', 'Corte masculino + barba completa', 60, 85.00, 5) RETURNING id INTO v_svc5_id;
-- Professional <-> Services
INSERT INTO professional_services (professional_id, service_id, commission_percent) VALUES
(v_prof1_id, v_svc1_id, 40), (v_prof1_id, v_svc2_id, 40), (v_prof1_id, v_svc3_id, 40),
(v_prof1_id, v_svc4_id, 40), (v_prof1_id, v_svc5_id, 40),
(v_prof2_id, v_svc1_id, 35), (v_prof2_id, v_svc3_id, 35), (v_prof2_id, v_svc5_id, 35);
-- Working Hours (Mon-Sat for both professionals)
FOR i IN 1..6 LOOP
INSERT INTO working_hours (tenant_id, professional_id, day_of_week, start_time, end_time, break_start, break_end) VALUES
(v_tenant_id, v_prof1_id, i, '09:00', '19:00', '12:00', '13:00'),
(v_tenant_id, v_prof2_id, i, '10:00', '20:00', '13:00', '14:00');
END LOOP;
-- Sample Clients
INSERT INTO clients (tenant_id, name, email, phone, total_visits) VALUES
(v_tenant_id, 'João Mendes', 'joao@email.com', '(11) 97777-1111', 12),
(v_tenant_id, 'Pedro Oliveira', 'pedro@email.com', '(11) 97777-2222', 8),
(v_tenant_id, 'Lucas Ferreira', 'lucas@email.com', '(11) 97777-3333', 5),
(v_tenant_id, 'Mateus Costa', 'mateus@email.com', '(11) 97777-4444', 3),
(v_tenant_id, 'Gabriel Souza', 'gabriel@email.com', '(11) 97777-5555', 15);
-- Sample appointments for today
INSERT INTO appointments (tenant_id, client_id, professional_id, service_id, status, date, start_time, end_time, price, source)
SELECT v_tenant_id, c.id, v_prof1_id, v_svc1_id, 'confirmed', CURRENT_DATE, '10:00', '10:40', 55.00, 'online'
FROM clients c WHERE c.name = 'João Mendes' AND c.tenant_id = v_tenant_id LIMIT 1;
INSERT INTO appointments (tenant_id, client_id, professional_id, service_id, status, date, start_time, end_time, price, source)
SELECT v_tenant_id, c.id, v_prof1_id, v_svc5_id, 'confirmed', CURRENT_DATE, '11:00', '12:00', 85.00, 'manual'
FROM clients c WHERE c.name = 'Pedro Oliveira' AND c.tenant_id = v_tenant_id LIMIT 1;
INSERT INTO appointments (tenant_id, client_id, professional_id, service_id, status, date, start_time, end_time, price, source)
SELECT v_tenant_id, c.id, v_prof2_id, v_svc3_id, 'pending', CURRENT_DATE, '14:00', '14:30', 40.00, 'online'
FROM clients c WHERE c.name = 'Lucas Ferreira' AND c.tenant_id = v_tenant_id LIMIT 1;
INSERT INTO appointments (tenant_id, client_id, professional_id, service_id, status, date, start_time, end_time, price, source)
SELECT v_tenant_id, c.id, v_prof2_id, v_svc1_id, 'completed', CURRENT_DATE, '10:00', '10:40', 55.00, 'manual'
FROM clients c WHERE c.name = 'Gabriel Souza' AND c.tenant_id = v_tenant_id LIMIT 1;
-- Subscription record
INSERT INTO subscriptions (tenant_id, plan_id, status, billing_cycle, current_period_start, current_period_end, amount, next_payment_at)
VALUES (v_tenant_id, v_plan_id, 'active', 'monthly', NOW(), NOW() + INTERVAL '30 days', 99.90, NOW() + INTERVAL '30 days');
END IF;
END $$;

91
deploy.sh Normal file
View File

@ -0,0 +1,91 @@
#!/bin/bash
# ================================================================
# AgendaPRO — Deploy Script para Docker Swarm via SSH
# Uso: ./deploy.sh [user@host] [dominio-app] [dominio-admin]
# Ex: ./deploy.sh root@vps.meusite.com app.agendapro.com.br admin.agendapro.com.br
# ================================================================
set -e
SSH_TARGET=${1:-"root@your-server.com"}
APP_DOMAIN=${2:-"app.agendapro.com.br"}
ADMIN_DOMAIN=${3:-"admin.agendapro.com.br"}
STACK_NAME="agendapro"
echo "🚀 AgendaPRO — Deploy para Docker Swarm"
echo "=========================================="
echo " Servidor: $SSH_TARGET"
echo " App URL: https://$APP_DOMAIN"
echo " Admin URL: https://$ADMIN_DOMAIN"
echo "=========================================="
# 1. Build das imagens
echo ""
echo "📦 [1/6] Construindo imagens Docker..."
docker build -t agendapro-app:latest .
docker build -t agendapro-admin:latest ./admin
# 2. Salvar imagens para transferência
echo ""
echo "💾 [2/6] Exportando imagens..."
docker save agendapro-app:latest | gzip > /tmp/agendapro-app.tar.gz
docker save agendapro-admin:latest | gzip > /tmp/agendapro-admin.tar.gz
# 3. Transferir via SSH
echo ""
echo "📤 [3/6] Transferindo para o servidor..."
scp /tmp/agendapro-app.tar.gz $SSH_TARGET:/tmp/
scp /tmp/agendapro-admin.tar.gz $SSH_TARGET:/tmp/
scp docker-compose.yml $SSH_TARGET:/opt/agendapro/
scp database/init.sql $SSH_TARGET:/opt/agendapro/database/
# 4. Carregar imagens no servidor
echo ""
echo "📥 [4/6] Carregando imagens no servidor..."
ssh $SSH_TARGET "docker load < /tmp/agendapro-app.tar.gz && docker load < /tmp/agendapro-admin.tar.gz"
# 5. Criar secrets (se não existirem)
echo ""
echo "🔐 [5/6] Verificando Docker Secrets..."
ssh $SSH_TARGET "
docker swarm init 2>/dev/null || true
docker network create --driver overlay webproxy 2>/dev/null || true
if ! docker secret inspect db_password > /dev/null 2>&1; then
echo \$(openssl rand -base64 32) | docker secret create db_password -
echo ' ✓ Secret db_password criado'
fi
if ! docker secret inspect jwt_secret > /dev/null 2>&1; then
echo \$(openssl rand -base64 64) | docker secret create jwt_secret -
echo ' ✓ Secret jwt_secret criado'
fi
if ! docker secret inspect admin_secret > /dev/null 2>&1; then
echo \$(openssl rand -base64 64) | docker secret create admin_secret -
echo ' ✓ Secret admin_secret criado'
fi
"
# 6. Deploy da stack
echo ""
echo "🚀 [6/6] Fazendo deploy da stack..."
ssh $SSH_TARGET "
cd /opt/agendapro
# Substituir domínios no compose
sed -i 's/app.agendapro.com.br/$APP_DOMAIN/g' docker-compose.yml
sed -i 's/admin.agendapro.com.br/$ADMIN_DOMAIN/g' docker-compose.yml
docker stack deploy -c docker-compose.yml $STACK_NAME
"
echo ""
echo "✅ Deploy concluído com sucesso!"
echo ""
echo " 🌐 AgendaPRO App: https://$APP_DOMAIN"
echo " 🔧 Admin Panel: https://$ADMIN_DOMAIN"
echo " 📊 Traefik Dashboard: https://traefik.$APP_DOMAIN"
echo ""
echo " Comandos úteis:"
echo " ssh $SSH_TARGET 'docker service ls'"
echo " ssh $SSH_TARGET 'docker service logs ${STACK_NAME}_agendapro'"
echo " ssh $SSH_TARGET 'docker service logs ${STACK_NAME}_admin'"
echo ""

156
docker-compose.yml Normal file
View File

@ -0,0 +1,156 @@
version: "3.8"
services:
# =============================================================
# TRAEFIK — Reverse Proxy & SSL (Let's Encrypt)
# =============================================================
traefik:
image: traefik:v2.11
command:
- "--api.dashboard=true"
- "--providers.docker=true"
- "--providers.docker.swarmMode=true"
- "--providers.docker.exposedbydefault=false"
- "--entrypoints.web.address=:80"
- "--entrypoints.websecure.address=:443"
- "--certificatesresolvers.letsencrypt.acme.httpchallenge.entrypoint=web"
- "--certificatesresolvers.letsencrypt.acme.email=admin@agendapro.com.br"
- "--certificatesresolvers.letsencrypt.acme.storage=/letsencrypt/acme.json"
- "--entrypoints.web.http.redirections.entrypoint.to=websecure"
- "--entrypoints.web.http.redirections.entrypoint.scheme=https"
ports:
- "80:80"
- "443:443"
volumes:
- "/var/run/docker.sock:/var/run/docker.sock:ro"
- "traefik-certs:/letsencrypt"
networks:
- webproxy
deploy:
replicas: 1
placement:
constraints: [node.role == manager]
labels:
- "traefik.enable=true"
- "traefik.http.routers.traefik.rule=Host(`traefik.agendapro.com.br`)"
- "traefik.http.routers.traefik.entrypoints=websecure"
- "traefik.http.routers.traefik.tls.certresolver=letsencrypt"
- "traefik.http.routers.traefik.service=api@internal"
- "traefik.http.services.traefik.loadbalancer.server.port=8080"
# =============================================================
# POSTGRESQL — Banco de Dados Compartilhado
# =============================================================
postgres:
image: postgres:16-alpine
environment:
POSTGRES_DB: agendapro
POSTGRES_USER: agendapro_user
POSTGRES_PASSWORD_FILE: /run/secrets/db_password
secrets:
- db_password
volumes:
- pgdata:/var/lib/postgresql/data
- ./database/init.sql:/docker-entrypoint-initdb.d/01-init.sql
networks:
- backend
deploy:
replicas: 1
placement:
constraints: [node.role == manager]
# =============================================================
# AGENDAPRO APP — Sistema Principal (app.agendapro.com.br)
# =============================================================
agendapro:
image: agendapro-app:latest
build:
context: .
dockerfile: Dockerfile
environment:
DATABASE_URL: postgresql://agendapro_user:${DB_PASSWORD}@postgres:5432/agendapro
JWT_SECRET_FILE: /run/secrets/jwt_secret
NODE_ENV: production
secrets:
- jwt_secret
networks:
- webproxy
- backend
deploy:
replicas: 2
update_config:
parallelism: 1
delay: 10s
order: start-first
rollback_config:
parallelism: 1
restart_policy:
condition: on-failure
delay: 5s
max_attempts: 3
labels:
- "traefik.enable=true"
- "traefik.http.routers.agendapro.rule=Host(`app.agendapro.com.br`)"
- "traefik.http.routers.agendapro.entrypoints=websecure"
- "traefik.http.routers.agendapro.tls.certresolver=letsencrypt"
- "traefik.http.services.agendapro.loadbalancer.server.port=3000"
- "traefik.http.services.agendapro.loadbalancer.healthcheck.path=/api/health"
- "traefik.http.services.agendapro.loadbalancer.healthcheck.interval=15s"
# =============================================================
# ADMIN PANEL — Gerenciador de Assinaturas (admin.agendapro.com.br)
# =============================================================
admin:
image: agendapro-admin:latest
build:
context: ./admin
dockerfile: Dockerfile
environment:
DATABASE_URL: postgresql://agendapro_user:${DB_PASSWORD}@postgres:5432/agendapro
JWT_SECRET_FILE: /run/secrets/jwt_secret
ADMIN_SECRET_FILE: /run/secrets/admin_secret
NODE_ENV: production
secrets:
- jwt_secret
- admin_secret
networks:
- webproxy
- backend
deploy:
replicas: 1
update_config:
parallelism: 1
delay: 10s
restart_policy:
condition: on-failure
delay: 5s
max_attempts: 3
labels:
- "traefik.enable=true"
- "traefik.http.routers.admin.rule=Host(`admin.agendapro.com.br`)"
- "traefik.http.routers.admin.entrypoints=websecure"
- "traefik.http.routers.admin.tls.certresolver=letsencrypt"
- "traefik.http.services.admin.loadbalancer.server.port=3001"
# Proteção extra: Basic Auth no admin
- "traefik.http.routers.admin.middlewares=admin-auth"
- "traefik.http.middlewares.admin-auth.basicauth.users=admin:$$apr1$$xyz$$hashedpassword"
volumes:
pgdata:
traefik-certs:
networks:
webproxy:
driver: overlay
external: false
backend:
driver: overlay
internal: true
secrets:
db_password:
external: true
jwt_secret:
external: true
admin_secret:
external: true

60
gemini.md Normal file
View File

@ -0,0 +1,60 @@
# Registro de Modificações - Gemini (AgendaPRO)
Este arquivo registra todas as alterações, novos arquivos criados e configurações de dependências feitas no projeto **AgendaPRO** para a integração do gateway Asaas e controle de acesso por assinatura.
---
## 1. Arquivos Criados
### A. `src/lib/asaas.ts`
Implementação do cliente de API modular do Asaas (v3) para sandbox e produção:
* Registro de clientes (`createCustomer`).
* Geração de cobranças avulsas (`createPayment`).
* Busca de QR Code e Linha Digitável do Pix (`getPixQrCode`).
* Gerenciamento de Assinaturas recorrentes (`createSubscription` e `cancelSubscription`).
### B. `src/app/api/webhooks/asaas/route.ts`
Endpoint de recebimento de notificações do Asaas:
* Validação do cabeçalho `asaas-access-token`.
* Idempotência via tabela `asaas_webhook_events`.
* Atualização automatizada de status de agendamentos (`payments` e `appointments`) e assinaturas dos inquilinos (`subscriptions`).
### C. `src/app/api/tenant/subscription/route.ts`
Endpoint para controle de fluxo de planos:
* `GET`: Consulta do plano ativo do tenant, validade e lista de planos disponíveis (com auto-seeding de planos Starter, Professional, Business e Enterprise).
* `POST`: Criação e cobrança de novas assinaturas via Pix, Boleto ou Cartão de Crédito integrados ao Asaas.
### D. `src/app/api/tenant/subscription/simulate/route.ts`
Endpoint de desenvolvimento e testes em sandbox:
* `POST`: Permite forçar instantaneamente o status do tenant como `active` (desbloqueado) ou `inactive` (bloqueado) sem depender do envio externo de webhooks.
### E. `asaas_skill.md`
Manual de referência contendo todas as chaves, rotas, payloads de integração e procedimentos de testes no ambiente Sandbox do Asaas.
---
## 2. Arquivos Modificados
### A. `src/app/page.tsx`
* Adicionado carregamento de status do plano no carregamento inicial da página.
* Enforcamento de bloqueio: inquilinos com assinatura pendente ou vencida são forçados a visualizar apenas a aba de assinatura.
* Adicionado loader de transição visual durante a verificação de acesso.
### B. `src/components/Sidebar.tsx`
* Suporte ao parâmetro `isLocked`.
* Desativação visual (opacidade reduzida e ícone de cadeado `🔒`) dos menus de controle (Dashboard, Agenda, Clientes, Serviços, Equipe, Configurações) quando o acesso do inquilino está bloqueado por falta de pagamento.
### C. `src/components/Subscription.tsx`
* Exibição de banner de alerta vermelho quando o acesso está bloqueado.
* Seleção visual interativa do plano desejado e do meio de pagamento (Pix, Boleto, Cartão de Crédito).
* Formulário de preenchimento de dados de cartão.
* Retorno com QR Code visual para Pix ou link para download do boleto.
* Botão **"Simular Pagamento Webhook"** integrado para desbloqueio instantâneo do sistema em ambiente local.
### D. `tsconfig.json`
* Adicionada a exclusão da pasta `admin` (`"admin"`) na verificação de tipos do tsconfig principal para resolver conflitos de paths entre as duas subaplicações.
---
## 3. Dependências Instaladas
* `@types/pg`: Instalado como dependência de desenvolvimento (`npm i --save-dev @types/pg`) para garantir tipagem estática e evitar erros de compilação em consultas ao banco PostgreSQL.

58
memory.md Normal file
View File

@ -0,0 +1,58 @@
# Contexto de Memória - AgendaPRO SaaS
Este arquivo contém o histórico arquitetural e as decisões do sistema **AgendaPRO** para garantir consistência em futuras sessões de desenvolvimento.
> [!IMPORTANT]
> O **AgendaPRO** é um projeto totalmente separado e independente do EduManager. O banco de dados e os recursos do AgendaPRO não devem ter nenhuma relação com o banco de dados do EduManager.
---
## 1. Estrutura do Banco de Dados (AgendaPRO)
O banco de dados oficial do AgendaPRO é estruturado para multi-inquilinato (multi-tenant) usando PostgreSQL. Abaixo estão as tabelas principais mapeadas:
* **`tenants`**: Cadastro de inquilinos/estabelecimentos.
* `id` (UUID, PK)
* `name` (VARCHAR)
* `slug` (VARCHAR, Unique) - Identificador na URL (ex: `barbearia-premium`)
* `subscription_status` (VARCHAR) - Status da assinatura (`active`, `pending`, `past_due`, `cancelled`, `suspended`)
* `subscription_ends_at` (TIMESTAMPTZ) - Data limite de validade do plano
* `settings` (JSONB) - Configurações gerais (fuso horário, chaves integradas de gateways de pagamento)
* **`plans`**: Planos de assinatura disponíveis na plataforma.
* `id` (UUID, PK)
* `name` (VARCHAR)
* `price` (NUMERIC)
* `features` (TEXT[])
* `slug` (VARCHAR)
* **`subscriptions`**: Vinculo do tenant ao plano.
* `id` (UUID, PK)
* `tenant_id` (UUID, FK -> tenants)
* `plan_id` (UUID, FK -> plans)
* `status` (VARCHAR)
* `gateway_subscription_id` (VARCHAR) - ID da assinatura gerada no Asaas
* `amount` (NUMERIC)
* **`subscription_payments`**: Transações e faturas do plano de assinatura.
* `id` (UUID, PK)
* `subscription_id` (UUID, FK)
* `tenant_id` (UUID, FK)
* `amount` (NUMERIC)
* `status` (VARCHAR)
* `gateway_payment_id` (VARCHAR)
* **`payments`**: Cobranças avulsas emitidas pelos inquilinos para os seus clientes (agendamentos).
* `id` (UUID, PK)
* `tenant_id` (UUID, FK)
* `external_id` (VARCHAR) - Mapeamento com ID do Asaas (`pay_...`)
* `status` (VARCHAR) - (`pending`, `completed`, `refunded`, `failed`)
* `amount` (NUMERIC)
* `appointment_id` (UUID)
---
## 2. Estrutura do Código e Separação de Projetos
* **Aplicação Principal (Painel do Inquilino & Agendamento do Cliente):** Localizada na raiz do projeto (`src/...`).
* **Painel Administrativo da Plataforma (Master Admin):** Localizado na subpasta `/admin`. Possui dockerfile próprio e gerencia todos os inquilinos e faturamento global da plataforma.
---
## 3. Isolamento e Segurança
* Toda requisição feita de forma administrativa para cobrar os inquilinos usa a credencial mestre do AgendaPRO (`process.env.ASAAS_API_KEY`).
* Configurações específicas dos clientes dos inquilinos (ex: Pix do salão ou barbearia) são carregadas do campo `settings` da tabela `tenants`.

6
next-env.d.ts vendored Normal file
View File

@ -0,0 +1,6 @@
/// <reference types="next" />
/// <reference types="next/image-types/global" />
/// <reference path="./.next/types/routes.d.ts" />
// NOTE: This file should not be edited
// see https://nextjs.org/docs/app/api-reference/config/typescript for more information.

7
next.config.ts Normal file
View File

@ -0,0 +1,7 @@
import type { NextConfig } from "next";
const nextConfig: NextConfig = {
output: "standalone",
};
export default nextConfig;

1288
package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

29
package.json Normal file
View File

@ -0,0 +1,29 @@
{
"name": "agendapro",
"version": "1.0.0",
"private": true,
"scripts": {
"dev": "next dev",
"build": "next build",
"start": "next start",
"lint": "next lint"
},
"dependencies": {
"@types/node": "^22.15.21",
"@types/react": "^19.1.4",
"@types/react-dom": "^19.1.5",
"bcryptjs": "^3.0.2",
"date-fns": "^4.1.0",
"jsonwebtoken": "^9.0.2",
"lucide-react": "^0.511.0",
"next": "^15.3.3",
"pg": "^8.16.0",
"react": "^19.1.0",
"react-dom": "^19.1.0",
"typescript": "^5.8.3",
"uuid": "^11.1.0"
},
"devDependencies": {
"@types/pg": "^8.20.0"
}
}

View File

@ -0,0 +1,400 @@
'use client';
import { useState, useEffect, use } from 'react';
import { Calendar, Clock, User, Phone, Mail, ChevronLeft, ChevronRight, Check, Scissors, MapPin, Star } from 'lucide-react';
const establishment = {
name: 'Barbearia Premium',
type: 'barbearia',
description: 'A melhor barbearia da cidade. Cortes modernos, barbas impecáveis e ambiente premium.',
phone: '(11) 99999-1234',
address: 'Rua das Flores, 123 - Centro, São Paulo - SP',
rating: 4.9,
reviews: 127,
};
const services = [
{ id: '1', category: 'Cabelo', name: 'Corte Masculino', desc: 'Corte moderno com lavagem e finalização', duration: 40, price: 55 },
{ id: '2', category: 'Cabelo', name: 'Corte Infantil', desc: 'Corte para crianças até 12 anos', duration: 30, price: 40 },
{ id: '3', category: 'Barba', name: 'Barba Completa', desc: 'Aparar, modelar e hidratação', duration: 30, price: 40 },
{ id: '4', category: 'Barba', name: 'Design de Sobrancelha', desc: 'Alinhamento com navalha', duration: 15, price: 25 },
{ id: '5', category: 'Combos', name: 'Combo Corte + Barba', desc: 'Corte masculino + barba completa', duration: 60, price: 85 },
];
const professionals = [
{ id: '1', name: 'Carlos Silva', role: 'Barbeiro Senior', rating: 4.8 },
{ id: '2', name: 'Rafael Santos', role: 'Barbeiro', rating: 4.6 },
];
export default function PublicBookingPage({ params }: { params: Promise<{ slug: string }> }) {
const { slug } = use(params);
const [step, setStep] = useState(1);
const [selectedService, setSelectedService] = useState<string | null>(null);
const [selectedProfessional, setSelectedProfessional] = useState<string | null>(null);
const [selectedDate, setSelectedDate] = useState<string>('');
const [selectedTime, setSelectedTime] = useState<string | null>(null);
const [clientName, setClientName] = useState('');
const [clientPhone, setClientPhone] = useState('');
const [clientEmail, setClientEmail] = useState('');
const [booked, setBooked] = useState(false);
const [slots, setSlots] = useState<string[]>([]);
// Carrega e gera os slots dinâmicos do barbeiro
useEffect(() => {
const getCookie = (name: string): string => {
if (typeof document === 'undefined') return '';
const value = `; ${document.cookie}`;
const parts = value.split(`; ${name}=`);
if (parts.length === 2) return parts.pop()!.split(';').shift() || '';
return '';
};
const start = getCookie(`agendapro_start_time_${slug}`) || localStorage.getItem(`agendapro_start_time_${slug}`) || '09:00';
const end = getCookie(`agendapro_end_time_${slug}`) || localStorage.getItem(`agendapro_end_time_${slug}`) || '19:00';
const interval = Number(getCookie(`agendapro_interval_${slug}`) || localStorage.getItem(`agendapro_interval_${slug}`) || '30');
// Algoritmo dinâmico de fatiamento de grade
const generateSlots = (startTimeStr: string, endTimeStr: string, intervalMin: number) => {
const generated = [];
let [sh, sm] = startTimeStr.split(':').map(Number);
const [eh, em] = endTimeStr.split(':').map(Number);
let currentMinutes = sh * 60 + sm;
const endMinutes = eh * 60 + em;
while (currentMinutes < endMinutes) {
const h = Math.floor(currentMinutes / 60);
const m = currentMinutes % 60;
const timeStr = `${String(h).padStart(2, '0')}:${String(m).padStart(2, '0')}`;
generated.push(timeStr);
currentMinutes += intervalMin;
}
return generated;
};
setSlots(generateSlots(start, end, interval));
}, [slug]);
const today = new Date();
const dates = Array.from({ length: 14 }, (_, i) => {
const d = new Date(today);
d.setDate(d.getDate() + i);
return d;
});
const svc = services.find(s => s.id === selectedService);
const prof = professionals.find(p => p.id === selectedProfessional);
const canNext = () => {
if (step === 1) return !!selectedService;
if (step === 2) return !!selectedProfessional;
if (step === 3) return !!selectedDate && !!selectedTime;
if (step === 4) return clientName.trim() && clientPhone.trim();
return false;
};
const handleBook = () => {
// 1. Monta o objeto de agendamento no formato esperado pela Agenda
const [startHour, startMin] = selectedTime ? selectedTime.split(':').map(Number) : [9, 0];
const newAppointment = {
id: Date.now().toString(),
professionalId: selectedProfessional || '1',
client: clientName,
phone: clientPhone,
email: clientEmail,
service: svc?.name || 'Serviço',
price: svc?.price || 0,
duration: svc?.duration || 30,
startHour,
startMin,
status: 'pending', // Inicia pendente de aprovação!
date: selectedDate, // Data do agendamento (ex: "2026-05-31")
};
// 2. Salva no localStorage com isolamento por slug (Tenant Isolation)
const key = `agendapro_appointments_${slug}`;
const existing = localStorage.getItem(key);
let currentList = [];
if (existing) {
currentList = JSON.parse(existing);
} else {
// Se não existia e for barbearia premium demo, pega os mockAppointments padrão
currentList = slug === 'barbearia-premium' ? [
{ id: '1', professionalId: '1', client: 'João Mendes', service: 'Corte Masculino', startHour: 10, startMin: 0, duration: 40, status: 'confirmed', price: 55, date: new Date().toISOString().split('T')[0] },
{ id: '2', professionalId: '1', client: 'Pedro Oliveira', service: 'Combo Corte + Barba', startHour: 11, startMin: 0, duration: 60, status: 'confirmed', price: 85, date: new Date().toISOString().split('T')[0] },
{ id: '3', professionalId: '2', client: 'Lucas Ferreira', service: 'Barba Completa', startHour: 14, startMin: 0, duration: 30, status: 'pending', price: 40, date: new Date().toISOString().split('T')[0] },
{ id: '4', professionalId: '2', client: 'Gabriel Souza', service: 'Corte Masculino', startHour: 10, startMin: 0, duration: 40, status: 'completed', price: 55, date: new Date().toISOString().split('T')[0] },
{ id: '5', professionalId: '1', client: 'Mateus Costa', service: 'Design de Sobrancelha', startHour: 15, startMin: 30, duration: 15, status: 'confirmed', price: 25, date: new Date().toISOString().split('T')[0] }
] : [];
}
currentList.push(newAppointment);
localStorage.setItem(key, JSON.stringify(currentList));
setBooked(true);
};
if (booked) {
return (
<div style={{ minHeight: '100vh', background: 'var(--bg-primary)', display: 'flex', alignItems: 'center', justifyContent: 'center', padding: '20px' }}>
<div style={{ textAlign: 'center', maxWidth: '480px', animation: 'slideUp 0.5s ease' }}>
<div style={{
width: 80, height: 80, borderRadius: '50%', background: 'var(--success-bg)', display: 'flex',
alignItems: 'center', justifyContent: 'center', margin: '0 auto 24px', border: '3px solid var(--success)'
}}>
<Check size={40} color="var(--success)" />
</div>
<h1 style={{ fontSize: '28px', fontWeight: 800, marginBottom: '12px' }}>Agendamento Confirmado! 🎉</h1>
<p style={{ color: 'var(--text-secondary)', fontSize: '16px', marginBottom: '32px' }}>
Seu horário foi reservado com sucesso.
</p>
<div style={{
background: 'var(--bg-card)', border: '1px solid var(--border-color)',
borderRadius: 'var(--radius-lg)', padding: '24px', textAlign: 'left'
}}>
<div style={{ display: 'grid', gap: '16px' }}>
<div><span style={{ fontSize: '12px', color: 'var(--text-muted)', textTransform: 'uppercase' }}>Serviço</span><div style={{ fontWeight: 600 }}>{svc?.name}</div></div>
<div><span style={{ fontSize: '12px', color: 'var(--text-muted)', textTransform: 'uppercase' }}>Profissional</span><div style={{ fontWeight: 600 }}>{prof?.name}</div></div>
<div className="grid-2">
<div><span style={{ fontSize: '12px', color: 'var(--text-muted)', textTransform: 'uppercase' }}>Data</span><div style={{ fontWeight: 600 }}>{selectedDate ? new Date(selectedDate + 'T12:00:00').toLocaleDateString('pt-BR', { weekday: 'long', day: 'numeric', month: 'long' }) : ''}</div></div>
<div><span style={{ fontSize: '12px', color: 'var(--text-muted)', textTransform: 'uppercase' }}>Horário</span><div style={{ fontWeight: 600 }}>{selectedTime}</div></div>
</div>
<div style={{ borderTop: '1px solid var(--border-color)', paddingTop: '16px', display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
<span style={{ color: 'var(--text-secondary)' }}>Total</span>
<span style={{ fontSize: '24px', fontWeight: 800, color: 'var(--accent)' }}>R$ {svc?.price.toFixed(2)}</span>
</div>
</div>
</div>
<p style={{ fontSize: '13px', color: 'var(--text-muted)', marginTop: '20px' }}>
Você receberá uma confirmação por e-mail/WhatsApp.
</p>
</div>
</div>
);
}
return (
<div style={{ minHeight: '100vh', background: 'var(--bg-primary)' }}>
{/* HEADER */}
<header style={{
background: 'var(--gradient-primary)', padding: '32px 24px', textAlign: 'center',
position: 'relative', overflow: 'hidden'
}}>
<div style={{ position: 'absolute', inset: 0, background: 'radial-gradient(circle at 30% 50%, rgba(255,255,255,0.1), transparent 60%)' }} />
<div style={{ position: 'relative', maxWidth: '600px', margin: '0 auto' }}>
<div style={{ fontSize: '32px', marginBottom: '8px' }}></div>
<h1 style={{ fontSize: '28px', fontWeight: 900, letterSpacing: '-1px', marginBottom: '6px' }}>{establishment.name}</h1>
<p style={{ fontSize: '14px', opacity: 0.9, display: 'flex', alignItems: 'center', justifyContent: 'center', gap: '6px' }}>
<MapPin size={14} /> {establishment.address}
</p>
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'center', gap: '6px', marginTop: '8px' }}>
<Star size={14} fill="white" />
<span style={{ fontWeight: 700 }}>{establishment.rating}</span>
<span style={{ opacity: 0.8 }}>({establishment.reviews} avaliações)</span>
</div>
</div>
</header>
{/* STEPPER */}
<div style={{ maxWidth: '700px', margin: '0 auto', padding: '32px 20px' }}>
<div style={{ display: 'flex', justifyContent: 'center', gap: '8px', marginBottom: '40px' }}>
{['Serviço', 'Profissional', 'Data e Hora', 'Seus Dados'].map((label, i) => (
<div key={label} style={{ display: 'flex', alignItems: 'center', gap: '8px' }}>
<div style={{
width: 32, height: 32, borderRadius: '50%', display: 'flex', alignItems: 'center', justifyContent: 'center',
fontSize: '13px', fontWeight: 700,
background: step > i + 1 ? 'var(--success)' : step === i + 1 ? 'var(--accent)' : 'var(--bg-elevated)',
color: step >= i + 1 ? 'white' : 'var(--text-muted)',
transition: 'all 0.3s'
}}>
{step > i + 1 ? <Check size={16} /> : i + 1}
</div>
<span style={{
fontSize: '13px', fontWeight: step === i + 1 ? 600 : 400,
color: step === i + 1 ? 'var(--text-primary)' : 'var(--text-muted)',
display: i < 3 ? 'inline' : 'inline'
}}>{label}</span>
{i < 3 && <div style={{ width: '24px', height: '2px', background: step > i + 1 ? 'var(--success)' : 'var(--bg-elevated)', borderRadius: '1px' }} />}
</div>
))}
</div>
{/* STEP 1: SELECT SERVICE */}
{step === 1 && (
<div className="slide-up">
<h2 style={{ fontSize: '20px', fontWeight: 700, marginBottom: '20px' }}>Escolha o serviço</h2>
<div style={{ display: 'flex', flexDirection: 'column', gap: '12px' }}>
{services.map(s => (
<div key={s.id} onClick={() => setSelectedService(s.id)} style={{
padding: '18px 20px', borderRadius: 'var(--radius-md)', cursor: 'pointer',
background: selectedService === s.id ? 'var(--accent-glow)' : 'var(--bg-card)',
border: `2px solid ${selectedService === s.id ? 'var(--accent)' : 'var(--border-color)'}`,
transition: 'all 0.2s', display: 'flex', justifyContent: 'space-between', alignItems: 'center'
}}>
<div>
<div style={{ fontWeight: 700, fontSize: '15px', marginBottom: '4px' }}>{s.name}</div>
<div style={{ fontSize: '13px', color: 'var(--text-secondary)' }}>{s.desc}</div>
<div style={{ fontSize: '12px', color: 'var(--text-muted)', marginTop: '4px', display: 'flex', alignItems: 'center', gap: '4px' }}>
<Clock size={12} /> {s.duration} min
</div>
</div>
<div style={{ textAlign: 'right' }}>
<div style={{ fontSize: '20px', fontWeight: 800, color: 'var(--accent)' }}>R$ {s.price.toFixed(2)}</div>
</div>
</div>
))}
</div>
</div>
)}
{/* STEP 2: SELECT PROFESSIONAL */}
{step === 2 && (
<div className="slide-up">
<h2 style={{ fontSize: '20px', fontWeight: 700, marginBottom: '20px' }}>Escolha o profissional</h2>
<div style={{ display: 'flex', flexDirection: 'column', gap: '12px' }}>
{professionals.map(p => (
<div key={p.id} onClick={() => setSelectedProfessional(p.id)} style={{
padding: '20px', borderRadius: 'var(--radius-md)', cursor: 'pointer',
background: selectedProfessional === p.id ? 'var(--accent-glow)' : 'var(--bg-card)',
border: `2px solid ${selectedProfessional === p.id ? 'var(--accent)' : 'var(--border-color)'}`,
transition: 'all 0.2s', display: 'flex', alignItems: 'center', gap: '16px'
}}>
<div className="avatar avatar-lg">{p.name.split(' ').map(n => n[0]).join('')}</div>
<div style={{ flex: 1 }}>
<div style={{ fontWeight: 700, fontSize: '15px' }}>{p.name}</div>
<div style={{ fontSize: '13px', color: 'var(--text-secondary)' }}>{p.role}</div>
</div>
<div style={{ display: 'flex', alignItems: 'center', gap: '4px', color: 'var(--warning)' }}>
<Star size={14} fill="var(--warning)" /> <span style={{ fontWeight: 700 }}>{p.rating}</span>
</div>
</div>
))}
</div>
</div>
)}
{/* STEP 3: SELECT DATE & TIME */}
{step === 3 && (
<div className="slide-up">
<h2 style={{ fontSize: '20px', fontWeight: 700, marginBottom: '20px' }}>Escolha data e horário</h2>
<div style={{ marginBottom: '24px' }}>
<h3 style={{ fontSize: '14px', fontWeight: 600, color: 'var(--text-secondary)', marginBottom: '12px' }}>
<Calendar size={14} style={{ display: 'inline', marginRight: '6px' }} />Selecione a data
</h3>
<div style={{ display: 'flex', gap: '8px', overflowX: 'auto', paddingBottom: '8px' }}>
{dates.map(d => {
const iso = d.toISOString().split('T')[0];
const isSelected = selectedDate === iso;
const isToday = d.toDateString() === today.toDateString();
const dayName = d.toLocaleDateString('pt-BR', { weekday: 'short' }).replace('.', '');
return (
<div key={iso} onClick={() => { setSelectedDate(iso); setSelectedTime(null); }} style={{
minWidth: '64px', padding: '12px 8px', borderRadius: 'var(--radius-md)', cursor: 'pointer',
textAlign: 'center', flexShrink: 0, transition: 'all 0.2s',
background: isSelected ? 'var(--accent)' : 'var(--bg-card)',
border: `2px solid ${isSelected ? 'var(--accent)' : 'var(--border-color)'}`,
color: isSelected ? 'white' : 'var(--text-primary)'
}}>
<div style={{ fontSize: '11px', textTransform: 'uppercase', opacity: 0.7, marginBottom: '4px' }}>{dayName}</div>
<div style={{ fontSize: '20px', fontWeight: 800 }}>{d.getDate()}</div>
<div style={{ fontSize: '11px', opacity: 0.7 }}>{d.toLocaleDateString('pt-BR', { month: 'short' }).replace('.', '')}</div>
{isToday && <div style={{ fontSize: '9px', fontWeight: 700, color: isSelected ? 'white' : 'var(--accent)', marginTop: '2px' }}>HOJE</div>}
</div>
);
})}
</div>
</div>
{selectedDate && (
<div>
<h3 style={{ fontSize: '14px', fontWeight: 600, color: 'var(--text-secondary)', marginBottom: '12px' }}>
<Clock size={14} style={{ display: 'inline', marginRight: '6px' }} />Horários disponíveis
</h3>
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(auto-fill, minmax(90px, 1fr))', gap: '8px' }}>
{(slots.length > 0 ? slots : ['09:00', '09:30', '10:00', '10:30', '11:00', '11:30', '14:00', '14:30', '15:00', '15:30', '16:00', '16:30', '17:00', '17:30', '18:00']).map(slot => (
<div key={slot} onClick={() => setSelectedTime(slot)} style={{
padding: '12px', borderRadius: 'var(--radius-sm)', cursor: 'pointer', textAlign: 'center',
fontWeight: 600, fontSize: '14px', transition: 'all 0.2s',
background: selectedTime === slot ? 'var(--accent)' : 'var(--bg-card)',
border: `1px solid ${selectedTime === slot ? 'var(--accent)' : 'var(--border-color)'}`,
color: selectedTime === slot ? 'white' : 'var(--text-primary)'
}}>
{slot}
</div>
))}
</div>
</div>
)}
</div>
)}
{/* STEP 4: CLIENT DATA */}
{step === 4 && (
<div className="slide-up">
<h2 style={{ fontSize: '20px', fontWeight: 700, marginBottom: '20px' }}>Seus dados</h2>
<div style={{
background: 'var(--bg-card)', border: '1px solid var(--border-color)',
borderRadius: 'var(--radius-lg)', padding: '20px', marginBottom: '24px'
}}>
<h3 style={{ fontSize: '13px', fontWeight: 600, color: 'var(--text-muted)', textTransform: 'uppercase', marginBottom: '12px' }}>Resumo do agendamento</h3>
<div style={{ display: 'grid', gap: '8px', fontSize: '14px' }}>
<div style={{ display: 'flex', justifyContent: 'space-between' }}><span style={{ color: 'var(--text-secondary)' }}>Serviço</span><span style={{ fontWeight: 600 }}>{svc?.name}</span></div>
<div style={{ display: 'flex', justifyContent: 'space-between' }}><span style={{ color: 'var(--text-secondary)' }}>Profissional</span><span style={{ fontWeight: 600 }}>{prof?.name}</span></div>
<div style={{ display: 'flex', justifyContent: 'space-between' }}><span style={{ color: 'var(--text-secondary)' }}>Data</span><span style={{ fontWeight: 600 }}>{selectedDate ? new Date(selectedDate + 'T12:00:00').toLocaleDateString('pt-BR') : ''}</span></div>
<div style={{ display: 'flex', justifyContent: 'space-between' }}><span style={{ color: 'var(--text-secondary)' }}>Horário</span><span style={{ fontWeight: 600 }}>{selectedTime}</span></div>
<div style={{ display: 'flex', justifyContent: 'space-between' }}><span style={{ color: 'var(--text-secondary)' }}>Duração</span><span style={{ fontWeight: 600 }}>{svc?.duration} min</span></div>
<div style={{ borderTop: '1px solid var(--border-color)', paddingTop: '8px', display: 'flex', justifyContent: 'space-between' }}>
<span style={{ fontWeight: 600 }}>Total</span>
<span style={{ fontSize: '20px', fontWeight: 800, color: 'var(--accent)' }}>R$ {svc?.price.toFixed(2)}</span>
</div>
</div>
</div>
<div className="form-group">
<label className="form-label"><User size={14} style={{ display: 'inline', marginRight: 4 }} />Nome completo *</label>
<input className="form-input" placeholder="Seu nome" value={clientName} onChange={e => setClientName(e.target.value)} />
</div>
<div className="grid-2">
<div className="form-group">
<label className="form-label"><Phone size={14} style={{ display: 'inline', marginRight: 4 }} />Telefone / WhatsApp *</label>
<input className="form-input" placeholder="(00) 00000-0000" value={clientPhone} onChange={e => setClientPhone(e.target.value)} />
</div>
<div className="form-group">
<label className="form-label"><Mail size={14} style={{ display: 'inline', marginRight: 4 }} />E-mail (opcional)</label>
<input className="form-input" placeholder="seu@email.com" value={clientEmail} onChange={e => setClientEmail(e.target.value)} />
</div>
</div>
</div>
)}
{/* NAVIGATION BUTTONS */}
<div style={{ display: 'flex', justifyContent: 'space-between', marginTop: '32px', gap: '12px' }}>
{step > 1 ? (
<button className="btn btn-secondary" onClick={() => setStep(s => s - 1)}>
<ChevronLeft size={16} /> Voltar
</button>
) : <div />}
{step < 4 ? (
<button className="btn btn-primary" disabled={!canNext()} onClick={() => setStep(s => s + 1)}
style={{ opacity: canNext() ? 1 : 0.5 }}>
Próximo <ChevronRight size={16} />
</button>
) : (
<button className="btn btn-primary" disabled={!canNext()} onClick={handleBook}
style={{ opacity: canNext() ? 1 : 0.5, padding: '12px 32px', fontSize: '16px' }}>
<Check size={18} /> Confirmar Agendamento
</button>
)}
</div>
</div>
{/* FOOTER */}
<footer style={{ textAlign: 'center', padding: '32px 20px', borderTop: '1px solid var(--border-color)', marginTop: '40px' }}>
<p style={{ fontSize: '13px', color: 'var(--text-muted)' }}>
Powered by <span style={{ fontWeight: 700, color: 'var(--accent)' }}>AgendaPRO</span>
</p>
</footer>
</div>
);
}

View File

@ -0,0 +1,5 @@
import { NextResponse } from 'next/server';
export async function GET() {
return NextResponse.json({ status: 'ok', timestamp: new Date().toISOString() });
}

View File

@ -0,0 +1,286 @@
import { NextRequest, NextResponse } from 'next/server';
import { query, getClient } from '@/lib/db';
import { createCustomer, createSubscription, createPayment, getPixQrCode } from '@/lib/asaas';
/**
* GET: Retrieves the tenant's current subscription status and plan info.
* Query Parameter: ?slug=barbearia-premium
*/
export async function GET(req: NextRequest) {
const { searchParams } = new URL(req.url);
const slug = searchParams.get('slug');
if (!slug) {
return NextResponse.json({ error: 'Missing tenant slug' }, { status: 400 });
}
try {
// 1. Fetch the tenant from the database
let tenant;
let plans;
let activeSubscription = null;
try {
let tenantRes = await query('SELECT * FROM tenants WHERE slug = $1', [slug]);
// Auto-create demo tenant if it doesn't exist
if (tenantRes.rows.length === 0) {
await query(
`INSERT INTO tenants (name, slug, subscription_status, subscription_ends_at, settings)
VALUES ($1, $2, 'active', NOW() + INTERVAL '30 days', '{"timezone": "America/Sao_Paulo", "currency": "BRL"}')`,
[slug.replace('-', ' ').replace(/\b\w/g, c => c.toUpperCase()), slug]
);
tenantRes = await query('SELECT * FROM tenants WHERE slug = $1', [slug]);
}
tenant = tenantRes.rows[0];
// 2. Fetch the plans
const plansRes = await query('SELECT * FROM plans ORDER BY price ASC');
plans = plansRes.rows;
// Auto-seed plans if database is empty
if (plans.length === 0) {
const defaultPlans = [
{ name: 'Starter', price: 49.90, slug: 'starter', features: ['1 profissional', '10 serviços', '200 agendamentos/mês', 'Agendamento online', 'Página de reservas', 'Lembretes por e-mail', 'Relatórios básicos'] },
{ name: 'Professional', price: 99.90, slug: 'professional', features: ['5 profissionais', '30 serviços', 'Agendamentos ilimitados', 'Tudo do Starter', 'Múltiplos profissionais', 'Comissionamento', 'Relatórios avançados', 'Personalização da marca'] },
{ name: 'Business', price: 199.90, slug: 'business', features: ['15 profissionais', '100 serviços', 'Agendamentos ilimitados', 'Tudo do Professional', 'API de integração', 'Suporte prioritário', 'Multi-unidades', 'Automações avançadas'] },
{ name: 'Enterprise', price: 399.90, slug: 'enterprise', features: ['Profissionais ilimitados', 'Serviços ilimitados', 'Agendamentos ilimitados', 'Tudo do Business', 'Gerente dedicado', 'SLA garantido', 'Integrações customizadas', 'White-label'] }
];
for (const p of defaultPlans) {
await query(
'INSERT INTO plans (name, price, slug, features) VALUES ($1, $2, $3, $4)',
[p.name, p.price, p.slug, p.features]
);
}
const plansRetry = await query('SELECT * FROM plans ORDER BY price ASC');
plans = plansRetry.rows;
}
// 3. Fetch active subscription
const subRes = await query(
`SELECT s.*, p.name as plan_name, p.price as plan_price
FROM subscriptions s
JOIN plans p ON s.plan_id = p.id
WHERE s.tenant_id = $1
ORDER BY s.created_at DESC LIMIT 1`,
[tenant.id]
);
activeSubscription = subRes.rows[0] || null;
} catch (dbErr) {
console.warn('Postgres connection failed. Falling back to local mock data.', dbErr);
return NextResponse.json({
tenantId: 'mock-tenant-uuid',
name: slug.replace('-', ' ').replace(/\b\w/g, c => c.toUpperCase()),
slug: slug,
status: 'inactive', // Forces subscription checkout view
endsAt: null,
subscription: null,
plans: [
{ name: 'Starter', price: 49.90, slug: 'starter', features: ['1 profissional', '10 serviços', '200 agendamentos/mês', 'Agendamento online', 'Página de reservas', 'Lembretes por e-mail', 'Relatórios básicos'] },
{ name: 'Professional', price: 99.90, slug: 'professional', features: ['5 profissionais', '30 serviços', 'Agendamentos ilimitados', 'Tudo do Starter', 'Múltiplos profissionais', 'Comissionamento', 'Relatórios avançados', 'Personalização da marca'] },
{ name: 'Business', price: 199.90, slug: 'business', features: ['15 profissionais', '100 serviços', 'Agendamentos ilimitados', 'Tudo do Professional', 'API de integração', 'Suporte prioritário', 'Multi-unidades', 'Automações avançadas'] },
{ name: 'Enterprise', price: 399.90, slug: 'enterprise', features: ['Profissionais ilimitados', 'Serviços ilimitados', 'Agendamentos ilimitados', 'Tudo do Business', 'Gerente dedicado', 'SLA garantido', 'Integrações customizadas', 'White-label'] }
]
});
}
return NextResponse.json({
tenantId: tenant.id,
name: tenant.name,
slug: tenant.slug,
status: tenant.subscription_status || 'inactive',
endsAt: tenant.subscription_ends_at,
subscription: activeSubscription,
plans,
});
} catch (error: any) {
console.error('Failed to get subscription info:', error);
return NextResponse.json({ error: error.message || 'Internal server error' }, { status: 500 });
}
}
/**
* POST: Initiates a subscription/payment flow.
*/
export async function POST(req: NextRequest) {
try {
const { slug, planName, billingType, creditCard, creditCardHolderInfo, billingInfo } = await req.json();
if (!slug || !planName || !billingType) {
return NextResponse.json({ error: 'Missing required fields' }, { status: 400 });
}
const userBilling = billingInfo || {};
// 1. Fetch tenant and plan
let tenant;
let plan;
let dbFailed = false;
try {
const tenantRes = await query('SELECT * FROM tenants WHERE slug = $1', [slug]);
const planRes = await query('SELECT * FROM plans WHERE name = $1', [planName]);
if (tenantRes.rows.length === 0) {
return NextResponse.json({ error: 'Tenant not found' }, { status: 404 });
}
if (planRes.rows.length === 0) {
return NextResponse.json({ error: 'Plan not found' }, { status: 404 });
}
tenant = tenantRes.rows[0];
plan = planRes.rows[0];
} catch (dbErr) {
console.warn('Postgres offline in POST. Using mock records to continue Asaas integration.', dbErr);
dbFailed = true;
tenant = { id: 'mock-uuid', name: slug.toUpperCase(), slug, settings: {} };
plan = { id: 'mock-plan-uuid', name: planName, price: 199.90 };
if (planName === 'Starter') plan.price = 49.90;
else if (planName === 'Professional') plan.price = 99.90;
else if (planName === 'Business') plan.price = 199.90;
else if (planName === 'Enterprise') plan.price = 399.90;
}
const settings = tenant.settings || {};
// 2. Ensure customer exists in Asaas (AgendaPRO's account)
let asaasCustomerId = settings.asaas_customer_id;
if (!asaasCustomerId) {
console.log(`Creating Asaas customer for tenant ${tenant.name}...`);
try {
const customer = await createCustomer('', {
name: userBilling.name || tenant.name,
cpfCnpj: userBilling.cpfCnpj ? userBilling.cpfCnpj.replace(/\D/g, '') : '81804938069',
email: userBilling.email || settings.email || `${slug}@agendapro.com`,
phone: userBilling.phone ? userBilling.phone.replace(/\D/g, '') : settings.phone || '11999991234',
externalReference: tenant.id,
});
asaasCustomerId = customer.id;
if (!dbFailed) {
settings.asaas_customer_id = asaasCustomerId;
await query('UPDATE tenants SET settings = $1 WHERE id = $2', [settings, tenant.id]);
}
} catch (err: any) {
console.error('Failed to create customer in Asaas:', err);
return NextResponse.json({ error: `Asaas customer creation failed: ${err.message}` }, { status: 400 });
}
}
// 3. Setup dates
const nextDueDate = new Date();
nextDueDate.setDate(nextDueDate.getDate() + 3); // Due in 3 days
const formattedDueDate = nextDueDate.toISOString().split('T')[0];
// 4. Create charging entity in Asaas (Subscription or single payment)
let gatewaySubscriptionId = '';
let gatewayPaymentId = '';
let pixCode = null;
let pixQrCodeImage = null;
let localSubId = dbFailed ? 'mock-sub-id' : '';
if (billingType === 'PIX' || billingType === 'BOLETO') {
console.log(`Creating Asaas subscription for tenant ${tenant.name}...`);
const asaasSub = await createSubscription('', {
customer: asaasCustomerId,
billingType,
value: parseFloat(plan.price),
nextDueDate: formattedDueDate,
cycle: 'MONTHLY',
description: `Mensalidade AgendaPRO - Plano ${plan.name}`,
externalReference: localSubId,
});
gatewaySubscriptionId = asaasSub.id;
if (billingType === 'PIX') {
const paymentRes = await createPayment('', {
customer: asaasCustomerId,
billingType: 'PIX',
value: parseFloat(plan.price),
dueDate: formattedDueDate,
description: `Mensalidade AgendaPRO - Plano ${plan.name} (Primeira Cobrança)`,
externalReference: localSubId,
});
gatewayPaymentId = paymentRes.id;
const pixData = await getPixQrCode('', gatewayPaymentId);
pixCode = pixData.payload;
pixQrCodeImage = pixData.encodedImage;
}
} else if (billingType === 'CREDIT_CARD') {
console.log(`Creating Asaas credit card subscription for tenant ${tenant.name}...`);
const asaasSub = await createSubscription('', {
customer: asaasCustomerId,
billingType: 'CREDIT_CARD',
value: parseFloat(plan.price),
nextDueDate: formattedDueDate,
cycle: 'MONTHLY',
description: `Mensalidade AgendaPRO - Plano ${plan.name}`,
externalReference: localSubId,
...({
creditCard,
creditCardHolderInfo,
} as any)
});
gatewaySubscriptionId = asaasSub.id;
}
// 5. Update local subscription database ONLY if DB is active
if (!dbFailed) {
const client = await getClient();
await client.query('BEGIN');
try {
await client.query("UPDATE subscriptions SET status = 'cancelled' WHERE tenant_id = $1", [tenant.id]);
const localSubRes = await client.query(
`INSERT INTO subscriptions (tenant_id, plan_id, status, billing_cycle, payment_method, payment_gateway, amount)
VALUES ($1, $2, 'pending', 'monthly', $3, 'asaas', $4)
RETURNING id`,
[tenant.id, plan.id, billingType.toLowerCase(), plan.price]
);
localSubId = localSubRes.rows[0].id;
await client.query(
`UPDATE subscriptions
SET gateway_subscription_id = $1,
updated_at = NOW()
WHERE id = $2`,
[gatewaySubscriptionId, localSubId]
);
await client.query(
`INSERT INTO subscription_payments (subscription_id, tenant_id, amount, status, gateway_payment_id)
VALUES ($1, $2, $3, 'pending', $4)`,
[localSubId, tenant.id, plan.price, gatewayPaymentId || gatewaySubscriptionId]
);
await client.query('COMMIT');
} catch (err) {
await client.query('ROLLBACK');
throw err;
} finally {
client.release();
}
}
return NextResponse.json({
success: true,
subscriptionId: localSubId,
gatewaySubscriptionId,
gatewayPaymentId,
pixCode,
pixQrCodeImage,
});
} catch (error: any) {
console.error('Failed to initiate subscription payment:', error);
return NextResponse.json({ error: error.message || 'Internal server error' }, { status: 500 });
}
}

View File

@ -0,0 +1,110 @@
import { NextRequest, NextResponse } from 'next/server';
import { query, getClient } from '@/lib/db';
/**
* POST: Simulates a successful Asaas payment webhook callback.
* Body: { slug: string, status: 'active' | 'inactive' | 'past_due' }
*/
export async function POST(req: NextRequest) {
try {
const { slug, status } = await req.json();
if (!slug || !status) {
return NextResponse.json({ error: 'Missing slug or status' }, { status: 400 });
}
// Find the tenant
const tenantRes = await query('SELECT * FROM tenants WHERE slug = $1', [slug]);
if (tenantRes.rows.length === 0) {
return NextResponse.json({ error: 'Tenant not found' }, { status: 404 });
}
const tenant = tenantRes.rows[0];
const client = await getClient();
await client.query('BEGIN');
try {
if (status === 'active') {
// Unlock: set tenant status to active and extend expiration
await client.query(
`UPDATE tenants
SET subscription_status = 'active',
subscription_ends_at = NOW() + INTERVAL '30 days',
updated_at = NOW()
WHERE id = $1`,
[tenant.id]
);
// Find pending subscription
const subRes = await client.query(
`SELECT id FROM subscriptions WHERE tenant_id = $1 ORDER BY created_at DESC LIMIT 1`,
[tenant.id]
);
if (subRes.rows.length > 0) {
const subId = subRes.rows[0].id;
// Update subscription record to active
await client.query(
`UPDATE subscriptions
SET status = 'active',
current_period_start = NOW(),
current_period_end = NOW() + INTERVAL '30 days',
last_payment_at = NOW(),
next_payment_at = NOW() + INTERVAL '30 days',
updated_at = NOW()
WHERE id = $1`,
[subId]
);
// Update subscription payment record to paid
await client.query(
`UPDATE subscription_payments
SET status = 'paid',
paid_at = NOW(),
updated_at = NOW()
WHERE subscription_id = $1`,
[subId]
);
}
} else {
// Lock: set tenant status to inactive/past_due and set ends_at to past
await client.query(
`UPDATE tenants
SET subscription_status = $1,
subscription_ends_at = NOW() - INTERVAL '1 day',
updated_at = NOW()`,
[status]
);
// Cancel subscriptions
await client.query(
`UPDATE subscriptions
SET status = 'cancelled',
updated_at = NOW()
WHERE tenant_id = $1`,
[tenant.id]
);
}
await client.query('COMMIT');
return NextResponse.json({
success: true,
message: `Subscription simulated successfully to status: ${status}`,
tenantStatus: status,
});
} catch (err: any) {
await client.query('ROLLBACK');
throw err;
} finally {
client.release();
}
} catch (error: any) {
console.error('Failed to simulate subscription:', error);
return NextResponse.json({ error: error.message || 'Internal server error' }, { status: 500 });
}
}

View File

@ -0,0 +1,236 @@
import { NextRequest, NextResponse } from 'next/server';
import { query, getClient } from '@/lib/db';
import { getTenantAsaasConfig } from '@/lib/asaas';
export async function POST(req: NextRequest) {
const { searchParams } = new URL(req.url);
const tenantId = searchParams.get('tenant_id');
// 1. Retrieve the secure token from headers
const token = req.headers.get('asaas-access-token');
if (!token) {
console.error('Webhook error: Missing asaas-access-token header');
return NextResponse.json({ error: 'Missing security token' }, { status: 401 });
}
// 2. Parse the webhook body
let body: any;
try {
body = await req.json();
} catch (err) {
console.error('Webhook error: Failed to parse body JSON', err);
return NextResponse.json({ error: 'Invalid JSON' }, { status: 400 });
}
const eventId = body.id;
const eventType = body.event;
if (!eventId || !eventType) {
return NextResponse.json({ error: 'Invalid payload' }, { status: 400 });
}
// Ensure our idempotency and tracking table exists
try {
await query(`
CREATE TABLE IF NOT EXISTS asaas_webhook_events (
event_id VARCHAR(200) PRIMARY KEY,
event_type VARCHAR(100),
processed_at TIMESTAMPTZ DEFAULT NOW()
)
`);
} catch (err) {
console.error('Failed to ensure asaas_webhook_events table exists:', err);
}
// 3. Resolve the expected webhook secret
let expectedSecret = '';
try {
if (tenantId) {
// Scoped webhook for a tenant
const config = await getTenantAsaasConfig(tenantId);
expectedSecret = config.webhookSecret;
} else {
// Master platform webhook (SaaS subscriptions)
expectedSecret = process.env.ASAAS_WEBHOOK_SECRET || '';
}
} catch (err: any) {
console.error(`Webhook configuration resolution failed: ${err.message}`);
return NextResponse.json({ error: 'Config not found' }, { status: 400 });
}
// Validate the secret
if (!expectedSecret || token !== expectedSecret) {
console.error('Webhook error: Invalid security token');
return NextResponse.json({ error: 'Unauthorized security token' }, { status: 401 });
}
// 4. Implement Idempotency Check
try {
const existingEvent = await query(
'SELECT 1 FROM asaas_webhook_events WHERE event_id = $1',
[eventId]
);
if (existingEvent.rows.length > 0) {
console.log(`Webhook warning: Event ${eventId} was already processed. Skipping.`);
return NextResponse.json({ received: true, status: 'already_processed' }, { status: 200 });
}
} catch (err) {
console.error('Idempotency query failed:', err);
}
const client = await getClient();
try {
await client.query('BEGIN');
// Record the event for idempotency
await client.query(
'INSERT INTO asaas_webhook_events (event_id, event_type) VALUES ($1, $2)',
[eventId, eventType]
);
// 5. Process events based on category
if (tenantId) {
// CLIENT PAYMENTS (Appointments / Bookings paid by clients)
const paymentData = body.payment;
if (paymentData) {
const externalReference = paymentData.externalReference;
if (externalReference) {
// Verify if this payment belongs to the tenant
const paymentCheck = await client.query(
'SELECT id, appointment_id FROM payments WHERE id = $1 AND tenant_id = $2',
[externalReference, tenantId]
);
if (paymentCheck.rows.length > 0) {
const paymentRecord = paymentCheck.rows[0];
if (eventType === 'PAYMENT_RECEIVED' || eventType === 'PAYMENT_CONFIRMED') {
const paidAt = paymentData.confirmedDate || paymentData.paymentDate || 'NOW()';
// Update local payment record
await client.query(
`UPDATE payments
SET status = 'completed', paid_at = $1, notes = COALESCE(notes, '') || '\nAsaas ID: ' || $2
WHERE id = $3`,
[paidAt, paymentData.id, externalReference]
);
// Update the linked appointment status if it was pending
if (paymentRecord.appointment_id) {
await client.query(
`UPDATE appointments
SET status = 'confirmed', updated_at = NOW()
WHERE id = $1 AND status = 'pending'`,
[paymentRecord.appointment_id]
);
}
} else if (eventType === 'PAYMENT_REFUNDED') {
await client.query(
`UPDATE payments SET status = 'refunded' WHERE id = $1`,
[externalReference]
);
} else if (eventType === 'PAYMENT_OVERDUE' || eventType === 'PAYMENT_DELETED') {
await client.query(
`UPDATE payments SET status = 'failed' WHERE id = $1`,
[externalReference]
);
}
} else {
console.warn(`Payment ${externalReference} not found for tenant ${tenantId}`);
}
}
}
} else {
// PLATFORM SUBSCRIBERS (Tenants subscribing to AgendaPRO plans)
const paymentData = body.payment;
if (paymentData) {
const asaasSubscriptionId = paymentData.subscription;
if (asaasSubscriptionId) {
// Find the SaaS subscription by gateway ID
const subRes = await client.query(
'SELECT id, tenant_id, plan_id, billing_cycle FROM subscriptions WHERE gateway_subscription_id = $1',
[asaasSubscriptionId]
);
if (subRes.rows.length > 0) {
const subscription = subRes.rows[0];
if (eventType === 'PAYMENT_RECEIVED' || eventType === 'PAYMENT_CONFIRMED') {
const paidAt = paymentData.confirmedDate || paymentData.paymentDate || 'NOW()';
const value = paymentData.value;
// Insert subscription payment history record
await client.query(
`INSERT INTO subscription_payments (subscription_id, tenant_id, amount, status, gateway_payment_id, paid_at)
VALUES ($1, $2, $3, 'paid', $4, $5)`,
[subscription.id, subscription.tenant_id, value, paymentData.id, paidAt]
);
// Calculate subscription duration period
const periodInterval = subscription.billing_cycle === 'yearly' ? '1 year' : '30 days';
// Update subscription table
await client.query(
`UPDATE subscriptions
SET status = 'active',
current_period_start = NOW(),
current_period_end = NOW() + CAST($1 AS INTERVAL),
last_payment_at = $2,
next_payment_at = NOW() + CAST($1 AS INTERVAL),
updated_at = NOW()
WHERE id = $3`,
[periodInterval, paidAt, subscription.id]
);
// Update tenant status
await client.query(
`UPDATE tenants
SET subscription_status = 'active',
subscription_ends_at = NOW() + CAST($1 AS INTERVAL),
updated_at = NOW()
WHERE id = $2`,
[periodInterval, subscription.tenant_id]
);
}
}
}
}
// Handle subscription-specific events if any
const subscriptionData = body.subscription;
if (subscriptionData) {
const asaasSubscriptionId = subscriptionData.id;
const externalReference = subscriptionData.externalReference;
if (eventType === 'SUBSCRIPTION_DELETED' || eventType === 'SUBSCRIPTION_CANCELLED') {
// Cancel subscription locally
const queryStr = asaasSubscriptionId
? 'UPDATE subscriptions SET status = $1, cancelled_at = NOW(), updated_at = NOW() WHERE gateway_subscription_id = $2 RETURNING tenant_id'
: 'UPDATE subscriptions SET status = $1, cancelled_at = NOW(), updated_at = NOW() WHERE id = $2 RETURNING tenant_id';
const target = asaasSubscriptionId || externalReference;
const subCancelRes = await client.query(queryStr, ['cancelled', target]);
if (subCancelRes.rows.length > 0) {
const tenantId = subCancelRes.rows[0].tenant_id;
await client.query(
`UPDATE tenants SET subscription_status = 'cancelled', updated_at = NOW() WHERE id = $1`,
[tenantId]
);
}
}
}
}
await client.query('COMMIT');
return NextResponse.json({ received: true }, { status: 200 });
} catch (error) {
await client.query('ROLLBACK');
console.error('Webhook processing transaction failed:', error);
return NextResponse.json({ error: 'Internal processing error' }, { status: 500 });
} finally {
client.release();
}
}

852
src/app/globals.css Normal file
View File

@ -0,0 +1,852 @@
@import url('https://fonts.googleapis.com/css2?family=Inter:wght@300;400;500;600;700;800;900&display=swap');
:root {
--bg-primary: #0a0a0f;
--bg-secondary: #12121a;
--bg-card: #1a1a2e;
--bg-card-hover: #22223a;
--bg-elevated: #252540;
--border-color: rgba(255,255,255,0.06);
--border-active: rgba(108,99,255,0.5);
--text-primary: #f0f0f5;
--text-secondary: #8888a0;
--text-muted: #55556a;
--accent: #6C63FF;
--accent-hover: #5a52e0;
--accent-glow: rgba(108,99,255,0.25);
--success: #22c55e;
--success-bg: rgba(34,197,94,0.1);
--warning: #f59e0b;
--warning-bg: rgba(245,158,11,0.1);
--danger: #ef4444;
--danger-bg: rgba(239,68,68,0.1);
--info: #3b82f6;
--info-bg: rgba(59,130,246,0.1);
--gradient-primary: linear-gradient(135deg, #6C63FF 0%, #a855f7 100%);
--gradient-card: linear-gradient(145deg, rgba(26,26,46,0.8), rgba(18,18,26,0.9));
--shadow-sm: 0 2px 8px rgba(0,0,0,0.3);
--shadow-md: 0 4px 20px rgba(0,0,0,0.4);
--shadow-lg: 0 8px 40px rgba(0,0,0,0.5);
--shadow-glow: 0 0 30px rgba(108,99,255,0.15);
--radius-sm: 8px;
--radius-md: 12px;
--radius-lg: 16px;
--radius-xl: 24px;
}
* { margin: 0; padding: 0; box-sizing: border-box; }
html, body { height: 100%; }
body {
font-family: 'Inter', -apple-system, sans-serif;
background: var(--bg-primary);
color: var(--text-primary);
line-height: 1.6;
-webkit-font-smoothing: antialiased;
}
/* === SCROLLBAR === */
::-webkit-scrollbar { width: 6px; }
::-webkit-scrollbar-track { background: transparent; }
::-webkit-scrollbar-thumb { background: var(--bg-elevated); border-radius: 3px; }
::-webkit-scrollbar-thumb:hover { background: var(--accent); }
/* === LAYOUT === */
.app-layout {
display: flex;
min-height: 100vh;
}
.sidebar {
width: 260px;
background: var(--bg-secondary);
border-right: 1px solid var(--border-color);
display: flex;
flex-direction: column;
position: fixed;
top: 0;
left: 0;
bottom: 0;
z-index: 100;
transition: transform 0.3s ease;
}
.sidebar-header {
padding: 24px 20px;
border-bottom: 1px solid var(--border-color);
}
.sidebar-logo {
display: flex;
align-items: center;
gap: 12px;
text-decoration: none;
}
.logo-icon {
width: 40px;
height: 40px;
background: var(--gradient-primary);
border-radius: var(--radius-md);
display: flex;
align-items: center;
justify-content: center;
font-size: 20px;
color: white;
box-shadow: var(--shadow-glow);
}
.logo-text {
font-size: 20px;
font-weight: 800;
background: var(--gradient-primary);
-webkit-background-clip: text;
-webkit-text-fill-color: transparent;
letter-spacing: -0.5px;
}
.sidebar-nav {
flex: 1;
padding: 16px 12px;
overflow-y: auto;
}
.nav-section-title {
font-size: 11px;
font-weight: 600;
color: var(--text-muted);
text-transform: uppercase;
letter-spacing: 1.2px;
padding: 16px 12px 8px;
}
.nav-item {
display: flex;
align-items: center;
gap: 12px;
padding: 10px 12px;
border-radius: var(--radius-sm);
color: var(--text-secondary);
text-decoration: none;
font-size: 14px;
font-weight: 500;
cursor: pointer;
transition: all 0.2s ease;
border: none;
background: none;
width: 100%;
text-align: left;
}
.nav-item:hover {
background: var(--bg-card);
color: var(--text-primary);
}
.nav-item.active {
background: var(--accent-glow);
color: var(--accent);
box-shadow: inset 3px 0 0 var(--accent);
}
.nav-item svg { width: 20px; height: 20px; flex-shrink: 0; }
.sidebar-footer {
padding: 16px;
border-top: 1px solid var(--border-color);
}
.plan-badge {
display: flex;
align-items: center;
gap: 8px;
padding: 10px 14px;
background: var(--accent-glow);
border-radius: var(--radius-sm);
font-size: 12px;
color: var(--accent);
font-weight: 600;
}
/* === MAIN CONTENT === */
.main-content {
flex: 1;
margin-left: 260px;
min-height: 100vh;
}
.topbar {
display: flex;
align-items: center;
justify-content: space-between;
padding: 16px 32px;
border-bottom: 1px solid var(--border-color);
background: rgba(10,10,15,0.8);
backdrop-filter: blur(20px);
position: sticky;
top: 0;
z-index: 50;
}
.topbar-left h1 {
font-size: 22px;
font-weight: 700;
letter-spacing: -0.5px;
}
.topbar-left p {
font-size: 13px;
color: var(--text-secondary);
margin-top: 2px;
}
.topbar-right {
display: flex;
align-items: center;
gap: 12px;
}
.page-content {
padding: 32px;
}
/* === CARDS === */
.card {
background: var(--bg-card);
border: 1px solid var(--border-color);
border-radius: var(--radius-lg);
padding: 24px;
transition: all 0.3s ease;
}
.card:hover {
border-color: var(--border-active);
box-shadow: var(--shadow-glow);
}
.card-header {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 20px;
}
.card-title {
font-size: 16px;
font-weight: 600;
}
/* === STATS === */
.stats-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(220px, 1fr));
gap: 20px;
margin-bottom: 32px;
}
.stat-card {
background: var(--bg-card);
border: 1px solid var(--border-color);
border-radius: var(--radius-lg);
padding: 24px;
display: flex;
align-items: flex-start;
gap: 16px;
transition: all 0.3s ease;
}
.stat-card:hover {
transform: translateY(-2px);
box-shadow: var(--shadow-glow);
border-color: var(--border-active);
}
.stat-icon {
width: 48px;
height: 48px;
border-radius: var(--radius-md);
display: flex;
align-items: center;
justify-content: center;
flex-shrink: 0;
}
.stat-icon.purple { background: var(--accent-glow); color: var(--accent); }
.stat-icon.green { background: var(--success-bg); color: var(--success); }
.stat-icon.orange { background: var(--warning-bg); color: var(--warning); }
.stat-icon.blue { background: var(--info-bg); color: var(--info); }
.stat-value {
font-size: 28px;
font-weight: 800;
letter-spacing: -1px;
line-height: 1.2;
}
.stat-label {
font-size: 13px;
color: var(--text-secondary);
margin-top: 2px;
}
.stat-change {
font-size: 12px;
font-weight: 600;
margin-top: 4px;
}
.stat-change.up { color: var(--success); }
.stat-change.down { color: var(--danger); }
/* === BUTTONS === */
.btn {
display: inline-flex;
align-items: center;
gap: 8px;
padding: 10px 20px;
border-radius: var(--radius-sm);
font-size: 14px;
font-weight: 600;
cursor: pointer;
border: none;
transition: all 0.2s ease;
text-decoration: none;
font-family: inherit;
}
.btn-primary {
background: var(--gradient-primary);
color: white;
box-shadow: var(--shadow-glow);
}
.btn-primary:hover {
transform: translateY(-1px);
box-shadow: 0 0 40px rgba(108,99,255,0.3);
}
.btn-secondary {
background: var(--bg-elevated);
color: var(--text-primary);
border: 1px solid var(--border-color);
}
.btn-secondary:hover { border-color: var(--accent); }
.btn-danger {
background: var(--danger-bg);
color: var(--danger);
border: 1px solid rgba(239,68,68,0.2);
}
.btn-ghost {
background: transparent;
color: var(--text-secondary);
padding: 8px 12px;
}
.btn-ghost:hover { color: var(--text-primary); background: var(--bg-card); }
.btn-sm { padding: 6px 14px; font-size: 13px; }
.btn-icon { padding: 8px; border-radius: var(--radius-sm); }
/* === FORMS === */
.form-group { margin-bottom: 20px; }
.form-label {
display: block;
font-size: 13px;
font-weight: 600;
color: var(--text-secondary);
margin-bottom: 6px;
}
.form-input, .form-select, .form-textarea {
width: 100%;
padding: 10px 14px;
background: var(--bg-primary);
border: 1px solid var(--border-color);
border-radius: var(--radius-sm);
color: var(--text-primary);
font-size: 14px;
font-family: inherit;
transition: all 0.2s;
outline: none;
}
.form-input:focus, .form-select:focus, .form-textarea:focus {
border-color: var(--accent);
box-shadow: 0 0 0 3px var(--accent-glow);
}
.form-textarea { resize: vertical; min-height: 80px; }
.form-select { cursor: pointer; }
/* === TABLE === */
.table-container {
overflow-x: auto;
border-radius: var(--radius-lg);
border: 1px solid var(--border-color);
}
table {
width: 100%;
border-collapse: collapse;
}
th {
text-align: left;
padding: 12px 16px;
font-size: 12px;
font-weight: 600;
color: var(--text-muted);
text-transform: uppercase;
letter-spacing: 0.8px;
background: var(--bg-secondary);
border-bottom: 1px solid var(--border-color);
}
td {
padding: 14px 16px;
font-size: 14px;
border-bottom: 1px solid var(--border-color);
vertical-align: middle;
}
tr:last-child td { border-bottom: none; }
tr:hover td { background: var(--bg-card-hover); }
/* === BADGES === */
.badge {
display: inline-flex;
align-items: center;
gap: 4px;
padding: 4px 10px;
border-radius: 20px;
font-size: 12px;
font-weight: 600;
}
.badge-success { background: var(--success-bg); color: var(--success); }
.badge-warning { background: var(--warning-bg); color: var(--warning); }
.badge-danger { background: var(--danger-bg); color: var(--danger); }
.badge-info { background: var(--info-bg); color: var(--info); }
.badge-purple { background: var(--accent-glow); color: var(--accent); }
/* === MODAL === */
.modal-overlay {
position: fixed;
inset: 0;
background: rgba(0,0,0,0.7);
backdrop-filter: blur(4px);
display: flex;
align-items: center;
justify-content: center;
z-index: 1000;
animation: fadeIn 0.2s ease;
}
.modal {
background: var(--bg-secondary);
border: 1px solid var(--border-color);
border-radius: var(--radius-xl);
width: 90%;
max-width: 560px;
max-height: 85vh;
overflow-y: auto;
box-shadow: var(--shadow-lg);
animation: slideUp 0.3s ease;
}
.modal-header {
display: flex;
align-items: center;
justify-content: space-between;
padding: 24px;
border-bottom: 1px solid var(--border-color);
}
.modal-title { font-size: 18px; font-weight: 700; }
.modal-body { padding: 24px; }
.modal-footer {
display: flex;
justify-content: flex-end;
gap: 12px;
padding: 16px 24px;
border-top: 1px solid var(--border-color);
}
/* === CALENDAR === */
.calendar-grid {
display: grid;
grid-template-columns: 80px repeat(auto-fill, minmax(150px, 1fr));
gap: 0;
border: 1px solid var(--border-color);
border-radius: var(--radius-lg);
overflow: hidden;
}
.time-slot {
padding: 8px 12px;
font-size: 12px;
color: var(--text-muted);
border-bottom: 1px solid var(--border-color);
text-align: right;
}
.appointment-block {
padding: 8px 12px;
border-radius: var(--radius-sm);
font-size: 13px;
cursor: pointer;
transition: all 0.2s;
margin: 2px 4px;
}
.appointment-block:hover { transform: scale(1.02); }
.appointment-block.confirmed {
background: var(--accent-glow);
border-left: 3px solid var(--accent);
color: var(--text-primary);
}
.appointment-block.pending {
background: var(--warning-bg);
border-left: 3px solid var(--warning);
color: var(--text-primary);
}
.appointment-block.completed {
background: var(--success-bg);
border-left: 3px solid var(--success);
color: var(--text-primary);
}
/* === TABS === */
.tabs {
display: flex;
gap: 4px;
border-bottom: 1px solid var(--border-color);
margin-bottom: 24px;
}
.tab {
padding: 10px 20px;
font-size: 14px;
font-weight: 500;
color: var(--text-secondary);
cursor: pointer;
border: none;
background: none;
position: relative;
transition: all 0.2s;
font-family: inherit;
}
.tab:hover { color: var(--text-primary); }
.tab.active {
color: var(--accent);
}
.tab.active::after {
content: '';
position: absolute;
bottom: -1px;
left: 0;
right: 0;
height: 2px;
background: var(--accent);
border-radius: 2px 2px 0 0;
}
/* === AVATAR === */
.avatar {
width: 36px;
height: 36px;
border-radius: 50%;
background: var(--gradient-primary);
display: flex;
align-items: center;
justify-content: center;
font-weight: 700;
font-size: 14px;
color: white;
flex-shrink: 0;
}
.avatar-lg { width: 48px; height: 48px; font-size: 18px; }
/* === SEARCH === */
.search-box {
position: relative;
}
.search-box svg {
position: absolute;
left: 14px;
top: 50%;
transform: translateY(-50%);
color: var(--text-muted);
width: 18px;
height: 18px;
}
.search-box input {
padding-left: 42px;
}
/* === ANIMATIONS === */
@keyframes fadeIn {
from { opacity: 0; }
to { opacity: 1; }
}
@keyframes slideUp {
from { opacity: 0; transform: translateY(20px); }
to { opacity: 1; transform: translateY(0); }
}
@keyframes slideIn {
from { opacity: 0; transform: translateX(-10px); }
to { opacity: 1; transform: translateX(0); }
}
.fade-in { animation: fadeIn 0.3s ease; }
.slide-up { animation: slideUp 0.4s ease; }
/* === GRID UTILS === */
.grid-2 { display: grid; grid-template-columns: 1fr 1fr; gap: 16px; }
.grid-3 { display: grid; grid-template-columns: repeat(3, 1fr); gap: 16px; }
.flex-between { display: flex; align-items: center; justify-content: space-between; }
.flex-center { display: flex; align-items: center; justify-content: center; }
.flex-gap { display: flex; align-items: center; gap: 12px; }
.gap-sm { gap: 8px; }
.mt-4 { margin-top: 16px; }
.mt-6 { margin-top: 24px; }
.mb-4 { margin-bottom: 16px; }
.mb-6 { margin-bottom: 24px; }
/* === TOGGLE === */
.toggle {
width: 44px;
height: 24px;
border-radius: 12px;
background: var(--bg-elevated);
border: 1px solid var(--border-color);
cursor: pointer;
position: relative;
transition: all 0.3s;
}
.toggle.active {
background: var(--accent);
border-color: var(--accent);
}
.toggle::after {
content: '';
width: 18px;
height: 18px;
border-radius: 50%;
background: white;
position: absolute;
top: 2px;
left: 2px;
transition: all 0.3s;
}
.toggle.active::after { transform: translateX(20px); }
/* === SCHEDULE VIEW === */
.schedule-container {
display: grid;
grid-template-columns: 70px 1fr;
gap: 0;
}
.schedule-times {
display: flex;
flex-direction: column;
}
.schedule-time-label {
height: 60px;
display: flex;
align-items: flex-start;
justify-content: flex-end;
padding-right: 12px;
font-size: 12px;
color: var(--text-muted);
font-weight: 500;
}
.schedule-columns {
display: grid;
grid-template-columns: repeat(var(--cols, 2), 1fr);
gap: 8px;
}
.schedule-column {
position: relative;
min-height: 600px;
border-left: 1px solid var(--border-color);
}
.schedule-column-header {
text-align: center;
padding: 12px;
font-weight: 600;
font-size: 14px;
border-bottom: 1px solid var(--border-color);
position: sticky;
top: 0;
background: var(--bg-secondary);
z-index: 5;
}
.schedule-event {
position: absolute;
left: 4px;
right: 4px;
border-radius: var(--radius-sm);
padding: 6px 10px;
font-size: 12px;
cursor: pointer;
overflow: hidden;
transition: all 0.2s;
z-index: 2;
}
.schedule-event:hover {
z-index: 10;
box-shadow: var(--shadow-md);
transform: scale(1.02);
}
.schedule-event-title {
font-weight: 600;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.schedule-event-time {
font-size: 11px;
opacity: 0.8;
}
.hour-line {
position: absolute;
left: 0;
right: 0;
height: 1px;
background: var(--border-color);
}
/* === PRICING CARDS === */
.pricing-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(260px, 1fr));
gap: 24px;
}
.pricing-card {
background: var(--bg-card);
border: 1px solid var(--border-color);
border-radius: var(--radius-xl);
padding: 32px 24px;
text-align: center;
transition: all 0.3s;
position: relative;
}
.pricing-card.featured {
border-color: var(--accent);
box-shadow: var(--shadow-glow);
}
.pricing-card.featured::before {
content: 'Popular';
position: absolute;
top: -12px;
left: 50%;
transform: translateX(-50%);
background: var(--gradient-primary);
color: white;
padding: 4px 16px;
border-radius: 20px;
font-size: 12px;
font-weight: 700;
}
.pricing-card:hover {
transform: translateY(-4px);
box-shadow: var(--shadow-glow);
}
.pricing-name { font-size: 20px; font-weight: 700; margin-bottom: 8px; }
.pricing-price {
font-size: 40px;
font-weight: 900;
margin: 16px 0;
letter-spacing: -2px;
}
.pricing-price span {
font-size: 16px;
font-weight: 500;
color: var(--text-secondary);
}
.pricing-features {
list-style: none;
text-align: left;
margin: 24px 0;
}
.pricing-features li {
padding: 8px 0;
font-size: 14px;
color: var(--text-secondary);
display: flex;
align-items: center;
gap: 8px;
}
.pricing-features li svg { color: var(--success); width: 16px; height: 16px; flex-shrink: 0; }
/* === EMPTY STATE === */
.empty-state {
text-align: center;
padding: 60px 20px;
color: var(--text-secondary);
}
.empty-state svg {
width: 64px;
height: 64px;
color: var(--text-muted);
margin-bottom: 16px;
}
.empty-state h3 { font-size: 18px; color: var(--text-primary); margin-bottom: 8px; }
.empty-state p { max-width: 400px; margin: 0 auto 20px; font-size: 14px; }
/* === RESPONSIVE === */
@media (max-width: 768px) {
.sidebar { transform: translateX(-100%); }
.sidebar.open { transform: translateX(0); }
.main-content { margin-left: 0; }
.stats-grid { grid-template-columns: 1fr 1fr; }
.grid-2, .grid-3 { grid-template-columns: 1fr; }
.topbar { padding: 16px; }
.page-content { padding: 16px; }
.pricing-grid { grid-template-columns: 1fr; }
}

15
src/app/layout.tsx Normal file
View File

@ -0,0 +1,15 @@
import type { Metadata } from "next";
import "./globals.css";
export const metadata: Metadata = {
title: "AgendaPRO — Sistema de Agendamento SaaS",
description: "Plataforma completa de agendamento para barbearias, manicures, massagens e estética. Gerencie sua agenda, clientes e pagamentos.",
};
export default function RootLayout({ children }: { children: React.ReactNode }) {
return (
<html lang="pt-BR">
<body>{children}</body>
</html>
);
}

74
src/app/login/page.tsx Normal file
View File

@ -0,0 +1,74 @@
'use client';
import { useState } from 'react';
import { Mail, Lock, Shield, ArrowRight } from 'lucide-react';
import { useRouter } from 'next/navigation';
export default function LoginPage() {
const [email, setEmail] = useState('');
const [password, setPassword] = useState('');
const [loading, setLoading] = useState(false);
const router = useRouter();
const handleLogin = (e: React.FormEvent) => {
e.preventDefault();
setLoading(true);
// Simulação de login (em produção faria requisição à API)
setTimeout(() => {
setLoading(false);
localStorage.setItem('agendapro_token', 'demo_token');
router.push('/');
}, 1000);
};
return (
<div style={{ minHeight: '100vh', display: 'flex', background: 'var(--bg-primary)' }}>
{/* Lado Esquerdo - Decorativo */}
<div style={{
flex: 1, background: 'var(--gradient-primary)', position: 'relative', overflow: 'hidden',
display: 'flex', flexDirection: 'column', alignItems: 'center', justifyContent: 'center', padding: '40px'
}}>
<div style={{ position: 'absolute', inset: 0, background: 'radial-gradient(circle at 30% 50%, rgba(255,255,255,0.1), transparent 60%)' }} />
<div style={{ position: 'relative', zIndex: 1, textAlign: 'center', color: 'white', maxWidth: '400px' }}>
<div style={{ width: 80, height: 80, background: 'rgba(255,255,255,0.2)', borderRadius: 'var(--radius-xl)', backdropFilter: 'blur(10px)', display: 'flex', alignItems: 'center', justifyContent: 'center', margin: '0 auto 32px' }}>
<Shield size={40} color="white" />
</div>
<h1 style={{ fontSize: '36px', fontWeight: 900, letterSpacing: '-1px', marginBottom: '16px' }}>AgendaPRO</h1>
<p style={{ fontSize: '16px', opacity: 0.9, lineHeight: 1.6 }}>O sistema definitivo para gerenciar o seu estabelecimento com inteligência e beleza.</p>
</div>
</div>
{/* Lado Direito - Formulário */}
<div style={{ flex: 1, display: 'flex', alignItems: 'center', justifyContent: 'center', padding: '40px' }}>
<div style={{ width: '100%', maxWidth: '400px', animation: 'slideUp 0.5s ease' }}>
<h2 style={{ fontSize: '28px', fontWeight: 800, marginBottom: '8px' }}>Bem-vindo de volta</h2>
<p style={{ color: 'var(--text-secondary)', marginBottom: '32px' }}>Faça login para acessar o seu painel</p>
<form onSubmit={handleLogin} style={{ display: 'flex', flexDirection: 'column', gap: '20px' }}>
<div className="form-group" style={{ margin: 0 }}>
<label className="form-label" style={{ display: 'flex', alignItems: 'center', gap: 6 }}><Mail size={14} /> E-mail</label>
<input type="email" required className="form-input" placeholder="seu@email.com"
value={email} onChange={e => setEmail(e.target.value)} />
</div>
<div className="form-group" style={{ margin: 0 }}>
<div className="flex-between">
<label className="form-label" style={{ display: 'flex', alignItems: 'center', gap: 6 }}><Lock size={14} /> Senha</label>
<a href="#" style={{ fontSize: '12px', color: 'var(--accent)', textDecoration: 'none', fontWeight: 600 }}>Esqueceu a senha?</a>
</div>
<input type="password" required className="form-input" placeholder="••••••••"
value={password} onChange={e => setPassword(e.target.value)} />
</div>
<button type="submit" className="btn btn-primary" disabled={loading}
style={{ width: '100%', padding: '12px', fontSize: '16px', justifyContent: 'center', marginTop: '12px' }}>
{loading ? 'Entrando...' : <><ArrowRight size={18} /> Entrar no Sistema</>}
</button>
</form>
<p style={{ textAlign: 'center', marginTop: '32px', fontSize: '13px', color: 'var(--text-muted)' }}>
Ainda não tem uma conta? <a href="/register" style={{ color: 'var(--accent)', fontWeight: 600, textDecoration: 'none' }}>Crie agora</a>
</p>
</div>
</div>
</div>
);
}

153
src/app/page.tsx Normal file
View File

@ -0,0 +1,153 @@
'use client';
import React, { useState, useEffect } from 'react';
import { useRouter } from 'next/navigation';
import Sidebar from '@/components/Sidebar';
import Topbar from '@/components/Topbar';
import Dashboard from '@/components/Dashboard';
import Agenda from '@/components/Agenda';
import Clients from '@/components/Clients';
import Services from '@/components/Services';
import Team from '@/components/Team';
import Settings from '@/components/Settings';
import Subscription from '@/components/Subscription';
// Helper para cookies
const getCookie = (name: string): string => {
if (typeof document === 'undefined') return '';
const value = `; ${document.cookie}`;
const parts = value.split(`; ${name}=`);
if (parts.length === 2) return parts.pop()!.split(';').shift() || '';
return '';
};
const setCookie = (name: string, value: string, days: number = 7) => {
if (typeof document === 'undefined') return;
const date = new Date();
date.setTime(date.getTime() + (days * 24 * 60 * 60 * 1000));
const expires = "; expires=" + date.toUTCString();
document.cookie = name + "=" + (value || "") + expires + "; path=/";
};
export default function Home() {
const [currentPage, setCurrentPage] = useState('dashboard');
const [sidebarOpen, setSidebarOpen] = useState(false);
const [currentPlan, setCurrentPlan] = useState('Professional');
const [isLocked, setIsLocked] = useState(false);
const [loadingSubscription, setLoadingSubscription] = useState(true);
const router = useRouter();
const checkSubscriptionStatus = async () => {
const slug = localStorage.getItem('agendapro_tenant_slug') || 'barbearia-premium';
// Verifica primeiro se há um status ativo local/homologação
const localStatus = localStorage.getItem('agendapro_subscription_status') || getCookie('agendapro_subscription_status');
if (localStatus === 'active') {
setIsLocked(false);
setLoadingSubscription(false);
return;
}
try {
const res = await fetch(`/api/tenant/subscription?slug=${slug}`);
if (res.ok) {
const data = await res.json();
const inactive = data.status !== 'active';
setIsLocked(inactive);
if (inactive) {
setCurrentPage('subscription');
}
if (data.subscription?.plan_name) {
setCurrentPlan(data.subscription.plan_name);
}
}
} catch (err) {
console.error('Erro ao verificar assinatura:', err);
} finally {
setLoadingSubscription(false);
}
};
useEffect(() => {
const token = localStorage.getItem('agendapro_token');
if (!token) {
router.push('/login');
} else {
checkSubscriptionStatus();
}
const handleStorageChange = () => {
checkSubscriptionStatus();
};
window.addEventListener('storage', handleStorageChange);
return () => window.removeEventListener('storage', handleStorageChange);
}, [router]);
const handlePlanChange = (newPlan: string) => {
setCurrentPlan(newPlan);
localStorage.setItem('agendapro_plan', newPlan);
setCookie('agendapro_plan', newPlan, 30);
};
const pages: Record<string, { title: string; subtitle: string }> = {
dashboard: { title: 'Dashboard', subtitle: 'Visão geral do seu negócio' },
agenda: { title: 'Agenda', subtitle: 'Gerencie seus agendamentos' },
clients: { title: 'Clientes', subtitle: 'Base de clientes' },
services: { title: 'Serviços', subtitle: 'Catálogo de serviços' },
team: { title: 'Equipe', subtitle: 'Profissionais e horários' },
settings: { title: 'Configurações', subtitle: 'Personalize seu estabelecimento' },
subscription: { title: 'Assinatura', subtitle: 'Gerencie seu plano' },
};
const renderPage = () => {
switch (currentPage) {
case 'dashboard': return <Dashboard onNavigate={setCurrentPage} />;
case 'agenda': return <Agenda />;
case 'clients': return <Clients />;
case 'services': return <Services />;
case 'team': return <Team />;
case 'settings': return <Settings />;
case 'subscription': return (
<Subscription
currentPlan={currentPlan}
onPlanChange={handlePlanChange}
isLocked={isLocked}
onPaymentSuccess={checkSubscriptionStatus}
/>
);
default: return <Dashboard onNavigate={setCurrentPage} />;
}
};
if (loadingSubscription) {
return (
<div style={{ minHeight: '100vh', display: 'flex', alignItems: 'center', justifyContent: 'center', background: 'var(--bg-primary)', flexDirection: 'column', gap: '16px' }}>
<div className="avatar avatar-lg" style={{ animation: 'pulse 1.5s infinite', width: 64, height: 64, fontSize: 24, display: 'flex', alignItems: 'center', justifyContent: 'center' }}></div>
<div style={{ color: 'var(--text-secondary)', fontWeight: 600 }}>Carregando dados do AgendaPRO...</div>
</div>
);
}
return (
<div className="app-layout">
<Sidebar
currentPage={currentPage}
onNavigate={setCurrentPage}
isOpen={sidebarOpen}
onClose={() => setSidebarOpen(false)}
currentPlan={currentPlan}
isLocked={isLocked}
/>
<main className="main-content">
<Topbar
title={pages[currentPage]?.title || 'Dashboard'}
subtitle={pages[currentPage]?.subtitle || ''}
onMenuClick={() => setSidebarOpen(true)}
/>
<div className="page-content fade-in" key={currentPage}>
{renderPage()}
</div>
</main>
</div>
);
}

111
src/app/register/page.tsx Normal file
View File

@ -0,0 +1,111 @@
'use client';
import { useState } from 'react';
import { Mail, Lock, Shield, ArrowRight, Building, Globe } from 'lucide-react';
import { useRouter } from 'next/navigation';
export default function RegisterPage() {
const [name, setName] = useState('');
const [slug, setSlug] = useState('');
const [email, setEmail] = useState('');
const [password, setPassword] = useState('');
const [loading, setLoading] = useState(false);
const router = useRouter();
const handleRegister = (e: React.FormEvent) => {
e.preventDefault();
setLoading(true);
// Simulação do provisionamento do novo tenant do SaaS
setTimeout(() => {
setLoading(false);
// Salva o novo tenant simulado nos cookies ou localstorage para o login funcionar
localStorage.setItem('agendapro_token', 'demo_token');
localStorage.setItem('agendapro_tenant_name', name);
localStorage.setItem('agendapro_plan', 'Starter'); // Plano inicial trial
// Cria cookie para o admin enxergar o novo tenant se necessário
const date = new Date();
date.setTime(date.getTime() + (7*24*60*60*1000));
document.cookie = "agendapro_new_tenant=" + encodeURIComponent(name) + "; expires=" + date.toUTCString() + "; path=/";
router.push('/');
}, 1500);
};
const handleNameChange = (val: string) => {
setName(val);
// Gera o slug automaticamente baseado no nome
setSlug(val.toLowerCase()
.normalize('NFD')
.replace(/[\u0300-\u036f]/g, '')
.replace(/[^a-z0-9\s-]/g, '')
.replace(/\s+/g, '-')
.replace(/-+/g, '-'));
};
return (
<div style={{ minHeight: '100vh', display: 'flex', background: 'var(--bg-primary)' }}>
{/* Lado Esquerdo - Decorativo */}
<div style={{
flex: 1, background: 'var(--gradient-primary)', position: 'relative', overflow: 'hidden',
display: 'flex', flexDirection: 'column', alignItems: 'center', justifyContent: 'center', padding: '40px'
}}>
<div style={{ position: 'absolute', inset: 0, background: 'radial-gradient(circle at 30% 50%, rgba(255,255,255,0.1), transparent 60%)' }} />
<div style={{ position: 'relative', zIndex: 1, textAlign: 'center', color: 'white', maxWidth: '400px' }}>
<div style={{ width: 80, height: 80, background: 'rgba(255,255,255,0.2)', borderRadius: 'var(--radius-xl)', backdropFilter: 'blur(10px)', display: 'flex', alignItems: 'center', justifyContent: 'center', margin: '0 auto 32px' }}>
<Shield size={40} color="white" />
</div>
<h1 style={{ fontSize: '36px', fontWeight: 900, letterSpacing: '-1px', marginBottom: '16px' }}>AgendaPRO</h1>
<p style={{ fontSize: '16px', opacity: 0.9, lineHeight: 1.6 }}>Cadastre seu estabelecimento em segundos e comece a automatizar seus agendamentos de forma inteligente.</p>
</div>
</div>
{/* Lado Direito - Formulário */}
<div style={{ flex: 1, display: 'flex', alignItems: 'center', justifyContent: 'center', padding: '40px' }}>
<div style={{ width: '100%', maxWidth: '400px', animation: 'slideUp 0.5s ease' }}>
<h2 style={{ fontSize: '28px', fontWeight: 800, marginBottom: '8px' }}>Crie sua conta SaaS</h2>
<p style={{ color: 'var(--text-secondary)', marginBottom: '32px' }}>Preencha os dados do seu estabelecimento</p>
<form onSubmit={handleRegister} style={{ display: 'flex', flexDirection: 'column', gap: '18px' }}>
<div className="form-group" style={{ margin: 0 }}>
<label className="form-label" style={{ display: 'flex', alignItems: 'center', gap: 6 }}><Building size={14} /> Nome do Estabelecimento *</label>
<input type="text" required className="form-input" placeholder="Minha Barbearia Premium"
value={name} onChange={e => handleNameChange(e.target.value)} />
</div>
<div className="form-group" style={{ margin: 0 }}>
<label className="form-label" style={{ display: 'flex', alignItems: 'center', gap: 6 }}><Globe size={14} /> Link Público Desejado</label>
<div style={{ position: 'relative', display: 'flex', alignItems: 'center' }}>
<span style={{ position: 'absolute', left: 14, fontSize: '13px', color: 'var(--text-muted)' }}>agenda.pro/</span>
<input type="text" required className="form-input" placeholder="minha-barbearia"
style={{ paddingLeft: '96px' }}
value={slug} onChange={e => setSlug(e.target.value)} />
</div>
</div>
<div className="form-group" style={{ margin: 0 }}>
<label className="form-label" style={{ display: 'flex', alignItems: 'center', gap: 6 }}><Mail size={14} /> E-mail Profissional *</label>
<input type="email" required className="form-input" placeholder="contato@barbearia.com"
value={email} onChange={e => setEmail(e.target.value)} />
</div>
<div className="form-group" style={{ margin: 0 }}>
<label className="form-label" style={{ display: 'flex', alignItems: 'center', gap: 6 }}><Lock size={14} /> Crie uma Senha *</label>
<input type="password" required className="form-input" placeholder="••••••••"
value={password} onChange={e => setPassword(e.target.value)} />
</div>
<button type="submit" className="btn btn-primary" disabled={loading}
style={{ width: '100%', padding: '12px', fontSize: '16px', justifyContent: 'center', marginTop: '12px' }}>
{loading ? 'Criando Conta...' : <><ArrowRight size={18} /> Começar Gratuitamente</>}
</button>
</form>
<p style={{ textAlign: 'center', marginTop: '32px', fontSize: '13px', color: 'var(--text-muted)' }}>
tem uma conta? <a href="/login" style={{ color: 'var(--accent)', fontWeight: 600, textDecoration: 'none' }}>Faça login</a>
</p>
</div>
</div>
</div>
);
}

274
src/components/Agenda.tsx Normal file
View File

@ -0,0 +1,274 @@
import { useState, useEffect } from 'react';
import { Plus, ChevronLeft, ChevronRight, X, Clock, User, Calendar } from 'lucide-react';
const professionals = [
{ id: '1', name: 'Carlos Silva', color: '#6C63FF' },
{ id: '2', name: 'Rafael Santos', color: '#a855f7' },
];
const mockAppointments = [
{ id: '1', professionalId: '1', client: 'João Mendes', service: 'Corte Masculino', startHour: 10, startMin: 0, duration: 40, status: 'confirmed', price: 55 },
{ id: '2', professionalId: '1', client: 'Pedro Oliveira', service: 'Combo Corte + Barba', startHour: 11, startMin: 0, duration: 60, status: 'confirmed', price: 85 },
{ id: '3', professionalId: '2', client: 'Lucas Ferreira', service: 'Barba Completa', startHour: 14, startMin: 0, duration: 30, status: 'pending', price: 40 },
{ id: '4', professionalId: '2', client: 'Gabriel Souza', service: 'Corte Masculino', startHour: 10, startMin: 0, duration: 40, status: 'completed', price: 55 },
{ id: '5', professionalId: '1', client: 'Mateus Costa', service: 'Design de Sobrancelha', startHour: 15, startMin: 30, duration: 15, status: 'confirmed', price: 25 },
];
const hours = Array.from({ length: 12 }, (_, i) => i + 8);
const statusColors: Record<string, string> = {
confirmed: 'var(--accent-glow)',
pending: 'var(--warning-bg)',
completed: 'var(--success-bg)',
cancelled: 'var(--danger-bg)',
};
const statusBorders: Record<string, string> = {
confirmed: 'var(--accent)',
pending: 'var(--warning)',
completed: 'var(--success)',
cancelled: 'var(--danger)',
};
export default function Agenda() {
const [showModal, setShowModal] = useState(false);
const today = new Date();
const [currentDate, setCurrentDate] = useState(today);
const [appointments, setAppointments] = useState(mockAppointments);
useEffect(() => {
const activeSlug = localStorage.getItem('agendapro_tenant_slug') || 'barbearia-premium';
const savedAppts = localStorage.getItem(`agendapro_appointments_${activeSlug}`);
if (savedAppts) {
setAppointments(JSON.parse(savedAppts));
} else {
const savedName = localStorage.getItem('agendapro_tenant_name');
if (savedName) {
setAppointments([]);
localStorage.setItem(`agendapro_appointments_${activeSlug}`, JSON.stringify([]));
} else {
setAppointments(mockAppointments);
}
}
}, []);
// States do Modal de Novo Agendamento
const [newClient, setNewClient] = useState('');
const [newPhone, setNewPhone] = useState('');
const [newService, setNewService] = useState('Corte Masculino - R$ 55,00 (40min)');
const [newProf, setNewProf] = useState('1');
const [newTime, setNewTime] = useState('10:00');
const dateStr = currentDate.toLocaleDateString('pt-BR', { weekday: 'long', day: 'numeric', month: 'long', year: 'numeric' });
const changeDate = (days: number) => {
const newDate = new Date(currentDate);
newDate.setDate(newDate.getDate() + days);
setCurrentDate(newDate);
};
const goToToday = () => {
setCurrentDate(new Date());
};
return (
<div className="slide-up">
<div className="flex-between mb-6">
<div className="flex-gap">
<button className="btn btn-ghost btn-icon" onClick={() => changeDate(-1)}><ChevronLeft size={20} /></button>
<h2 style={{ fontSize: '16px', fontWeight: 600, textTransform: 'capitalize' }}>{dateStr}</h2>
<button className="btn btn-ghost btn-icon" onClick={() => changeDate(1)}><ChevronRight size={20} /></button>
</div>
<div className="flex-gap">
<button className="btn btn-secondary btn-sm" onClick={goToToday}>Hoje</button>
<button className="btn btn-primary" onClick={() => setShowModal(true)}>
<Plus size={16} /> Novo Agendamento
</button>
</div>
</div>
<div className="card" style={{ padding: 0, overflow: 'hidden' }}>
<div style={{ display: 'grid', gridTemplateColumns: '70px repeat(2, 1fr)', borderBottom: '1px solid var(--border-color)' }}>
<div style={{ padding: '14px', borderRight: '1px solid var(--border-color)' }} />
{professionals.map(p => (
<div key={p.id} style={{
padding: '14px 16px', textAlign: 'center', borderRight: '1px solid var(--border-color)',
background: 'var(--bg-secondary)', fontWeight: 600, fontSize: '14px'
}}>
<div className="flex-center" style={{ gap: '8px' }}>
<div style={{ width: 10, height: 10, borderRadius: '50%', background: p.color }} />
{p.name}
</div>
</div>
))}
</div>
<div style={{ display: 'grid', gridTemplateColumns: '70px repeat(2, 1fr)', position: 'relative' }}>
<div>
{hours.map(h => (
<div key={h} style={{
height: '120px', display: 'flex', alignItems: 'flex-start', justifyContent: 'flex-end',
paddingRight: '12px', paddingTop: '2px', fontSize: '12px', color: 'var(--text-muted)',
borderRight: '1px solid var(--border-color)', borderBottom: '1px solid var(--border-color)'
}}>
{String(h).padStart(2, '0')}:00
</div>
))}
</div>
{professionals.map(prof => {
// Lógica simples para simular agendamentos diferentes em dias diferentes
const daySeed = currentDate.getDate();
const isToday = daySeed === new Date().getDate();
// Se for hoje, mostra tudo que for do profissional. Se não, filtra baseado no dia para parecer que mudou.
const visibleAppointments = appointments.filter(a =>
a.professionalId === prof.id && (isToday || (parseInt(a.id) + daySeed) % 2 === 0)
);
return (
<div key={prof.id} style={{ position: 'relative', borderRight: '1px solid var(--border-color)' }}>
{hours.map(h => (
<div key={h} style={{ height: '120px', borderBottom: '1px solid var(--border-color)' }} />
))}
{visibleAppointments.map(apt => {
const scale = 2; // 2px per minute
const top = (apt.startHour - 8) * 60 * scale + (apt.startMin * scale);
const height = apt.duration * scale;
const isSmall = apt.duration <= 20;
return (
<div key={apt.id} style={{
position: 'absolute', top: `${top}px`, left: '4px', right: '4px',
height: `${height}px`, borderRadius: 'var(--radius-sm)',
background: statusColors[apt.status],
borderLeft: `3px solid ${statusBorders[apt.status]}`,
padding: isSmall ? '4px 8px' : '6px 10px', cursor: 'pointer', overflow: 'hidden',
transition: 'all 0.2s', zIndex: 2, display: 'flex', flexDirection: isSmall ? 'row' : 'column',
alignItems: isSmall ? 'center' : 'flex-start', gap: isSmall ? '8px' : '0'
}}
onMouseEnter={e => { (e.currentTarget as HTMLElement).style.transform = 'scale(1.02)'; (e.currentTarget as HTMLElement).style.zIndex = '10'; }}
onMouseLeave={e => { (e.currentTarget as HTMLElement).style.transform = 'scale(1)'; (e.currentTarget as HTMLElement).style.zIndex = '2'; }}
>
<div style={{ fontWeight: 600, fontSize: '12px', whiteSpace: 'nowrap', overflow: 'hidden', textOverflow: 'ellipsis' }}>
{apt.client}
</div>
<div style={{ fontSize: '11px', opacity: 0.8, whiteSpace: 'nowrap', overflow: 'hidden', textOverflow: 'ellipsis' }}>
{isSmall ? `- ${apt.service}` : `${apt.service} • R$ ${apt.price}`}
</div>
{!isSmall && height >= 60 && (
<div style={{ fontSize: '10px', opacity: 0.7, marginTop: '2px' }}>
{String(apt.startHour).padStart(2, '0')}:{String(apt.startMin).padStart(2, '0')} - {apt.duration}min
</div>
)}
</div>
);
})}
</div>
);
})}
</div>
</div>
{showModal && (
<div className="modal-overlay" onClick={() => setShowModal(false)}>
<div className="modal" onClick={e => e.stopPropagation()}>
<div className="modal-header">
<h2 className="modal-title">Novo Agendamento</h2>
<button className="btn-ghost btn-icon" onClick={() => setShowModal(false)}><X size={20} /></button>
</div>
<div className="modal-body">
<div className="form-group">
<label className="form-label"><User size={14} style={{ display: 'inline', marginRight: 4 }} />Cliente</label>
<input className="form-input" placeholder="Nome do cliente" value={newClient} onChange={e => setNewClient(e.target.value)} />
</div>
<div className="grid-2">
<div className="form-group">
<label className="form-label">Telefone</label>
<input className="form-input" placeholder="(00) 00000-0000" value={newPhone} onChange={e => setNewPhone(e.target.value)} />
</div>
<div className="form-group">
<label className="form-label">E-mail</label>
<input className="form-input" placeholder="email@exemplo.com" />
</div>
</div>
<div className="form-group">
<label className="form-label"><Scissors size={14} style={{ display: 'inline', marginRight: 4 }} />Serviço</label>
<select className="form-select" value={newService} onChange={e => setNewService(e.target.value)}>
<option>Corte Masculino - R$ 55,00 (40min)</option>
<option>Barba Completa - R$ 40,00 (30min)</option>
<option>Combo Corte + Barba - R$ 85,00 (60min)</option>
<option>Design de Sobrancelha - R$ 25,00 (15min)</option>
<option>Corte Infantil - R$ 40,00 (30min)</option>
</select>
</div>
<div className="form-group">
<label className="form-label">Profissional</label>
<select className="form-select" value={newProf} onChange={e => setNewProf(e.target.value)}>
<option value="1">Carlos Silva</option>
<option value="2">Rafael Santos</option>
</select>
</div>
<div className="grid-2">
<div className="form-group">
<label className="form-label"><Calendar size={14} style={{ display: 'inline', marginRight: 4 }} />Data</label>
<input className="form-input" type="date" defaultValue={today.toISOString().split('T')[0]} />
</div>
<div className="form-group">
<label className="form-label"><Clock size={14} style={{ display: 'inline', marginRight: 4 }} />Horário</label>
<input className="form-input" type="time" value={newTime} onChange={e => setNewTime(e.target.value)} />
</div>
</div>
<div className="form-group">
<label className="form-label">Observações</label>
<textarea className="form-textarea" placeholder="Alguma observação especial..." />
</div>
</div>
<div className="modal-footer">
<button className="btn btn-secondary" onClick={() => setShowModal(false)}>Cancelar</button>
<button className="btn btn-primary" onClick={() => {
const [h, m] = newTime.split(':').map(Number);
const serviceName = newService.split(' - ')[0];
const priceMatch = newService.match(/R\$ (\d+)/);
const price = priceMatch ? parseInt(priceMatch[1]) : 50;
const durMatch = newService.match(/\((\d+)min\)/);
const duration = durMatch ? parseInt(durMatch[1]) : 30;
const newApt = {
id: String(Date.now()),
professionalId: newProf,
client: newClient || 'Cliente Avulso',
service: serviceName,
startHour: h,
startMin: m,
duration: duration,
status: 'confirmed',
price: price
};
// Volta pra hoje pra ver o agendamento cair no grid
const activeSlug = localStorage.getItem('agendapro_tenant_slug') || 'barbearia-premium';
const updated = [...appointments, newApt];
setCurrentDate(new Date());
setAppointments(updated);
localStorage.setItem(`agendapro_appointments_${activeSlug}`, JSON.stringify(updated));
setShowModal(false);
// Reseta form
setNewClient('');
setNewPhone('');
}}>Agendar</button>
</div>
</div>
</div>
)}
</div>
);
}
function Scissors(props: { size: number; style?: React.CSSProperties }) {
return (
<svg width={props.size} height={props.size} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" style={props.style}>
<circle cx="6" cy="6" r="3"/><path d="M8.12 8.12 12 12"/><path d="M20 4 8.12 15.88"/><circle cx="6" cy="18" r="3"/><path d="M14.8 14.8 20 20"/>
</svg>
);
}

199
src/components/Clients.tsx Normal file
View File

@ -0,0 +1,199 @@
import { useState, useEffect } from 'react';
import { Plus, Search, Phone, Mail, X, Edit, Trash2, AlertTriangle } from 'lucide-react';
interface Client {
id: string; name: string; email: string; phone: string; cpf: string; birthDate: string; notes: string; visits: number; spent: number; lastVisit: string;
}
const initialClients: Client[] = [
{ id: '1', name: 'João Mendes', email: 'joao@email.com', phone: '(11) 97777-1111', cpf: '123.456.789-00', birthDate: '1990-05-15', notes: 'Cliente VIP', visits: 12, spent: 780, lastVisit: '2026-05-30' },
{ id: '2', name: 'Pedro Oliveira', email: 'pedro@email.com', phone: '(11) 97777-2222', cpf: '', birthDate: '', notes: '', visits: 8, spent: 520, lastVisit: '2026-05-28' },
{ id: '3', name: 'Lucas Ferreira', email: 'lucas@email.com', phone: '(11) 97777-3333', cpf: '', birthDate: '', notes: '', visits: 5, spent: 275, lastVisit: '2026-05-25' },
{ id: '4', name: 'Mateus Costa', email: 'mateus@email.com', phone: '(11) 97777-4444', cpf: '', birthDate: '', notes: '', visits: 3, spent: 165, lastVisit: '2026-05-20' },
{ id: '5', name: 'Gabriel Souza', email: 'gabriel@email.com', phone: '(11) 97777-5555', cpf: '', birthDate: '', notes: 'Prefere corte degradê', visits: 15, spent: 1120, lastVisit: '2026-05-31' },
{ id: '6', name: 'André Lima', email: 'andre@email.com', phone: '(11) 97777-6666', cpf: '', birthDate: '', notes: '', visits: 22, spent: 1540, lastVisit: '2026-05-31' },
{ id: '7', name: 'Ricardo Alves', email: 'ricardo@email.com', phone: '(11) 97777-7777', cpf: '', birthDate: '', notes: '', visits: 6, spent: 330, lastVisit: '2026-05-27' },
];
const emptyClient: Client = { id: '', name: '', email: '', phone: '', cpf: '', birthDate: '', notes: '', visits: 0, spent: 0, lastVisit: '' };
export default function Clients() {
const [clients, setClients] = useState<Client[]>(initialClients);
const [search, setSearch] = useState('');
const [showModal, setShowModal] = useState(false);
const [editingClient, setEditingClient] = useState<Client | null>(null);
const [formData, setFormData] = useState<Client>(emptyClient);
const [deleteConfirm, setDeleteConfirm] = useState<string | null>(null);
useEffect(() => {
const savedName = localStorage.getItem('agendapro_tenant_name');
if (savedName) {
setClients([]);
}
}, []);
const cleanSearch = search.replace(/\D/g, '');
const filtered = clients.filter(c =>
c.name.toLowerCase().includes(search.toLowerCase()) ||
(cleanSearch && c.phone.replace(/\D/g, '').includes(cleanSearch)) ||
c.email.toLowerCase().includes(search.toLowerCase())
);
const openAdd = () => {
setEditingClient(null);
setFormData({ ...emptyClient, id: Date.now().toString() });
setShowModal(true);
};
const openEdit = (client: Client) => {
setEditingClient(client);
setFormData({ ...client });
setShowModal(true);
};
const handleSave = () => {
if (!formData.name.trim()) return;
if (editingClient) {
setClients(prev => prev.map(c => c.id === editingClient.id ? { ...formData } : c));
} else {
setClients(prev => [...prev, { ...formData, lastVisit: new Date().toISOString().split('T')[0] }]);
}
setShowModal(false);
setEditingClient(null);
};
const handleDelete = (id: string) => {
setClients(prev => prev.filter(c => c.id !== id));
setDeleteConfirm(null);
};
const updateForm = (field: keyof Client, value: string) => {
setFormData(prev => ({ ...prev, [field]: value }));
};
return (
<div className="slide-up">
<div className="flex-between mb-6">
<div style={{ position: 'relative' }}>
<Search size={18} style={{ position: 'absolute', left: 14, top: '50%', transform: 'translateY(-50%)', color: 'var(--text-muted)', pointerEvents: 'none' }} />
<input className="form-input" placeholder="Buscar cliente por nome, telefone ou e-mail..."
style={{ width: '380px', paddingLeft: '42px' }} value={search} onChange={e => setSearch(e.target.value)} />
</div>
<button className="btn btn-primary" onClick={openAdd}>
<Plus size={16} /> Novo Cliente
</button>
</div>
<div className="card" style={{ padding: 0 }}>
<div className="table-container">
<table>
<thead>
<tr>
<th>Cliente</th>
<th>Contato</th>
<th>Visitas</th>
<th>Total Gasto</th>
<th>Última Visita</th>
<th style={{ width: 80 }}>Ações</th>
</tr>
</thead>
<tbody>
{filtered.map(c => (
<tr key={c.id}>
<td>
<div className="flex-gap">
<div className="avatar">{c.name.split(' ').map(n => n[0]).join('').substring(0, 2)}</div>
<div>
<div style={{ fontWeight: 600 }}>{c.name}</div>
<div style={{ fontSize: '12px', color: 'var(--text-muted)' }}>{c.visits} visitas</div>
</div>
</div>
</td>
<td>
<div style={{ display: 'flex', flexDirection: 'column', gap: '2px' }}>
<span style={{ fontSize: '13px', display: 'flex', alignItems: 'center', gap: '4px' }}>
<Phone size={12} color="var(--text-muted)" /> {c.phone}
</span>
<span style={{ fontSize: '12px', color: 'var(--text-secondary)', display: 'flex', alignItems: 'center', gap: '4px' }}>
<Mail size={12} /> {c.email}
</span>
</div>
</td>
<td><span className="badge badge-purple">{c.visits}</span></td>
<td style={{ fontWeight: 600 }}>R$ {c.spent.toFixed(2)}</td>
<td style={{ color: 'var(--text-secondary)', fontSize: '13px' }}>
{c.lastVisit ? new Date(c.lastVisit + 'T12:00:00').toLocaleDateString('pt-BR') : '-'}
</td>
<td>
{deleteConfirm === c.id ? (
<div style={{ display: 'flex', gap: '4px' }}>
<button className="btn btn-sm btn-danger" onClick={() => handleDelete(c.id)} title="Confirmar exclusão">Sim</button>
<button className="btn btn-sm btn-secondary" onClick={() => setDeleteConfirm(null)} title="Cancelar">Não</button>
</div>
) : (
<div style={{ display: 'flex', gap: '4px' }}>
<button className="btn-ghost btn-icon" title="Editar" onClick={() => openEdit(c)}><Edit size={14} /></button>
<button className="btn-ghost btn-icon" title="Excluir" style={{ color: 'var(--danger)' }}
onClick={() => setDeleteConfirm(c.id)}><Trash2 size={14} /></button>
</div>
)}
</td>
</tr>
))}
{filtered.length === 0 && (
<tr><td colSpan={6} style={{ textAlign: 'center', padding: '40px', color: 'var(--text-muted)' }}>Nenhum cliente encontrado</td></tr>
)}
</tbody>
</table>
</div>
</div>
{showModal && (
<div className="modal-overlay" onClick={() => setShowModal(false)}>
<div className="modal" onClick={e => e.stopPropagation()}>
<div className="modal-header">
<h2 className="modal-title">{editingClient ? 'Editar Cliente' : 'Novo Cliente'}</h2>
<button className="btn-ghost btn-icon" onClick={() => setShowModal(false)}><X size={20} /></button>
</div>
<div className="modal-body">
<div className="form-group">
<label className="form-label">Nome completo *</label>
<input className="form-input" placeholder="Nome do cliente" value={formData.name} onChange={e => updateForm('name', e.target.value)} />
</div>
<div className="grid-2">
<div className="form-group">
<label className="form-label">Telefone</label>
<input className="form-input" placeholder="(00) 00000-0000" value={formData.phone} onChange={e => updateForm('phone', e.target.value)} />
</div>
<div className="form-group">
<label className="form-label">E-mail</label>
<input className="form-input" placeholder="email@exemplo.com" value={formData.email} onChange={e => updateForm('email', e.target.value)} />
</div>
</div>
<div className="grid-2">
<div className="form-group">
<label className="form-label">CPF</label>
<input className="form-input" placeholder="000.000.000-00" value={formData.cpf} onChange={e => updateForm('cpf', e.target.value)} />
</div>
<div className="form-group">
<label className="form-label">Data de Nascimento</label>
<input className="form-input" type="date" value={formData.birthDate} onChange={e => updateForm('birthDate', e.target.value)} />
</div>
</div>
<div className="form-group">
<label className="form-label">Observações</label>
<textarea className="form-textarea" placeholder="Preferências, alergias, etc..." value={formData.notes} onChange={e => updateForm('notes', e.target.value)} />
</div>
</div>
<div className="modal-footer">
<button className="btn btn-secondary" onClick={() => setShowModal(false)}>Cancelar</button>
<button className="btn btn-primary" onClick={handleSave}>
{editingClient ? 'Salvar Alterações' : 'Salvar Cliente'}
</button>
</div>
</div>
</div>
)}
</div>
);
}

View File

@ -0,0 +1,278 @@
'use client';
import { useState, useEffect } from 'react';
import { Calendar, Users, DollarSign, TrendingUp, Clock, ArrowUpRight, Play, CheckSquare } from 'lucide-react';
interface DashboardProps {
onNavigate: (page: string) => void;
}
const todayAppointments = [
{ id: 1, time: '10:00', client: 'João Mendes', service: 'Corte Masculino', professional: 'Carlos Silva', status: 'confirmed', price: 55 },
{ id: 2, time: '11:00', client: 'Pedro Oliveira', service: 'Combo Corte + Barba', professional: 'Carlos Silva', status: 'confirmed', price: 85 },
{ id: 3, time: '14:00', client: 'Lucas Ferreira', service: 'Barba Completa', professional: 'Rafael Santos', status: 'pending', price: 40 },
{ id: 4, time: '15:30', client: 'Mateus Costa', service: 'Corte Masculino', professional: 'Rafael Santos', status: 'confirmed', price: 55 },
{ id: 5, time: '16:00', client: 'Gabriel Souza', service: 'Design de Sobrancelha', professional: 'Carlos Silva', status: 'confirmed', price: 25 },
];
const statusMap: Record<string, { label: string; cls: string }> = {
confirmed: { label: 'Confirmado', cls: 'badge-info' },
pending: { label: 'Pendente', cls: 'badge-warning' },
completed: { label: 'Concluído', cls: 'badge-success' },
cancelled: { label: 'Cancelado', cls: 'badge-danger' },
serving: { label: 'Em Atendimento', cls: 'badge-purple' },
};
export default function Dashboard({ onNavigate }: DashboardProps) {
const [appointments, setAppointments] = useState(todayAppointments);
const [currentServingId, setCurrentServingId] = useState<number | null>(null);
const [isNew, setIsNew] = useState(false);
useEffect(() => {
const savedName = localStorage.getItem('agendapro_tenant_name');
if (savedName) {
setIsNew(true);
setAppointments([]);
}
}, []);
const handleStartServing = (id: number) => {
setCurrentServingId(id);
setAppointments(prev => prev.map(a => a.id === id ? { ...a, status: 'serving' } : a));
};
const handleFinishServing = (id: number) => {
setCurrentServingId(null);
setAppointments(prev => prev.map(a => a.id === id ? { ...a, status: 'completed' } : a));
};
const servingApt = appointments.find(a => a.id === currentServingId);
const activeQueue = appointments.filter(a => a.status !== 'completed' && a.id !== currentServingId);
const nextInQueue = activeQueue[0];
// Cálculos dinâmicos de estatísticas
const appointmentsCount = isNew ? appointments.length : 24;
const completedRevenue = appointments.filter(a => a.status === 'completed' || a.status === 'serving').reduce((s, a) => s + a.price, 0);
const revenueValue = isNew ? completedRevenue : 2450;
const activeClientsCount = isNew ? appointments.length : 156;
const occupancyPercentage = isNew ? (appointments.length > 0 ? Math.min(100, Math.round((appointments.length / 8) * 100)) : 0) : 92;
return (
<div className="slide-up">
<style>{`
@keyframes pulse {
0% { opacity: 0.6; }
50% { opacity: 1; }
100% { opacity: 0.6; }
}
.pulse {
animation: pulse 1.5s infinite !important;
background: #a855f7 !important;
color: white !important;
}
`}</style>
{/* Painel de Fila de Atendimento */}
<div className="card mb-6" style={{ background: 'var(--bg-secondary)', borderLeft: '4px solid var(--accent)', padding: '20px' }}>
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', gap: '20px', flexWrap: 'wrap' }}>
<div>
<span className="badge badge-purple" style={{ marginBottom: '8px', fontSize: '11px', fontWeight: 700 }}>Fila de Atendimento</span>
{servingApt ? (
<div>
<h3 style={{ fontSize: '18px', fontWeight: 800, color: 'var(--text-primary)' }}>
Atendendo agora: <span style={{ color: 'var(--accent)' }}>{servingApt.client}</span>
</h3>
<p style={{ fontSize: '13px', color: 'var(--text-secondary)', marginTop: '4px' }}>
{servingApt.service} com {servingApt.professional} R$ {servingApt.price.toFixed(2)}
</p>
</div>
) : nextInQueue ? (
<div>
<h3 style={{ fontSize: '18px', fontWeight: 800, color: 'var(--text-primary)' }}>
Próximo da Fila: <span style={{ color: 'var(--accent)' }}>{nextInQueue.client}</span>
</h3>
<p style={{ fontSize: '13px', color: 'var(--text-secondary)', marginTop: '4px' }}>
Agendado para {nextInQueue.time} ({nextInQueue.service})
</p>
</div>
) : (
<div>
<h3 style={{ fontSize: '16px', fontWeight: 700, color: 'var(--text-muted)' }}>Nenhum cliente na fila de espera</h3>
<p style={{ fontSize: '13px', color: 'var(--text-muted)', marginTop: '4px' }}>Todos os agendamentos de hoje foram concluídos.</p>
</div>
)}
</div>
<div>
{servingApt ? (
<button className="btn btn-danger" style={{ gap: '8px' }} onClick={() => handleFinishServing(servingApt.id)}>
<CheckSquare size={16} /> Finalizar Atendimento
</button>
) : nextInQueue ? (
<button className="btn btn-primary" style={{ gap: '8px' }} onClick={() => handleStartServing(nextInQueue.id)}>
<Play size={16} /> Iniciar Atendimento
</button>
) : null}
</div>
</div>
{/* Sub-painel da Fila quando alguém já está sendo atendido */}
{servingApt && (
<div style={{ marginTop: '16px', paddingTop: '12px', borderTop: '1px solid var(--border-color)', display: 'flex', justifyContent: 'space-between', alignItems: 'center', fontSize: '13px', flexWrap: 'wrap', gap: '10px' }}>
<span style={{ color: 'var(--text-secondary)' }}>
<strong>Próximo da Fila:</strong> {activeQueue[0] ? `${activeQueue[0].client} (às ${activeQueue[0].time})` : 'Ninguém aguardando'}
</span>
{activeQueue[0] && (
<button className="btn btn-ghost btn-sm" style={{ padding: '2px 8px', fontSize: '12px', color: 'var(--accent)', height: 'auto', minHeight: 'unset' }} onClick={() => {
handleFinishServing(servingApt.id);
handleStartServing(activeQueue[0].id);
}}>
Chamar Próximo
</button>
)}
</div>
)}
</div>
<div className="stats-grid">
<div className="stat-card">
<div className="stat-icon purple"><Calendar size={24} /></div>
<div>
<div className="stat-value">{appointmentsCount}</div>
<div className="stat-label">Agendamentos Hoje</div>
<div className="stat-change up">{isNew ? '0% esta semana' : '↑ 12% vs semana anterior'}</div>
</div>
</div>
<div className="stat-card">
<div className="stat-icon green"><DollarSign size={24} /></div>
<div>
<div className="stat-value">R$ {revenueValue.toLocaleString('pt-BR', { minimumFractionDigits: 2, maximumFractionDigits: 2 })}</div>
<div className="stat-label">Receita do Dia</div>
<div className="stat-change up">{isNew ? '0% hoje' : '↑ 8% vs ontem'}</div>
</div>
</div>
<div className="stat-card">
<div className="stat-icon orange"><Users size={24} /></div>
<div>
<div className="stat-value">{activeClientsCount}</div>
<div className="stat-label">Clientes Ativos</div>
<div className="stat-change up">{isNew ? '0 novos clientes' : '↑ 5 novos esta semana'}</div>
</div>
</div>
<div className="stat-card">
<div className="stat-icon blue"><TrendingUp size={24} /></div>
<div>
<div className="stat-value">{occupancyPercentage}%</div>
<div className="stat-label">Taxa de Ocupação</div>
<div className="stat-change up">{isNew ? 'Com base na agenda' : '↑ 3% vs mês anterior'}</div>
</div>
</div>
</div>
<div style={{ display: 'grid', gridTemplateColumns: '1fr 360px', gap: '24px' }}>
<div className="card">
<div className="card-header">
<h2 className="card-title" style={{ display: 'flex', alignItems: 'center', gap: '8px' }}>
<Clock size={18} /> Agendamentos de Hoje
</h2>
<button className="btn btn-sm btn-secondary" onClick={() => onNavigate('agenda')}>
Ver Agenda <ArrowUpRight size={14} />
</button>
</div>
<div className="table-container">
<table>
<thead>
<tr>
<th>Horário</th>
<th>Cliente</th>
<th>Serviço</th>
<th>Profissional</th>
<th>Status</th>
<th>Valor</th>
</tr>
</thead>
<tbody>
{appointments.map(apt => {
const isServing = apt.status === 'serving';
const isCompleted = apt.status === 'completed';
return (
<tr key={apt.id} style={{ opacity: isCompleted ? 0.5 : 1, transition: 'opacity 0.3s' }}>
<td style={{ fontWeight: 600 }}>{apt.time}</td>
<td>
<div className="flex-gap gap-sm">
<div className="avatar" style={{ width: 28, height: 28, fontSize: 11 }}>
{apt.client.split(' ').map(n => n[0]).join('')}
</div>
{apt.client}
</div>
</td>
<td>{apt.service}</td>
<td>{apt.professional}</td>
<td>
<span className={`badge ${statusMap[apt.status].cls} ${isServing ? 'pulse' : ''}`}>
{statusMap[apt.status].label}
</span>
</td>
<td style={{ fontWeight: 600 }}>R$ {apt.price.toFixed(2)}</td>
</tr>
);
})}
</tbody>
</table>
</div>
</div>
<div style={{ display: 'flex', flexDirection: 'column', gap: '20px' }}>
<div className="card">
<h3 className="card-title mb-4">Desempenho Semanal</h3>
<div style={{ display: 'flex', flexDirection: 'column', gap: '16px' }}>
{[
{ label: 'Seg', value: isNew ? 0 : 85 },
{ label: 'Ter', value: isNew ? 0 : 72 },
{ label: 'Qua', value: isNew ? 0 : 90 },
{ label: 'Qui', value: isNew ? 0 : 65 },
{ label: 'Sex', value: isNew ? 0 : 95 },
{ label: 'Sáb', value: isNew ? 0 : 100 },
].map((d) => (
<div key={d.label} style={{ display: 'flex', alignItems: 'center', gap: '12px' }}>
<span style={{ width: '30px', fontSize: '12px', color: 'var(--text-secondary)' }}>{d.label}</span>
<div style={{ flex: 1, height: '8px', background: 'var(--bg-elevated)', borderRadius: '4px', overflow: 'hidden' }}>
<div style={{
width: `${d.value}%`, height: '100%',
background: 'var(--gradient-primary)',
borderRadius: '4px',
transition: 'width 1s ease'
}} />
</div>
<span style={{ fontSize: '12px', fontWeight: 600, width: '35px' }}>{d.value}%</span>
</div>
))}
</div>
</div>
<div className="card">
<h3 className="card-title mb-4">Próximos Horários Livres</h3>
<div style={{ display: 'flex', flexDirection: 'column', gap: '10px' }}>
{[
{ time: '13:00 - 14:00', prof: 'Carlos Silva' },
{ time: '14:30 - 15:30', prof: 'Carlos Silva' },
{ time: '16:00 - 17:00', prof: 'Rafael Santos' },
].map((slot, i) => (
<div key={i} style={{
display: 'flex', justifyContent: 'space-between', alignItems: 'center',
padding: '10px 14px', background: 'var(--bg-primary)', borderRadius: 'var(--radius-sm)',
border: '1px solid var(--border-color)'
}}>
<div>
<div style={{ fontSize: '14px', fontWeight: 600 }}>{slot.time}</div>
<div style={{ fontSize: '12px', color: 'var(--text-secondary)' }}>{slot.prof}</div>
</div>
<button className="btn btn-sm btn-primary" onClick={() => onNavigate('agenda')}>Agendar</button>
</div>
))}
</div>
</div>
</div>
</div>
</div>
);
}

189
src/components/Services.tsx Normal file
View File

@ -0,0 +1,189 @@
import { useState, useEffect } from 'react';
import { Plus, X, Clock, DollarSign, Edit, Trash2, AlertTriangle } from 'lucide-react';
const categories = [
{ id: '1', name: 'Cabelo', icon: '✂️' },
{ id: '2', name: 'Barba', icon: '🧔' },
{ id: '3', name: 'Combos', icon: '⭐' },
{ id: '4', name: 'Manicure', icon: '💅' },
{ id: '5', name: 'Massagem', icon: '💆' },
];
interface Service {
id: string; catId: string; name: string; desc: string; duration: number; price: number; active: boolean;
}
const initialServices: Service[] = [
{ id: '1', catId: '1', name: 'Corte Masculino', desc: 'Corte moderno com lavagem e finalização', duration: 40, price: 55, active: true },
{ id: '2', catId: '1', name: 'Corte Infantil', desc: 'Corte para crianças até 12 anos', duration: 30, price: 40, active: true },
{ id: '3', catId: '2', name: 'Barba Completa', desc: 'Aparar, modelar e hidratação', duration: 30, price: 40, active: true },
{ id: '4', catId: '2', name: 'Design de Sobrancelha', desc: 'Alinhamento com navalha', duration: 15, price: 25, active: true },
{ id: '5', catId: '3', name: 'Combo Corte + Barba', desc: 'Corte masculino + barba completa', duration: 60, price: 85, active: true },
{ id: '6', catId: '4', name: 'Manicure Completa', desc: 'Lixar, cuticular e esmaltação', duration: 45, price: 50, active: true },
{ id: '7', catId: '4', name: 'Pedicure', desc: 'Tratamento completo para os pés', duration: 50, price: 55, active: true },
{ id: '8', catId: '5', name: 'Massagem Relaxante', desc: 'Massagem corpo inteiro 60min', duration: 60, price: 120, active: true },
{ id: '9', catId: '5', name: 'Quick Massage', desc: 'Massagem expressa 20min', duration: 20, price: 45, active: true },
];
const emptyService: Service = { id: '', catId: '1', name: '', desc: '', duration: 30, price: 50, active: true };
export default function Services() {
const [services, setServices] = useState<Service[]>(initialServices);
const [showModal, setShowModal] = useState(false);
const [selectedCat, setSelectedCat] = useState<string | null>(null);
const [editingService, setEditingService] = useState<Service | null>(null);
const [formData, setFormData] = useState<Service>(emptyService);
const [deleteConfirm, setDeleteConfirm] = useState<string | null>(null);
useEffect(() => {
const savedName = localStorage.getItem('agendapro_tenant_name');
if (savedName) {
setServices([
{ id: '1', catId: '1', name: 'Corte de Cabelo (Exemplo)', desc: 'Corte padrão do estabelecimento', duration: 30, price: 40, active: true }
]);
}
}, []);
const filtered = selectedCat ? services.filter(s => s.catId === selectedCat) : services;
const openAdd = () => {
setEditingService(null);
setFormData({ ...emptyService, id: Date.now().toString() });
setShowModal(true);
};
const openEdit = (svc: Service) => {
setEditingService(svc);
setFormData({ ...svc });
setShowModal(true);
};
const handleSave = () => {
if (!formData.name.trim()) return;
if (editingService) {
setServices(prev => prev.map(s => s.id === editingService.id ? { ...formData } : s));
} else {
setServices(prev => [...prev, { ...formData }]);
}
setShowModal(false);
setEditingService(null);
};
const handleDelete = (id: string) => {
setServices(prev => prev.filter(s => s.id !== id));
setDeleteConfirm(null);
};
return (
<div className="slide-up">
<div className="flex-between mb-6">
<div className="flex-gap" style={{ gap: '8px', flexWrap: 'wrap' }}>
<button className={`btn btn-sm ${!selectedCat ? 'btn-primary' : 'btn-secondary'}`} onClick={() => setSelectedCat(null)}>
Todos
</button>
{categories.filter(c => services.some(s => s.catId === c.id)).map(c => (
<button key={c.id} className={`btn btn-sm ${selectedCat === c.id ? 'btn-primary' : 'btn-secondary'}`}
onClick={() => setSelectedCat(c.id)}>
{c.icon} {c.name}
</button>
))}
</div>
<button className="btn btn-primary" onClick={openAdd}>
<Plus size={16} /> Novo Serviço
</button>
</div>
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(auto-fill, minmax(300px, 1fr))', gap: '16px' }}>
{filtered.map(svc => (
<div key={svc.id} className="card" style={{ padding: '20px', position: 'relative' }}>
{deleteConfirm === svc.id && (
<div style={{
position: 'absolute', inset: 0, background: 'rgba(0,0,0,0.85)', borderRadius: 'var(--radius-lg)',
display: 'flex', flexDirection: 'column', alignItems: 'center', justifyContent: 'center', gap: '16px', zIndex: 10
}}>
<AlertTriangle size={32} color="var(--warning)" />
<p style={{ fontSize: '14px', fontWeight: 600, textAlign: 'center' }}>Excluir &quot;{svc.name}&quot;?</p>
<div style={{ display: 'flex', gap: '8px' }}>
<button className="btn btn-sm btn-danger" onClick={() => handleDelete(svc.id)}>Sim, excluir</button>
<button className="btn btn-sm btn-secondary" onClick={() => setDeleteConfirm(null)}>Cancelar</button>
</div>
</div>
)}
<div className="flex-between" style={{ marginBottom: '12px' }}>
<span style={{ fontSize: '24px' }}>{categories.find(c => c.id === svc.catId)?.icon}</span>
<div style={{ display: 'flex', gap: '4px' }}>
<button className="btn-ghost btn-icon" title="Editar" onClick={() => openEdit(svc)}><Edit size={14} /></button>
<button className="btn-ghost btn-icon" title="Excluir" style={{ color: 'var(--danger)' }}
onClick={() => setDeleteConfirm(svc.id)}><Trash2 size={14} /></button>
</div>
</div>
<h3 style={{ fontSize: '16px', fontWeight: 700, marginBottom: '6px' }}>{svc.name}</h3>
<p style={{ fontSize: '13px', color: 'var(--text-secondary)', marginBottom: '16px' }}>{svc.desc}</p>
<div className="flex-between">
<div className="flex-gap gap-sm">
<span className="badge badge-purple"><Clock size={12} /> {svc.duration}min</span>
</div>
<span style={{ fontSize: '20px', fontWeight: 800, color: 'var(--accent)' }}>
R$ {svc.price.toFixed(2)}
</span>
</div>
</div>
))}
</div>
{filtered.length === 0 && (
<div className="empty-state">
<h3>Nenhum serviço encontrado</h3>
<p>Adicione um serviço ou selecione outra categoria.</p>
</div>
)}
{showModal && (
<div className="modal-overlay" onClick={() => setShowModal(false)}>
<div className="modal" onClick={e => e.stopPropagation()}>
<div className="modal-header">
<h2 className="modal-title">{editingService ? 'Editar Serviço' : 'Novo Serviço'}</h2>
<button className="btn-ghost btn-icon" onClick={() => setShowModal(false)}><X size={20} /></button>
</div>
<div className="modal-body">
<div className="form-group">
<label className="form-label">Categoria</label>
<select className="form-select" value={formData.catId} onChange={e => setFormData(p => ({ ...p, catId: e.target.value }))}>
{categories.map(c => <option key={c.id} value={c.id}>{c.icon} {c.name}</option>)}
</select>
</div>
<div className="form-group">
<label className="form-label">Nome do serviço *</label>
<input className="form-input" placeholder="Ex: Corte Masculino" value={formData.name}
onChange={e => setFormData(p => ({ ...p, name: e.target.value }))} />
</div>
<div className="form-group">
<label className="form-label">Descrição</label>
<textarea className="form-textarea" placeholder="Descreva o serviço..." value={formData.desc}
onChange={e => setFormData(p => ({ ...p, desc: e.target.value }))} />
</div>
<div className="grid-2">
<div className="form-group">
<label className="form-label"><Clock size={14} style={{ display: 'inline', marginRight: 4 }} />Duração (min)</label>
<input className="form-input" type="number" value={formData.duration}
onChange={e => setFormData(p => ({ ...p, duration: Number(e.target.value) }))} />
</div>
<div className="form-group">
<label className="form-label"><DollarSign size={14} style={{ display: 'inline', marginRight: 4 }} />Preço (R$)</label>
<input className="form-input" type="number" step="0.01" value={formData.price}
onChange={e => setFormData(p => ({ ...p, price: Number(e.target.value) }))} />
</div>
</div>
</div>
<div className="modal-footer">
<button className="btn btn-secondary" onClick={() => setShowModal(false)}>Cancelar</button>
<button className="btn btn-primary" onClick={handleSave}>
{editingService ? 'Salvar Alterações' : 'Salvar Serviço'}
</button>
</div>
</div>
</div>
)}
</div>
);
}

421
src/components/Settings.tsx Normal file
View File

@ -0,0 +1,421 @@
import { useState, useEffect } from 'react';
import { Store, Clock, Globe, Bell, Palette, Shield, Save, Copy, ExternalLink, Check, Link } from 'lucide-react';
const tabs = [
{ id: 'general', label: 'Geral', icon: Store },
{ id: 'hours', label: 'Horários', icon: Clock },
{ id: 'booking', label: 'Reservas Online', icon: Globe },
{ id: 'notifications', label: 'Notificações', icon: Bell },
{ id: 'appearance', label: 'Aparência', icon: Palette },
{ id: 'security', label: 'Segurança', icon: Shield },
];
const businessTypes = [
{ value: 'barbearia', label: '💈 Barbearia' },
{ value: 'manicure', label: '💅 Manicure / Nail Designer' },
{ value: 'massagem', label: '💆 Massagem / Spa' },
{ value: 'estetica', label: '✨ Estética' },
{ value: 'salao', label: '💇 Salão de Beleza' },
{ value: 'outro', label: '🏢 Outro' },
];
export default function Settings() {
const [activeTab, setActiveTab] = useState('general');
const [saved, setSaved] = useState(false);
const [copied, setCopied] = useState(false);
// States Dinâmicos do Tenant Ativo (Multi-Tenant Isolation)
const [tenantName, setTenantName] = useState('Barbearia Premium');
const [slug, setSlug] = useState('barbearia-premium');
const [phone, setPhone] = useState('(11) 99999-1234');
const [email, setEmail] = useState('contato@barbeariapremium.com.br');
const [address, setAddress] = useState('Rua das Flores, 123 - Centro');
const [city, setCity] = useState('São Paulo');
const [state, setState] = useState('SP');
const [zipCode, setZipCode] = useState('01001-000');
const [description, setDescription] = useState('A melhor barbearia da cidade. Cortes modernos, barbas impecáveis e ambiente premium.');
// States para Horários de Expediente
const [startTime, setStartTime] = useState('09:00');
const [endTime, setEndTime] = useState('19:00');
const [intervalTime, setIntervalTime] = useState(30);
const [workingDays, setWorkingDays] = useState([false, true, true, true, true, true, true]); // Dom-Sab
// Carrega dados dinâmicos do Tenant logado
useEffect(() => {
const activeSlug = localStorage.getItem('agendapro_tenant_slug') || 'barbearia-premium';
setSlug(activeSlug);
const savedName = localStorage.getItem('agendapro_tenant_name') || 'Barbearia Premium';
setTenantName(savedName);
// Carrega dados isolados deste tenant específico
const savedPhone = localStorage.getItem(`agendapro_phone_${activeSlug}`) || (activeSlug === 'barbearia-premium' ? '(11) 99999-1234' : '');
const savedEmail = localStorage.getItem(`agendapro_email_${activeSlug}`) || (activeSlug === 'barbearia-premium' ? 'contato@barbeariapremium.com.br' : '');
const savedAddress = localStorage.getItem(`agendapro_address_${activeSlug}`) || (activeSlug === 'barbearia-premium' ? 'Rua das Flores, 123 - Centro' : '');
const savedCity = localStorage.getItem(`agendapro_city_${activeSlug}`) || (activeSlug === 'barbearia-premium' ? 'São Paulo' : '');
const savedState = localStorage.getItem(`agendapro_state_${activeSlug}`) || (activeSlug === 'barbearia-premium' ? 'SP' : 'SP');
const savedZip = localStorage.getItem(`agendapro_zip_${activeSlug}`) || (activeSlug === 'barbearia-premium' ? '01001-000' : '');
const savedDesc = localStorage.getItem(`agendapro_desc_${activeSlug}`) || (activeSlug === 'barbearia-premium' ? 'A melhor barbearia da cidade. Cortes modernos, barbas impecáveis e ambiente premium.' : 'Novo estabelecimento pronto para agendamentos online.');
setPhone(savedPhone);
setEmail(savedEmail);
setAddress(savedAddress);
setCity(savedCity);
setState(savedState);
setZipCode(savedZip);
setDescription(savedDesc);
// Horários isolados por tenant
const savedStart = localStorage.getItem(`agendapro_start_time_${activeSlug}`) || '09:00';
const savedEnd = localStorage.getItem(`agendapro_end_time_${activeSlug}`) || '19:00';
const savedInterval = Number(localStorage.getItem(`agendapro_interval_${activeSlug}`) || '30');
setStartTime(savedStart);
setEndTime(savedEnd);
setIntervalTime(savedInterval);
}, []);
// States para Reservas Online
const [allowOnline, setAllowOnline] = useState(true);
const [requireConfirmation, setRequireConfirmation] = useState(false);
const [autoReminders, setAutoReminders] = useState(true);
const [allowCancel, setAllowCancel] = useState(true);
// States para Notificações
const [notifyNew, setNotifyNew] = useState(true);
const [notifyCancel, setNotifyCancel] = useState(true);
const [notifyClient, setNotifyClient] = useState(true);
const [dailySummary, setDailySummary] = useState(false);
const [weeklyReport, setWeeklyReport] = useState(true);
const publicUrl = typeof window !== 'undefined' ? `${window.location.origin}/${slug}/agendar` : `/${slug}/agendar`;
const handleSave = () => {
// 1. Atualiza dados de sessão do tenant
localStorage.setItem('agendapro_tenant_name', tenantName);
localStorage.setItem('agendapro_tenant_slug', slug);
// 2. Persiste dados com isolamento por slug (Tenant Isolation)
localStorage.setItem(`agendapro_phone_${slug}`, phone);
localStorage.setItem(`agendapro_email_${slug}`, email);
localStorage.setItem(`agendapro_address_${slug}`, address);
localStorage.setItem(`agendapro_city_${slug}`, city);
localStorage.setItem(`agendapro_state_${slug}`, state);
localStorage.setItem(`agendapro_zip_${slug}`, zipCode);
localStorage.setItem(`agendapro_desc_${slug}`, description);
// Horários isolados
localStorage.setItem(`agendapro_start_time_${slug}`, startTime);
localStorage.setItem(`agendapro_end_time_${slug}`, endTime);
localStorage.setItem(`agendapro_interval_${slug}`, String(intervalTime));
// Atualiza cookies dinâmicos para a página pública
document.cookie = `agendapro_tenant_slug=${slug}; path=/; max-age=2592000`;
document.cookie = `agendapro_start_time_${slug}=${startTime}; path=/; max-age=2592000`;
document.cookie = `agendapro_end_time_${slug}=${endTime}; path=/; max-age=2592000`;
document.cookie = `agendapro_interval_${slug}=${intervalTime}; path=/; max-age=2592000`;
// Dispara evento customizado para notificar sidebar e topbar sobre alteração de nome do estabelecimento
if (typeof window !== 'undefined') {
window.dispatchEvent(new Event('storage'));
}
setSaved(true);
setTimeout(() => setSaved(false), 2000);
};
return (
<div className="slide-up">
<div className="tabs">
{tabs.map(t => {
const Icon = t.icon;
return (
<button key={t.id} className={`tab ${activeTab === t.id ? 'active' : ''}`}
onClick={() => setActiveTab(t.id)}>
<span style={{ display: 'inline-flex', alignItems: 'center', gap: '6px' }}>
<Icon size={14} /> {t.label}
</span>
</button>
);
})}
</div>
{activeTab === 'general' && (
<div className="card">
<h3 className="card-title mb-4">Informações do Estabelecimento</h3>
<div className="form-group">
<label className="form-label">Nome do Estabelecimento</label>
<input className="form-input" value={tenantName} onChange={e => setTenantName(e.target.value)} />
</div>
<div className="grid-2">
<div className="form-group">
<label className="form-label">Tipo de Negócio</label>
<select className="form-select" defaultValue="barbearia">
{businessTypes.map(bt => <option key={bt.value} value={bt.value}>{bt.label}</option>)}
</select>
</div>
<div className="form-group">
<label className="form-label">Slug (URL)</label>
<input className="form-input" value={slug} onChange={e => setSlug(e.target.value.toLowerCase().replace(/[^a-z0-9-]/g, '-'))} />
</div>
</div>
<div className="grid-2">
<div className="form-group">
<label className="form-label">Telefone</label>
<input className="form-input" value={phone} onChange={e => setPhone(e.target.value)} />
</div>
<div className="form-group">
<label className="form-label">E-mail</label>
<input className="form-input" value={email} onChange={e => setEmail(e.target.value)} />
</div>
</div>
<div className="form-group">
<label className="form-label">Endereço</label>
<input className="form-input" placeholder="Rua, número, bairro" value={address} onChange={e => setAddress(e.target.value)} />
</div>
<div className="grid-3">
<div className="form-group">
<label className="form-label">Cidade</label>
<input className="form-input" placeholder="São Paulo" value={city} onChange={e => setCity(e.target.value)} />
</div>
<div className="form-group">
<label className="form-label">Estado</label>
<select className="form-select" value={state} onChange={e => setState(e.target.value)}>
<option value="SP">SP</option>
<option value="RJ">RJ</option>
<option value="MG">MG</option>
<option value="SC">SC</option>
<option value="RS">RS</option>
<option value="PR">PR</option>
</select>
</div>
<div className="form-group">
<label className="form-label">CEP</label>
<input className="form-input" placeholder="00000-000" value={zipCode} onChange={e => setZipCode(e.target.value)} />
</div>
</div>
<div className="form-group">
<label className="form-label">Descrição</label>
<textarea className="form-textarea" value={description} onChange={e => setDescription(e.target.value)} />
</div>
<div style={{ marginTop: '24px' }}>
<button className="btn btn-primary" onClick={handleSave}>
<Save size={16} /> {saved ? '✓ Salvo!' : 'Salvar Alterações'}
</button>
</div>
</div>
)}
{activeTab === 'hours' && (
<div style={{ display: 'flex', flexDirection: 'column', gap: '20px' }}>
<div className="card">
<h3 className="card-title mb-4">Grade de Agendamento</h3>
<p style={{ fontSize: '13px', color: 'var(--text-secondary)', marginBottom: '20px' }}>
Defina o horário do expediente e o intervalo dinâmico de tempo entre cada horário livre para o agendamento online do cliente.
</p>
<div className="grid-3" style={{ gap: '16px' }}>
<div className="form-group" style={{ margin: 0 }}>
<label className="form-label">Início do Expediente</label>
<input className="form-input" type="time" value={startTime} onChange={e => setStartTime(e.target.value)} />
</div>
<div className="form-group" style={{ margin: 0 }}>
<label className="form-label">Fim do Expediente</label>
<input className="form-input" type="time" value={endTime} onChange={e => setEndTime(e.target.value)} />
</div>
<div className="form-group" style={{ margin: 0 }}>
<label className="form-label">Intervalo de Agendamento</label>
<select className="form-select" value={intervalTime} onChange={e => setIntervalTime(Number(e.target.value))}>
<option value={15}>15 minutos</option>
<option value={20}>20 minutos</option>
<option value={30}>30 minutos</option>
<option value={40}>40 minutos</option>
<option value={45}>45 minutos</option>
<option value={60}>60 minutos</option>
</select>
</div>
</div>
<div style={{ marginTop: '24px' }}>
<button className="btn btn-primary" onClick={handleSave}><Save size={16} /> {saved ? '✓ Salvo!' : 'Salvar Alterações'}</button>
</div>
</div>
<div className="card">
<h3 className="card-title mb-4">Dias de Funcionamento</h3>
{['Domingo', 'Segunda', 'Terça', 'Quarta', 'Quinta', 'Sexta', 'Sábado'].map((day, i) => (
<div key={day} style={{
display: 'flex', alignItems: 'center', gap: '16px',
padding: '12px 0', borderBottom: i < 6 ? '1px solid var(--border-color)' : 'none'
}}>
<div style={{ width: '100px', fontWeight: 600, fontSize: '14px' }}>{day}</div>
<div className={`toggle ${workingDays[i] ? 'active' : ''}`}
onClick={() => setWorkingDays(prev => { const n = [...prev]; n[i] = !n[i]; return n; })} />
{workingDays[i] ? (
<span style={{ fontSize: '13px', color: 'var(--text-secondary)' }}>Aberto para agendamentos online</span>
) : (
<span style={{ color: 'var(--text-muted)', fontSize: '13px' }}>Fechado</span>
)}
</div>
))}
</div>
</div>
)}
{activeTab === 'booking' && (
<div style={{ display: 'flex', flexDirection: 'column', gap: '20px' }}>
{/* PUBLIC LINK CARD */}
<div style={{
background: 'linear-gradient(135deg, rgba(108,99,255,0.15), rgba(168,85,247,0.1))',
border: '1px solid rgba(108,99,255,0.3)', borderRadius: 'var(--radius-xl)', padding: '28px'
}}>
<div style={{ display: 'flex', alignItems: 'center', gap: '10px', marginBottom: '16px' }}>
<div style={{ width: 40, height: 40, borderRadius: 'var(--radius-md)', background: 'var(--gradient-primary)', display: 'flex', alignItems: 'center', justifyContent: 'center' }}>
<Link size={20} color="white" />
</div>
<div>
<h3 style={{ fontSize: '16px', fontWeight: 700 }}>Link Público de Agendamento</h3>
<p style={{ fontSize: '13px', color: 'var(--text-secondary)' }}>Compartilhe este link com seus clientes para agendamento online</p>
</div>
</div>
<div style={{
display: 'flex', alignItems: 'center', gap: '8px', background: 'var(--bg-primary)',
borderRadius: 'var(--radius-sm)', padding: '4px 4px 4px 16px', border: '1px solid var(--border-color)'
}}>
<Globe size={16} color="var(--text-muted)" />
<span style={{ flex: 1, fontSize: '14px', fontWeight: 500, color: 'var(--accent)', overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>
{publicUrl}
</span>
<button className="btn btn-sm btn-secondary" style={{ flexShrink: 0 }}
onClick={() => { navigator.clipboard.writeText(publicUrl); setCopied(true); setTimeout(() => setCopied(false), 2000); }}>
{copied ? <><Check size={14} /> Copiado!</> : <><Copy size={14} /> Copiar</>}
</button>
<a href={`/${slug}/agendar`} target="_blank" rel="noopener noreferrer" className="btn btn-sm btn-primary" style={{ flexShrink: 0, textDecoration: 'none' }}>
<ExternalLink size={14} /> Abrir
</a>
</div>
<div className="form-group" style={{ marginTop: '16px', marginBottom: 0 }}>
<label className="form-label">Slug do estabelecimento (usado na URL)</label>
<input className="form-input" value={slug} onChange={e => setSlug(e.target.value.toLowerCase().replace(/[^a-z0-9-]/g, '-').replace(/--+/g, '-'))}
style={{ maxWidth: '400px' }} placeholder="meu-estabelecimento" />
<p style={{ fontSize: '12px', color: 'var(--text-muted)', marginTop: '4px' }}>Apenas letras minúsculas, números e hífens</p>
</div>
</div>
{/* BOOKING OPTIONS */}
<div className="card">
<h3 className="card-title mb-4">Configurações de Reserva Online</h3>
{[
{ label: 'Permitir agendamento online', desc: 'Clientes podem agendar pela página pública', value: allowOnline, setter: setAllowOnline },
{ label: 'Exigir confirmação manual', desc: 'Agendamentos precisam de aprovação', value: requireConfirmation, setter: setRequireConfirmation },
{ label: 'Enviar lembretes automáticos', desc: 'Enviar lembrete por e-mail/SMS antes do horário', value: autoReminders, setter: setAutoReminders },
{ label: 'Permitir cancelamento online', desc: 'Clientes podem cancelar pela plataforma', value: allowCancel, setter: setAllowCancel },
].map((item, i) => (
<div key={i} style={{
display: 'flex', justifyContent: 'space-between', alignItems: 'center',
padding: '16px 0', borderBottom: i < 3 ? '1px solid var(--border-color)' : 'none'
}}>
<div>
<div style={{ fontWeight: 600, fontSize: '14px' }}>{item.label}</div>
<div style={{ fontSize: '13px', color: 'var(--text-secondary)' }}>{item.desc}</div>
</div>
<div className={`toggle ${item.value ? 'active' : ''}`}
onClick={() => item.setter(!item.value)} />
</div>
))}
<div className="grid-2 mt-6">
<div className="form-group">
<label className="form-label">Antecedência mínima (min)</label>
<input className="form-input" type="number" defaultValue={60} />
</div>
<div className="form-group">
<label className="form-label">Antecedência máxima (dias)</label>
<input className="form-input" type="number" defaultValue={30} />
</div>
</div>
<div className="form-group">
<label className="form-label">Limite para cancelamento (minutos antes)</label>
<input className="form-input" type="number" defaultValue={120} />
</div>
<button className="btn btn-primary mt-4" onClick={handleSave}><Save size={16} /> {saved ? '✓ Salvo!' : 'Salvar'}</button>
</div>
</div>
)}
{activeTab === 'notifications' && (
<div className="card">
<h3 className="card-title mb-4">Preferências de Notificação</h3>
{[
{ label: 'Novo agendamento', desc: 'Quando um novo agendamento é criado', value: notifyNew, setter: setNotifyNew },
{ label: 'Cancelamento', desc: 'Quando um agendamento é cancelado', value: notifyCancel, setter: setNotifyCancel },
{ label: 'Lembrete para o cliente', desc: 'Enviar lembrete automático', value: notifyClient, setter: setNotifyClient },
{ label: 'Resumo diário', desc: 'Receber resumo dos agendamentos do dia', value: dailySummary, setter: setDailySummary },
{ label: 'Relatório semanal', desc: 'Relatório de desempenho semanal', value: weeklyReport, setter: setWeeklyReport },
].map((item, i) => (
<div key={i} style={{
display: 'flex', justifyContent: 'space-between', alignItems: 'center',
padding: '14px 0', borderBottom: i < 4 ? '1px solid var(--border-color)' : 'none'
}}>
<div>
<div style={{ fontWeight: 600, fontSize: '14px' }}>{item.label}</div>
<div style={{ fontSize: '13px', color: 'var(--text-secondary)' }}>{item.desc}</div>
</div>
<div className={`toggle ${item.value ? 'active' : ''}`}
onClick={() => item.setter(!item.value)} />
</div>
))}
<div className="form-group mt-6">
<label className="form-label">Tempo do lembrete (minutos antes)</label>
<input className="form-input" type="number" defaultValue={60} style={{ width: '200px' }} />
</div>
<button className="btn btn-primary mt-4" onClick={handleSave}><Save size={16} /> {saved ? '✓ Salvo!' : 'Salvar'}</button>
</div>
)}
{activeTab === 'appearance' && (
<div className="card">
<h3 className="card-title mb-4">Personalização Visual</h3>
<div className="form-group">
<label className="form-label">Cor Principal</label>
<div className="flex-gap">
{['#6C63FF', '#a855f7', '#ec4899', '#f59e0b', '#22c55e', '#3b82f6', '#ef4444'].map(c => (
<div key={c} style={{
width: 40, height: 40, borderRadius: 'var(--radius-sm)', background: c,
cursor: 'pointer', border: '2px solid transparent', transition: 'all 0.2s'
}}
onMouseEnter={e => (e.currentTarget as HTMLElement).style.transform = 'scale(1.1)'}
onMouseLeave={e => (e.currentTarget as HTMLElement).style.transform = 'scale(1)'}
onClick={() => {
document.documentElement.style.setProperty('--accent', c);
document.documentElement.style.setProperty('--accent-glow', `${c}26`); // 15% opacity hex
}}
/>
))}
</div>
<p style={{ fontSize: '12px', color: 'var(--text-muted)', marginTop: '8px' }}>Clique em uma cor para aplicar imediatamente ao seu painel.</p>
</div>
<button className="btn btn-primary mt-4" onClick={handleSave}><Save size={16} /> {saved ? '✓ Salvo!' : 'Salvar'}</button>
</div>
)}
{activeTab === 'security' && (
<div className="card">
<h3 className="card-title mb-4">Segurança</h3>
<div className="form-group">
<label className="form-label">Senha Atual</label>
<input className="form-input" type="password" placeholder="••••••••" />
</div>
<div className="grid-2">
<div className="form-group">
<label className="form-label">Nova Senha</label>
<input className="form-input" type="password" placeholder="••••••••" />
</div>
<div className="form-group">
<label className="form-label">Confirmar Nova Senha</label>
<input className="form-input" type="password" placeholder="••••••••" />
</div>
</div>
<button className="btn btn-primary mt-4" onClick={handleSave}><Save size={16} /> {saved ? '✓ Salvo!' : 'Salvar'}</button>
</div>
)}
</div>
);
}

105
src/components/Sidebar.tsx Normal file
View File

@ -0,0 +1,105 @@
import { useState, useEffect } from 'react';
import { Calendar, Users, Scissors, LayoutDashboard, Settings, CreditCard, UserCheck, X, Crown, LogOut } from 'lucide-react';
import { useRouter } from 'next/navigation';
interface SidebarProps {
currentPage: string;
onNavigate: (page: string) => void;
isOpen: boolean;
onClose: () => void;
currentPlan?: string;
isLocked?: boolean;
}
const navItems = [
{ section: 'Principal' },
{ id: 'dashboard', label: 'Dashboard', icon: LayoutDashboard },
{ id: 'agenda', label: 'Agenda', icon: Calendar },
{ id: 'clients', label: 'Clientes', icon: Users },
{ section: 'Gestão' },
{ id: 'services', label: 'Serviços', icon: Scissors },
{ id: 'team', label: 'Equipe', icon: UserCheck },
{ section: 'Sistema' },
{ id: 'settings', label: 'Configurações', icon: Settings },
{ id: 'subscription', label: 'Assinatura', icon: CreditCard },
];
export default function Sidebar({ currentPage, onNavigate, isOpen, onClose, currentPlan, isLocked = false }: SidebarProps) {
const router = useRouter();
const [tenantName, setTenantName] = useState('AgendaPRO');
useEffect(() => {
const savedName = localStorage.getItem('agendapro_tenant_name');
if (savedName) {
setTenantName(savedName);
}
}, []);
const handleLogout = () => {
localStorage.removeItem('agendapro_token');
localStorage.removeItem('agendapro_tenant_name');
localStorage.removeItem('agendapro_plan');
// Limpar cookies de sessão
document.cookie = "agendapro_new_tenant=; expires=Thu, 01 Jan 1970 00:00:00 UTC; path=/;";
document.cookie = "agendapro_plan=; expires=Thu, 01 Jan 1970 00:00:00 UTC; path=/;";
router.push('/login');
};
return (
<>
{isOpen && <div style={{ position: 'fixed', inset: 0, background: 'rgba(0,0,0,0.5)', zIndex: 99 }} onClick={onClose} />}
<aside className={`sidebar ${isOpen ? 'open' : ''}`}>
<div className="sidebar-header">
<div className="sidebar-logo">
<div className="logo-icon"></div>
<span className="logo-text" style={{ fontSize: '18px', fontWeight: 800 }}>{tenantName}</span>
</div>
<button className="btn-ghost btn-icon" onClick={onClose} style={{ display: 'none' }}>
<X size={20} />
</button>
</div>
<nav className="sidebar-nav">
{navItems.map((item, i) => {
if ('section' in item && item.section) {
return <div key={`s-${i}`} className="nav-section-title">{item.section}</div>;
}
if ('id' in item && item.id) {
const Icon = item.icon!;
const itemLocked = isLocked && item.id !== 'subscription';
return (
<button
key={item.id}
className={`nav-item ${currentPage === item.id ? 'active' : ''}`}
disabled={itemLocked}
style={itemLocked ? { opacity: 0.4, cursor: 'not-allowed' } : {}}
onClick={() => {
if (!itemLocked) {
onNavigate(item.id!);
onClose();
}
}}
>
<Icon size={20} />
{item.label}
{itemLocked && <span style={{ marginLeft: 'auto', fontSize: '12px' }}>🔒</span>}
</button>
);
}
return null;
})}
</nav>
<div className="sidebar-footer" style={{ padding: '20px', borderTop: '1px solid var(--border-color)', display: 'flex', flexDirection: 'column', gap: '12px' }}>
<div className="plan-badge">
<Crown size={16} />
<span>Plano {currentPlan || 'Professional'}</span>
</div>
<button className="btn btn-secondary" style={{ width: '100%', justifyContent: 'center' }} onClick={handleLogout}>
<LogOut size={16} /> Sair do Sistema
</button>
</div>
</aside>
</>
);
}

View File

@ -0,0 +1,469 @@
'use client';
import { useState } from 'react';
import { Check, Crown, Zap, Building2, Rocket, AlertTriangle, ShieldAlert, CreditCard, QrCode, FileText, CheckCircle2, ShieldCheck } from 'lucide-react';
const plans = [
{
name: 'Starter', price: 49.90, icon: Zap, color: '#3b82f6',
features: ['1 profissional', '10 serviços', '200 agendamentos/mês', 'Agendamento online', 'Página de reservas', 'Lembretes por e-mail', 'Relatórios básicos']
},
{
name: 'Professional', price: 99.90, icon: Crown, color: '#6C63FF', featured: true,
features: ['5 profissionais', '30 serviços', 'Agendamentos ilimitados', 'Tudo do Starter', 'Múltiplos profissionais', 'Comissionamento', 'Relatórios avançados', 'Personalização da marca']
},
{
name: 'Business', price: 199.90, icon: Building2, color: '#a855f7',
features: ['15 profissionais', '100 serviços', 'Agendamentos ilimitados', 'Tudo do Professional', 'API de integração', 'Suporte prioritário', 'Multi-unidades', 'Automações avançadas']
},
{
name: 'Enterprise', price: 399.90, icon: Rocket, color: '#f59e0b',
features: ['Profissionais ilimitados', 'Serviços ilimitados', 'Agendamentos ilimitados', 'Tudo do Business', 'Gerente dedicado', 'SLA garantido', 'Integrações customizadas', 'White-label']
},
];
// Máscaras de entrada
const formatCpfCnpj = (value: string) => {
const v = value.replace(/\D/g, '');
if (v.length <= 11) {
return v.replace(/(\d{3})(\d)/, '$1.$2')
.replace(/(\d{3})(\d)/, '$1.$2')
.replace(/(\d{3})(\d{1,2})$/, '$1-$2');
}
return v.replace(/^(\d{2})(\d)/, '$1.$2')
.replace(/^(\d{2})\.(\d{3})(\d)/, '$1.$2.$3')
.replace(/\.(\d{3})(\d)/, '.$1/$2')
.replace(/(\d{4})(\d)/, '$1-$2')
.slice(0, 18);
};
const formatPhone = (value: string) => {
const v = value.replace(/\D/g, '');
return v.replace(/(\d{2})(\d)/, '($1) $2')
.replace(/(\d{5})(\d)/, '$1-$2')
.slice(0, 15);
};
const formatCep = (value: string) => {
const v = value.replace(/\D/g, '');
return v.replace(/(\d{5})(\d)/, '$1-$2')
.slice(0, 9);
};
const formatCardNumber = (value: string) => {
const v = value.replace(/\D/g, '');
return v.replace(/(\d{4})(?=\d)/g, '$1 ').slice(0, 19);
};
const formatExpiry = (value: string) => {
const v = value.replace(/\D/g, '');
return v.replace(/(\d{2})(\d)/, '$1/$2').slice(0, 5);
};
const formatCvv = (value: string) => {
return value.replace(/\D/g, '').slice(0, 4);
};
interface SubscriptionProps {
currentPlan: string;
onPlanChange: (newPlan: string) => void;
isLocked?: boolean;
onPaymentSuccess?: () => void;
}
export default function Subscription({ currentPlan, onPlanChange, isLocked = false, onPaymentSuccess }: SubscriptionProps) {
const [selectedPlan, setSelectedPlan] = useState<any>(null);
const [paymentMethod, setPaymentMethod] = useState<'PIX' | 'BOLETO' | 'CREDIT_CARD'>('PIX');
const [loading, setLoading] = useState(false);
const [successData, setSuccessData] = useState<any>(null);
const [errorMsg, setErrorMsg] = useState<string | null>(null);
// Form de cartão
const [cardHolder, setCardHolder] = useState('');
const [cardNumber, setCardNumber] = useState('');
const [cardExpiry, setCardExpiry] = useState('');
const [cardCvv, setCardCvv] = useState('');
// Dados de Faturamento
const [billingInfo, setBillingInfo] = useState({
name: '',
cpfCnpj: '',
phone: '',
email: '',
postalCode: '',
addressNumber: '',
});
const activePlanData = plans.find(p => p.name === currentPlan) || plans[1];
const handleOpenPayment = (plan: any) => {
setSelectedPlan(plan);
setSuccessData(null);
setErrorMsg(null);
};
const handlePay = async (e: React.FormEvent) => {
e.preventDefault();
setLoading(true);
setErrorMsg(null);
const slug = localStorage.getItem('agendapro_tenant_slug') || 'barbearia-premium';
let creditCard = undefined;
let creditCardHolderInfo = undefined;
if (paymentMethod === 'CREDIT_CARD') {
const [month, year] = cardExpiry.split('/');
creditCard = {
holderName: cardHolder,
number: cardNumber.replace(/\s/g, ''),
expiryMonth: month || '12',
expiryYear: year ? `20${year}` : '2030',
ccv: cardCvv,
};
creditCardHolderInfo = {
name: cardHolder,
email: `${slug}@agendapro.com`,
cpfCnpj: '00000000000',
postalCode: '01310000',
addressNumber: '123',
phone: '11999991234',
};
}
try {
const res = await fetch('/api/tenant/subscription', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
slug,
planName: selectedPlan.name,
billingType: paymentMethod,
billingInfo,
creditCard,
creditCardHolderInfo,
}),
});
const data = await res.json();
if (!res.ok) {
throw new Error(data.error || 'Falha ao processar pagamento');
}
setSuccessData(data);
onPlanChange(selectedPlan.name);
} catch (err: any) {
console.error(err);
setErrorMsg(err.message || 'Erro inesperado no servidor');
} finally {
setLoading(false);
}
};
// Simulação de pagamento no Sandbox / Dev
const handleSimulatePayment = async () => {
setLoading(true);
const slug = localStorage.getItem('agendapro_tenant_slug') || 'barbearia-premium';
// Configura o cookie e localStorage de homologação local
localStorage.setItem('agendapro_subscription_status', 'active');
document.cookie = "agendapro_subscription_status=active; path=/; max-age=2592000";
try {
const res = await fetch('/api/tenant/subscription/simulate', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ slug, status: 'active' }),
});
alert('Pagamento simulado com sucesso! O sistema foi desbloqueado.');
setSelectedPlan(null);
if (onPaymentSuccess) {
onPaymentSuccess();
}
} catch (err) {
console.warn('Erro ao conectar com API de simulação, utilizando fallback local de homologação.', err);
alert('Pagamento simulado localmente! O sistema foi desbloqueado (modo offline).');
setSelectedPlan(null);
if (onPaymentSuccess) {
onPaymentSuccess();
}
} finally {
setLoading(false);
}
};
return (
<div className="slide-up">
{/* BANNER DE BLOQUEIO */}
{isLocked && (
<div style={{
background: 'linear-gradient(135deg, rgba(239,68,68,0.15), rgba(245,158,11,0.1))',
border: '2px solid rgba(239,68,68,0.4)', borderRadius: 'var(--radius-lg)',
padding: '20px', marginBottom: '28px', display: 'flex', gap: '16px', alignItems: 'center'
}}>
<div style={{ background: 'var(--danger)', borderRadius: '50%', width: 44, height: 44, display: 'flex', alignItems: 'center', justifyContent: 'center', flexShrink: 0 }}>
<ShieldAlert size={24} color="white" />
</div>
<div>
<h4 style={{ fontWeight: 800, color: 'var(--danger)', fontSize: '16px', marginBottom: '4px' }}>Acesso Pendente / Bloqueado 🔒</h4>
<p style={{ fontSize: '13px', color: 'var(--text-secondary)' }}>
Sua assinatura do AgendaPRO não está ativa. Para reativar seu acesso completo e continuar gerenciando seus agendamentos, clientes e equipe, escolha um plano abaixo e efetue o pagamento.
</p>
</div>
</div>
)}
{/* PLANO ATUAL INFO */}
{!isLocked && (
<div className="card mb-6" style={{ background: 'var(--gradient-primary)', border: 'none' }}>
<div className="flex-between">
<div>
<div style={{ display: 'flex', alignItems: 'center', gap: '10px', marginBottom: '8px' }}>
<Crown size={24} color="white" />
<h2 style={{ fontSize: '22px', fontWeight: 800, color: 'white' }}>Plano {activePlanData.name}</h2>
</div>
<p style={{ color: 'rgba(255,255,255,0.9)' }}>Sua assinatura está ativa e regularizada.</p>
</div>
<div style={{ textAlign: 'right', color: 'white' }}>
<div style={{ fontSize: '32px', fontWeight: 900 }}>
R$ {activePlanData.price.toFixed(2).replace('.', ',')}
<span style={{ fontSize: '14px', fontWeight: 400 }}>/mês</span>
</div>
<span className="badge" style={{ background: 'rgba(255,255,255,0.2)', color: 'white' }}>Ativo</span>
</div>
</div>
</div>
)}
<h3 style={{ fontSize: '18px', fontWeight: 700, marginBottom: '20px' }}>Escolha o plano ideal</h3>
<div className="pricing-grid">
{plans.map(plan => {
const Icon = plan.icon;
const isActive = currentPlan === plan.name && !isLocked;
return (
<div key={plan.name} className={`pricing-card ${isActive ? 'featured' : ''}`} style={isLocked ? { border: '1px solid var(--border-color)' } : {}}>
<div style={{ width: 56, height: 56, borderRadius: 'var(--radius-md)', background: `${plan.color}20`, display: 'flex', alignItems: 'center', justifyContent: 'center', margin: '0 auto 16px' }}>
<Icon size={28} color={plan.color} />
</div>
<div className="pricing-name">{plan.name}</div>
<div className="pricing-price">
R$ {plan.price.toFixed(2).replace('.', ',')}
<span>/mês</span>
</div>
<ul className="pricing-features">
{plan.features.map(f => (
<li key={f}><Check size={16} /> {f}</li>
))}
</ul>
<button
className={`btn ${isActive ? 'btn-primary' : 'btn-secondary'}`}
style={{ width: '100%', justifyContent: 'center' }}
onClick={() => handleOpenPayment(plan)}
>
{isActive ? 'Plano Atual (Pagar de Novo)' : 'Assinar Plano'}
</button>
</div>
);
})}
</div>
{/* MODAL DE PAGAMENTO */}
{selectedPlan && (
<div className="modal-overlay" onClick={() => setSelectedPlan(null)}>
<div className="modal" onClick={e => e.stopPropagation()} style={{ maxWidth: '500px' }}>
<div className="modal-header">
<h2 className="modal-title">Assinar Plano {selectedPlan.name}</h2>
<button className="btn-ghost btn-icon" onClick={() => setSelectedPlan(null)}></button>
</div>
<div className="modal-body">
{!successData ? (
<form onSubmit={handlePay} style={{ display: 'flex', flexDirection: 'column', gap: '20px' }}>
{/* Seleção de meio de pagamento */}
<div>
<label className="form-label">Meio de Pagamento</label>
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(3, 1fr)', gap: '8px' }}>
<button type="button" className={`btn ${paymentMethod === 'PIX' ? 'btn-primary' : 'btn-secondary'}`}
style={{ flexDirection: 'column', padding: '12px 6px', gap: '4px', fontSize: '13px' }}
onClick={() => setPaymentMethod('PIX')}>
<QrCode size={18} /> Pix
</button>
<button type="button" className={`btn ${paymentMethod === 'BOLETO' ? 'btn-primary' : 'btn-secondary'}`}
style={{ flexDirection: 'column', padding: '12px 6px', gap: '4px', fontSize: '13px' }}
onClick={() => setPaymentMethod('BOLETO')}>
<FileText size={18} /> Boleto
</button>
<button type="button" className={`btn ${paymentMethod === 'CREDIT_CARD' ? 'btn-primary' : 'btn-secondary'}`}
style={{ flexDirection: 'column', padding: '12px 6px', gap: '4px', fontSize: '13px' }}
onClick={() => setPaymentMethod('CREDIT_CARD')}>
<CreditCard size={18} /> Cartão
</button>
</div>
</div>
<div style={{ borderTop: '1px solid var(--border-color)', paddingTop: '16px' }}>
<div style={{ display: 'flex', justifyContent: 'space-between', marginBottom: '8px' }}>
<span style={{ color: 'var(--text-secondary)' }}>Plano:</span>
<strong style={{ color: 'var(--text-primary)' }}>{selectedPlan.name} (Mensal)</strong>
</div>
<div style={{ display: 'flex', justifyContent: 'space-between' }}>
<span style={{ color: 'var(--text-secondary)' }}>Valor total:</span>
<strong style={{ color: 'var(--accent)', fontSize: '18px' }}>R$ {selectedPlan.price.toFixed(2).replace('.', ',')}</strong>
</div>
</div>
{/* Dados de Faturamento */}
<div style={{ borderTop: '1px solid var(--border-color)', paddingTop: '16px', display: 'flex', flexDirection: 'column', gap: '12px' }}>
<h4 style={{ fontSize: '14px', fontWeight: 600, color: 'var(--text-primary)', marginBottom: '4px' }}>Dados do Pagador</h4>
<div className="form-group" style={{ margin: 0 }}>
<label className="form-label">Nome Completo / Razão Social</label>
<input required className="form-input" placeholder="João da Silva" value={billingInfo.name} onChange={e => setBillingInfo({...billingInfo, name: e.target.value})} />
</div>
<div className="grid-2">
<div className="form-group" style={{ margin: 0 }}>
<label className="form-label">CPF ou CNPJ</label>
<input required className="form-input" placeholder="000.000.000-00" value={billingInfo.cpfCnpj} onChange={e => setBillingInfo({...billingInfo, cpfCnpj: formatCpfCnpj(e.target.value)})} />
</div>
<div className="form-group" style={{ margin: 0 }}>
<label className="form-label">Celular</label>
<input required className="form-input" placeholder="(00) 90000-0000" value={billingInfo.phone} onChange={e => setBillingInfo({...billingInfo, phone: formatPhone(e.target.value)})} />
</div>
</div>
<div className="form-group" style={{ margin: 0 }}>
<label className="form-label">E-mail</label>
<input type="email" required className="form-input" placeholder="seu@email.com" value={billingInfo.email} onChange={e => setBillingInfo({...billingInfo, email: e.target.value})} />
</div>
{paymentMethod === 'CREDIT_CARD' && (
<div className="grid-2">
<div className="form-group" style={{ margin: 0 }}>
<label className="form-label">CEP</label>
<input required className="form-input" placeholder="00000-000" value={billingInfo.postalCode} onChange={e => setBillingInfo({...billingInfo, postalCode: formatCep(e.target.value)})} />
</div>
<div className="form-group" style={{ margin: 0 }}>
<label className="form-label">Número</label>
<input required className="form-input" placeholder="123" value={billingInfo.addressNumber} onChange={e => setBillingInfo({...billingInfo, addressNumber: e.target.value})} />
</div>
</div>
)}
</div>
{/* Inputs específicos do Cartão */}
{paymentMethod === 'CREDIT_CARD' && (
<div style={{ display: 'flex', flexDirection: 'column', gap: '12px', animation: 'fadeIn 0.2s' }}>
<div className="form-group" style={{ margin: 0 }}>
<label className="form-label">Nome do Titular (como no cartão)</label>
<input required className="form-input" placeholder="Nome Completo" value={cardHolder} onChange={e => setCardHolder(e.target.value)} />
</div>
<div className="form-group" style={{ margin: 0 }}>
<label className="form-label">Número do Cartão</label>
<input required className="form-input" placeholder="0000 0000 0000 0000" value={cardNumber} onChange={e => setCardNumber(formatCardNumber(e.target.value))} />
</div>
<div className="grid-2">
<div className="form-group" style={{ margin: 0 }}>
<label className="form-label">Validade (MM/AA)</label>
<input required className="form-input" placeholder="MM/AA" value={cardExpiry} onChange={e => setCardExpiry(formatExpiry(e.target.value))} />
</div>
<div className="form-group" style={{ margin: 0 }}>
<label className="form-label">CVV</label>
<input required className="form-input" placeholder="000" value={cardCvv} onChange={e => setCardCvv(formatCvv(e.target.value))} />
</div>
</div>
</div>
)}
{errorMsg && (
<div style={{ color: 'var(--danger)', fontSize: '13px', background: 'rgba(239,68,68,0.1)', padding: '10px', borderRadius: 'var(--radius-sm)', border: '1px solid var(--danger)' }}>
{errorMsg}
</div>
)}
<button type="submit" className="btn btn-primary" style={{ width: '100%', justifyContent: 'center', padding: '12px' }} disabled={loading}>
{loading ? 'Processando transação...' : `Confirmar e Pagar R$ ${selectedPlan.price.toFixed(2).replace('.', ',')}`}
</button>
</form>
) : (
/* RESPOSTA DE SUCESSO DE PAGAMENTO */
<div style={{ textAlign: 'center', padding: '12px 0', animation: 'fadeIn 0.3s' }}>
{paymentMethod === 'PIX' && successData.pixCode && (
<div>
<div style={{ width: 44, height: 44, background: 'var(--success-bg)', color: 'var(--success)', borderRadius: '50%', display: 'flex', alignItems: 'center', justifyContent: 'center', margin: '0 auto 16px' }}>
<CheckCircle2 size={24} />
</div>
<h3 style={{ fontSize: '18px', fontWeight: 800, marginBottom: '8px' }}>Cobrança Pix Gerada!</h3>
<p style={{ fontSize: '13px', color: 'var(--text-secondary)', marginBottom: '20px' }}>Escaneie o QR Code abaixo com seu banco para efetuar o pagamento da assinatura.</p>
{successData.pixQrCodeImage && (
<div style={{ background: 'white', padding: '16px', width: '200px', height: '200px', margin: '0 auto 20px', borderRadius: 'var(--radius-md)', display: 'flex', alignItems: 'center', justifyContent: 'center', border: '1px solid var(--border-color)' }}>
<img src={`data:image/png;base64,${successData.pixQrCodeImage}`} alt="QR Code Pix" style={{ width: '100%', height: '100%' }} />
</div>
)}
<div className="form-group">
<label className="form-label" style={{ fontSize: '11px', textTransform: 'uppercase' }}>Código Copia e Cola</label>
<div style={{ display: 'flex', gap: '8px' }}>
<input readOnly className="form-input" style={{ fontSize: '12px' }} value={successData.pixCode} />
<button className="btn btn-secondary" onClick={() => { navigator.clipboard.writeText(successData.pixCode); alert('Código copiado!'); }}>Copiar</button>
</div>
</div>
</div>
)}
{paymentMethod === 'BOLETO' && (
<div>
<div style={{ width: 44, height: 44, background: 'var(--success-bg)', color: 'var(--success)', borderRadius: '50%', display: 'flex', alignItems: 'center', justifyContent: 'center', margin: '0 auto 16px' }}>
<CheckCircle2 size={24} />
</div>
<h3 style={{ fontSize: '18px', fontWeight: 800, marginBottom: '8px' }}>Boleto Emitido com Sucesso!</h3>
<p style={{ fontSize: '13px', color: 'var(--text-secondary)', marginBottom: '24px' }}>
O boleto de cobrança foi emitido. Você receberá um e-mail com as instruções, ou poderá realizar o pagamento usando o link do painel de controle.
</p>
<button className="btn btn-primary" style={{ margin: '0 auto' }} onClick={() => window.open('https://sandbox.asaas.com', '_blank')}>
Visualizar Boleto PDF
</button>
</div>
)}
{paymentMethod === 'CREDIT_CARD' && (
<div>
<div style={{ width: 44, height: 44, background: 'var(--success-bg)', color: 'var(--success)', borderRadius: '50%', display: 'flex', alignItems: 'center', justifyContent: 'center', margin: '0 auto 16px' }}>
<ShieldCheck size={24} />
</div>
<h3 style={{ fontSize: '18px', fontWeight: 800, marginBottom: '8px' }}>Transação Aprovada!</h3>
<p style={{ fontSize: '13px', color: 'var(--text-secondary)', marginBottom: '24px' }}>
Sua assinatura do plano <strong>{selectedPlan.name}</strong> foi confirmada e está ativa. Obrigado!
</p>
<button className="btn btn-primary" style={{ margin: '0 auto' }} onClick={() => { setSelectedPlan(null); if (onPaymentSuccess) onPaymentSuccess(); }}>
Entrar no Sistema
</button>
</div>
)}
</div>
)}
</div>
{/* BARRA DE FERRAMENTAS DE SIMULAÇÃO (SANDBOX DEVELOPER MODE) */}
<div className="modal-footer" style={{ background: 'var(--bg-elevated)', flexDirection: 'column', gap: '12px', alignItems: 'stretch' }}>
<div style={{ fontSize: '12px', color: 'var(--text-muted)', textAlign: 'center', display: 'flex', alignItems: 'center', gap: '6px', justifyContent: 'center' }}>
<ShieldCheck size={14} color="var(--warning)" /> Modo Homologação Sandbox Ativo
</div>
<div style={{ display: 'flex', gap: '8px' }}>
<button className="btn btn-secondary" style={{ flex: 1 }} onClick={() => setSelectedPlan(null)}>
Fechar Janela
</button>
<button className="btn btn-primary" style={{ flex: 1.5, background: 'var(--warning)', border: 'none', color: 'black', fontWeight: 700 }}
onClick={handleSimulatePayment} disabled={loading}>
Simular Pagamento Webhook
</button>
</div>
</div>
</div>
</div>
)}
</div>
);
}

210
src/components/Team.tsx Normal file
View File

@ -0,0 +1,210 @@
'use client';
import { useState } from 'react';
import { Plus, X, Clock, Edit, Trash2, Star, AlertTriangle } from 'lucide-react';
interface Professional {
id: string; name: string; role: string; phone: string; email: string; commission: number;
services: string[]; schedule: Record<string, string>; rating: number; totalClients: number;
}
const initialTeam: Professional[] = [
{
id: '1', name: 'Carlos Silva', role: 'Barbeiro Senior', phone: '(11) 98888-1111',
email: 'carlos@demo.com', commission: 40, services: ['Corte Masculino', 'Barba Completa', 'Combo Corte + Barba', 'Corte Infantil', 'Design de Sobrancelha'],
schedule: { seg: '09:00-19:00', ter: '09:00-19:00', qua: '09:00-19:00', qui: '09:00-19:00', sex: '09:00-19:00', sab: '09:00-19:00' },
rating: 4.8, totalClients: 89
},
{
id: '2', name: 'Rafael Santos', role: 'Barbeiro', phone: '(11) 98888-2222',
email: 'rafael@demo.com', commission: 35, services: ['Corte Masculino', 'Barba Completa', 'Combo Corte + Barba'],
schedule: { seg: '10:00-20:00', ter: '10:00-20:00', qua: '10:00-20:00', qui: '10:00-20:00', sex: '10:00-20:00', sab: '10:00-20:00' },
rating: 4.6, totalClients: 65
},
];
const emptyProfessional: Professional = {
id: '', name: '', role: '', phone: '', email: '', commission: 30,
services: [], schedule: { seg: '09:00-18:00', ter: '09:00-18:00', qua: '09:00-18:00', qui: '09:00-18:00', sex: '09:00-18:00', sab: '09:00-18:00' },
rating: 5.0, totalClients: 0
};
export default function Team() {
const [team, setTeam] = useState<Professional[]>(initialTeam);
const [showModal, setShowModal] = useState(false);
const [editingProf, setEditingProf] = useState<Professional | null>(null);
const [formData, setFormData] = useState<Professional>(emptyProfessional);
const [deleteConfirm, setDeleteConfirm] = useState<string | null>(null);
const openAdd = () => {
setEditingProf(null);
setFormData({ ...emptyProfessional, id: Date.now().toString() });
setShowModal(true);
};
const openEdit = (prof: Professional) => {
setEditingProf(prof);
setFormData({ ...prof });
setShowModal(true);
};
const handleSave = () => {
if (!formData.name.trim()) return;
if (editingProf) {
setTeam(prev => prev.map(p => p.id === editingProf.id ? { ...formData } : p));
} else {
setTeam(prev => [...prev, { ...formData }]);
}
setShowModal(false);
setEditingProf(null);
};
const handleDelete = (id: string) => {
setTeam(prev => prev.filter(p => p.id !== id));
setDeleteConfirm(null);
};
return (
<div className="slide-up">
<div className="flex-between mb-6">
<p style={{ color: 'var(--text-secondary)', fontSize: '14px' }}>{team.length} profissionais ativos</p>
<button className="btn btn-primary" onClick={openAdd}>
<Plus size={16} /> Adicionar Profissional
</button>
</div>
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(auto-fill, minmax(380px, 1fr))', gap: '20px' }}>
{team.map(p => (
<div key={p.id} className="card" style={{ position: 'relative' }}>
{deleteConfirm === p.id && (
<div style={{
position: 'absolute', inset: 0, background: 'rgba(0,0,0,0.9)', borderRadius: 'var(--radius-lg)',
display: 'flex', flexDirection: 'column', alignItems: 'center', justifyContent: 'center', gap: '16px', zIndex: 10
}}>
<AlertTriangle size={36} color="var(--warning)" />
<p style={{ fontSize: '15px', fontWeight: 600, textAlign: 'center' }}>Excluir {p.name}?</p>
<p style={{ fontSize: '13px', color: 'var(--text-secondary)', textAlign: 'center' }}>Essa ação não pode ser desfeita.</p>
<div style={{ display: 'flex', gap: '10px' }}>
<button className="btn btn-danger" onClick={() => handleDelete(p.id)}>Sim, excluir</button>
<button className="btn btn-secondary" onClick={() => setDeleteConfirm(null)}>Cancelar</button>
</div>
</div>
)}
<div className="flex-between" style={{ marginBottom: '16px' }}>
<div className="flex-gap">
<div className="avatar avatar-lg">{p.name.split(' ').map(n => n[0]).join('').substring(0, 2)}</div>
<div>
<h3 style={{ fontSize: '16px', fontWeight: 700 }}>{p.name}</h3>
<p style={{ fontSize: '13px', color: 'var(--text-secondary)' }}>{p.role}</p>
</div>
</div>
<div style={{ display: 'flex', gap: '4px' }}>
<button className="btn-ghost btn-icon" title="Editar" onClick={() => openEdit(p)}><Edit size={14} /></button>
<button className="btn-ghost btn-icon" title="Excluir" style={{ color: 'var(--danger)' }}
onClick={() => setDeleteConfirm(p.id)}><Trash2 size={14} /></button>
</div>
</div>
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr 1fr', gap: '12px', marginBottom: '16px' }}>
<div style={{ textAlign: 'center', padding: '10px', background: 'var(--bg-primary)', borderRadius: 'var(--radius-sm)' }}>
<div style={{ fontSize: '18px', fontWeight: 800, color: 'var(--accent)' }}>{p.commission}%</div>
<div style={{ fontSize: '11px', color: 'var(--text-muted)' }}>Comissão</div>
</div>
<div style={{ textAlign: 'center', padding: '10px', background: 'var(--bg-primary)', borderRadius: 'var(--radius-sm)' }}>
<div style={{ fontSize: '18px', fontWeight: 800, color: 'var(--success)' }}>{p.totalClients}</div>
<div style={{ fontSize: '11px', color: 'var(--text-muted)' }}>Clientes</div>
</div>
<div style={{ textAlign: 'center', padding: '10px', background: 'var(--bg-primary)', borderRadius: 'var(--radius-sm)' }}>
<div style={{ fontSize: '18px', fontWeight: 800, color: 'var(--warning)' }}>
<span style={{ display: 'inline-flex', alignItems: 'center', gap: '2px' }}><Star size={14} fill="var(--warning)" /> {p.rating}</span>
</div>
<div style={{ fontSize: '11px', color: 'var(--text-muted)' }}>Avaliação</div>
</div>
</div>
<div style={{ marginBottom: '16px' }}>
<h4 style={{ fontSize: '12px', fontWeight: 600, color: 'var(--text-muted)', textTransform: 'uppercase', marginBottom: '8px' }}>
Serviços ({p.services.length})
</h4>
<div style={{ display: 'flex', gap: '6px', flexWrap: 'wrap' }}>
{p.services.map(s => (
<span key={s} className="badge badge-purple" style={{ fontSize: '11px' }}>{s}</span>
))}
</div>
</div>
<div>
<h4 style={{ fontSize: '12px', fontWeight: 600, color: 'var(--text-muted)', textTransform: 'uppercase', marginBottom: '8px' }}>
<Clock size={12} style={{ display: 'inline', marginRight: '4px' }} /> Horário de Trabalho
</h4>
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(3, 1fr)', gap: '6px' }}>
{Object.entries(p.schedule).map(([day, hours]) => (
<div key={day} style={{ fontSize: '12px', padding: '4px 8px', background: 'var(--bg-primary)', borderRadius: '4px', textAlign: 'center' }}>
<span style={{ color: 'var(--text-muted)', textTransform: 'capitalize' }}>{day} </span>
<span style={{ fontWeight: 600 }}>{hours}</span>
</div>
))}
</div>
</div>
</div>
))}
</div>
{team.length === 0 && (
<div className="empty-state">
<h3>Nenhum profissional cadastrado</h3>
<p>Adicione profissionais para começar a usar a agenda.</p>
<button className="btn btn-primary" onClick={openAdd}><Plus size={16} /> Adicionar Profissional</button>
</div>
)}
{showModal && (
<div className="modal-overlay" onClick={() => setShowModal(false)}>
<div className="modal" onClick={e => e.stopPropagation()}>
<div className="modal-header">
<h2 className="modal-title">{editingProf ? 'Editar Profissional' : 'Adicionar Profissional'}</h2>
<button className="btn-ghost btn-icon" onClick={() => setShowModal(false)}><X size={20} /></button>
</div>
<div className="modal-body">
<div className="form-group">
<label className="form-label">Nome completo *</label>
<input className="form-input" placeholder="Nome do profissional" value={formData.name}
onChange={e => setFormData(p => ({ ...p, name: e.target.value }))} />
</div>
<div className="grid-2">
<div className="form-group">
<label className="form-label">E-mail</label>
<input className="form-input" type="email" placeholder="email@exemplo.com" value={formData.email}
onChange={e => setFormData(p => ({ ...p, email: e.target.value }))} />
</div>
<div className="form-group">
<label className="form-label">Telefone</label>
<input className="form-input" placeholder="(00) 00000-0000" value={formData.phone}
onChange={e => setFormData(p => ({ ...p, phone: e.target.value }))} />
</div>
</div>
<div className="grid-2">
<div className="form-group">
<label className="form-label">Cargo</label>
<input className="form-input" placeholder="Ex: Barbeiro Senior" value={formData.role}
onChange={e => setFormData(p => ({ ...p, role: e.target.value }))} />
</div>
<div className="form-group">
<label className="form-label">Comissão (%)</label>
<input className="form-input" type="number" value={formData.commission}
onChange={e => setFormData(p => ({ ...p, commission: Number(e.target.value) }))} />
</div>
</div>
</div>
<div className="modal-footer">
<button className="btn btn-secondary" onClick={() => setShowModal(false)}>Cancelar</button>
<button className="btn btn-primary" onClick={handleSave}>
{editingProf ? 'Salvar Alterações' : 'Salvar'}
</button>
</div>
</div>
</div>
)}
</div>
);
}

49
src/components/Topbar.tsx Normal file
View File

@ -0,0 +1,49 @@
import { useState, useEffect } from 'react';
import { Bell, Menu, Search } from 'lucide-react';
interface TopbarProps {
title: string;
subtitle: string;
onMenuClick: () => void;
}
export default function Topbar({ title, subtitle, onMenuClick }: TopbarProps) {
const [avatarInitials, setAvatarInitials] = useState('AD');
useEffect(() => {
const savedName = localStorage.getItem('agendapro_tenant_name');
if (savedName) {
const initials = savedName.split(' ').map(n => n[0]).join('').substring(0, 2).toUpperCase();
setAvatarInitials(initials || 'BJ');
}
}, []);
return (
<header className="topbar">
<div style={{ display: 'flex', alignItems: 'center', gap: '16px' }}>
<button className="btn-ghost btn-icon" onClick={onMenuClick} style={{ display: 'none' }} id="menu-toggle">
<Menu size={22} />
</button>
<div className="topbar-left">
<h1>{title}</h1>
<p>{subtitle}</p>
</div>
</div>
<div className="topbar-right">
<div className="search-box">
<Search />
<input className="form-input" placeholder="Buscar..." style={{ width: '240px', paddingLeft: '42px' }} />
</div>
<button className="btn-ghost btn-icon" style={{ position: 'relative' }}>
<Bell size={20} />
<span style={{
position: 'absolute', top: '4px', right: '4px',
width: '8px', height: '8px', borderRadius: '50%',
background: 'var(--danger)'
}} />
</button>
<div className="avatar">{avatarInitials}</div>
</div>
</header>
);
}

187
src/lib/asaas.ts Normal file
View File

@ -0,0 +1,187 @@
import { query } from './db';
export interface AsaasConfig {
apiKey: string;
webhookSecret: string;
baseUrl: string;
}
/**
* Retrieves the Asaas configuration for a specific tenant.
* Falls back to environment variables if not present in tenant settings.
*/
export async function getTenantAsaasConfig(tenantId?: string): Promise<AsaasConfig> {
let settings: any = {};
if (tenantId) {
try {
const res = await query('SELECT settings FROM tenants WHERE id = $1', [tenantId]);
if (res.rows.length > 0) {
settings = res.rows[0].settings || {};
}
} catch (err) {
console.warn(`Postgres offline in Asaas helper. Falling back to env variables for tenant ${tenantId}`);
}
}
// Scoped API Key and Webhook Secret per tenant, with fallback to environment variables for sandbox/dev
const apiKey = settings.asaas_api_key || process.env.ASAAS_API_KEY;
const webhookSecret = settings.asaas_webhook_secret || process.env.ASAAS_WEBHOOK_SECRET;
if (!apiKey) {
throw new Error('Asaas API Key not configured (check .env or database)');
}
const isProduction = process.env.ASAAS_ENVIRONMENT === 'production' || settings.asaas_environment === 'production';
const baseUrl = isProduction
? 'https://api.asaas.com/v3'
: 'https://api-sandbox.asaas.com/v3';
return {
apiKey,
webhookSecret: webhookSecret || '',
baseUrl,
};
}
/**
* Helper to make requests to the Asaas API.
*/
async function asaasRequest(config: AsaasConfig, endpoint: string, options: RequestInit = {}) {
const url = `${config.baseUrl}${endpoint}`;
const headers = {
'Content-Type': 'application/json',
'access_token': config.apiKey,
'User-Agent': 'AgendaPRO-Integration',
...options.headers,
};
const response = await fetch(url, {
...options,
headers,
});
if (!response.ok) {
const errorText = await response.text();
let errorJson;
try {
errorJson = JSON.parse(errorText);
} catch {
// Not JSON
}
console.error(`Asaas API error on ${endpoint}:`, errorText);
const errorMessage = errorJson?.errors?.[0]?.description || `Asaas API request failed with status ${response.status}`;
throw new Error(errorMessage);
}
return response.json();
}
/**
* Creates a customer in Asaas.
*/
export async function createCustomer(
tenantId: string,
data: {
name: string;
cpfCnpj: string;
email?: string;
phone?: string;
mobilePhone?: string;
externalReference?: string;
}
) {
const config = await getTenantAsaasConfig(tenantId);
return asaasRequest(config, '/customers', {
method: 'POST',
body: JSON.stringify({
name: data.name,
cpfCnpj: data.cpfCnpj,
email: data.email,
phone: data.phone,
mobilePhone: data.mobilePhone,
externalReference: data.externalReference,
notificationDisabled: false,
}),
});
}
/**
* Creates a single payment (charge) in Asaas.
*/
export async function createPayment(
tenantId: string,
data: {
customer: string; // Asaas customer ID
billingType: 'PIX' | 'BOLETO' | 'CREDIT_CARD' | 'UNDEFINED';
value: number;
dueDate: string; // YYYY-MM-DD
description?: string;
externalReference?: string; // Local payment ID
}
) {
const config = await getTenantAsaasConfig(tenantId);
return asaasRequest(config, '/payments', {
method: 'POST',
body: JSON.stringify({
customer: data.customer,
billingType: data.billingType,
value: data.value,
dueDate: data.dueDate,
description: data.description,
externalReference: data.externalReference,
}),
});
}
/**
* Retrieves Pix QR Code details for a payment.
*/
export async function getPixQrCode(tenantId: string, paymentId: string) {
const config = await getTenantAsaasConfig(tenantId);
return asaasRequest(config, `/payments/${paymentId}/pixQrCode`, {
method: 'GET',
});
}
/**
* Creates a subscription in Asaas.
*/
export async function createSubscription(
tenantId: string,
data: {
customer: string; // Asaas customer ID
billingType: 'PIX' | 'BOLETO' | 'CREDIT_CARD';
value: number;
nextDueDate: string; // YYYY-MM-DD
cycle: 'WEEKLY' | 'BIWEEKLY' | 'MONTHLY' | 'BIMONTHLY' | 'QUARTERLY' | 'SEMIANNUALLY' | 'YEARLY';
description?: string;
externalReference?: string; // Local subscription ID
}
) {
const config = await getTenantAsaasConfig(tenantId);
return asaasRequest(config, '/subscriptions', {
method: 'POST',
body: JSON.stringify({
customer: data.customer,
billingType: data.billingType,
value: data.value,
nextDueDate: data.nextDueDate,
cycle: data.cycle,
description: data.description,
externalReference: data.externalReference,
}),
});
}
/**
* Cancels a subscription in Asaas.
*/
export async function cancelSubscription(tenantId: string, subscriptionId: string) {
const config = await getTenantAsaasConfig(tenantId);
return asaasRequest(config, `/subscriptions/${subscriptionId}`, {
method: 'DELETE',
});
}

29
src/lib/db.ts Normal file
View File

@ -0,0 +1,29 @@
import { Pool } from 'pg';
const pool = new Pool({
connectionString: process.env.DATABASE_URL || 'postgresql://agendapro:agendapro123@localhost:5432/agendapro',
max: 20,
idleTimeoutMillis: 30000,
connectionTimeoutMillis: 2000,
});
pool.on('error', (err) => {
console.error('Unexpected error on idle client', err);
});
export async function query(text: string, params?: unknown[]) {
const start = Date.now();
const res = await pool.query(text, params);
const duration = Date.now() - start;
if (process.env.NODE_ENV === 'development') {
console.log('Executed query', { text: text.substring(0, 80), duration, rows: res.rowCount });
}
return res;
}
export async function getClient() {
const client = await pool.connect();
return client;
}
export default pool;

42
tsconfig.json Normal file
View File

@ -0,0 +1,42 @@
{
"compilerOptions": {
"lib": [
"dom",
"dom.iterable",
"esnext"
],
"allowJs": true,
"skipLibCheck": true,
"strict": true,
"noEmit": true,
"esModuleInterop": true,
"module": "esnext",
"moduleResolution": "bundler",
"resolveJsonModule": true,
"isolatedModules": true,
"jsx": "preserve",
"incremental": true,
"plugins": [
{
"name": "next"
}
],
"paths": {
"@/*": [
"./src/*"
]
},
"target": "ES2017"
},
"include": [
"next-env.d.ts",
"**/*.ts",
"**/*.tsx",
".next/types/**/*.ts",
".next/dev/types/**/*.ts"
],
"exclude": [
"node_modules",
"admin"
]
}

1
tsconfig.tsbuildinfo Normal file

File diff suppressed because one or more lines are too long