feat: integracao asaas gateway e painel admin
This commit is contained in:
commit
50bf3fe210
|
|
@ -0,0 +1,6 @@
|
||||||
|
node_modules
|
||||||
|
.next
|
||||||
|
.git
|
||||||
|
*.md
|
||||||
|
.env*
|
||||||
|
docker-compose*.yml
|
||||||
|
|
@ -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?
|
||||||
|
|
@ -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"]
|
||||||
|
|
@ -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"]
|
||||||
|
|
@ -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.
|
||||||
|
|
@ -0,0 +1,3 @@
|
||||||
|
import type { NextConfig } from "next";
|
||||||
|
const nextConfig: NextConfig = { output: "standalone" };
|
||||||
|
export default nextConfig;
|
||||||
File diff suppressed because it is too large
Load Diff
|
|
@ -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"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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; }
|
||||||
|
|
@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -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
|
|
@ -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.
|
||||||
|
|
@ -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 $$;
|
||||||
|
|
@ -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 ""
|
||||||
|
|
@ -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
|
||||||
|
|
@ -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.
|
||||||
|
|
@ -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`.
|
||||||
|
|
@ -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.
|
||||||
|
|
@ -0,0 +1,7 @@
|
||||||
|
import type { NextConfig } from "next";
|
||||||
|
|
||||||
|
const nextConfig: NextConfig = {
|
||||||
|
output: "standalone",
|
||||||
|
};
|
||||||
|
|
||||||
|
export default nextConfig;
|
||||||
File diff suppressed because it is too large
Load Diff
|
|
@ -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"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,5 @@
|
||||||
|
import { NextResponse } from 'next/server';
|
||||||
|
|
||||||
|
export async function GET() {
|
||||||
|
return NextResponse.json({ status: 'ok', timestamp: new Date().toISOString() });
|
||||||
|
}
|
||||||
|
|
@ -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 });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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 });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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; }
|
||||||
|
}
|
||||||
|
|
@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -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)' }}>
|
||||||
|
Já tem uma conta? <a href="/login" style={{ color: 'var(--accent)', fontWeight: 600, textDecoration: 'none' }}>Faça login</a>
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -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 "{svc.name}"?</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>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -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>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -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',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
@ -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;
|
||||||
|
|
@ -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"
|
||||||
|
]
|
||||||
|
}
|
||||||
File diff suppressed because one or more lines are too long
Loading…
Reference in New Issue