From 038ce3f5560a450a37b1350b9b9e59efdcccfe0f Mon Sep 17 00:00:00 2001 From: Felipe Carvalho Date: Thu, 16 Apr 2026 06:40:41 +0200 Subject: [PATCH] =?UTF-8?q?feat:=20initial=20commit=20=E2=80=94=20asaas-ch?= =?UTF-8?q?eckout=20template=20white-label?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Template genérico de checkout com ASAAS, parametrizado via env vars. Inclui fluxo completo: checkout → pedido → polling → webhook → admin. Co-Authored-By: Claude Sonnet 4.6 --- .env.example | 44 + .gitignore | 6 + .specs/PROJECT.md | 57 + .specs/STATE.md | 25 + CLAUDE.md | 127 + Dockerfile | 43 + app/admin/columns.tsx | 73 + app/admin/layout.tsx | 14 + app/admin/page.tsx | 144 + app/api/agendamento/[id]/status/route.ts | 28 + app/api/asaas-webhook/route.ts | 102 + app/api/checkout/route.ts | 6 + app/api/pedido/[id]/route.ts | 48 + app/api/pedido/[id]/sync/route.ts | 45 + app/api/webhook/route.ts | 6 + app/comprar/page.tsx | 75 + app/globals.css | 84 + app/layout-client.tsx | 27 + app/layout.tsx | 40 + app/login/page.tsx | 70 + app/page.tsx | 64 + app/pedido/[id]/page.tsx | 37 + app/produtos/[id]/page.tsx | 71 + app/produtos/page.tsx | 5 + components.json | 21 + components/agendamentos-table.tsx | 133 + components/auth-guard.tsx | 25 + components/cupom-table.tsx | 239 + components/customer-form-dialog.tsx | 86 + components/customer-table.tsx | 47 + components/dashboard-header.tsx | 23 + components/dashboard-shell.tsx | 12 + components/dashboard-stats.tsx | 63 + components/data-table-column-header.tsx | 65 + components/data-table-faceted-filter.tsx | 129 + components/data-table-pagination.tsx | 85 + components/data-table-row-actions.tsx | 45 + components/data-table-toolbar.tsx | 48 + components/data-table-view-options.tsx | 50 + components/data-table.tsx | 97 + components/icons.tsx | 60 + components/overview.tsx | 32 + components/pedido-detail-modal.tsx | 226 + components/pedido-status.tsx | 263 + components/product-buy-form.tsx | 122 + components/product-form-dialog.tsx | 171 + components/product-table.tsx | 139 + components/products-table-skeleton.tsx | 67 + components/recent-sales.tsx | 60 + components/site-header.tsx | 69 + components/theme-provider.tsx | 7 + components/transaction-table.tsx | 197 + components/ui/accordion.tsx | 58 + components/ui/avatar.tsx | 50 + components/ui/badge.tsx | 36 + components/ui/button.tsx | 47 + components/ui/calendar.tsx | 21 + components/ui/card.tsx | 79 + components/ui/checkbox.tsx | 30 + components/ui/command.tsx | 69 + components/ui/dialog.tsx | 97 + components/ui/dropdown-menu.tsx | 100 + components/ui/input.tsx | 22 + components/ui/label.tsx | 26 + components/ui/popover.tsx | 29 + components/ui/select.tsx | 160 + components/ui/separator.tsx | 14 + components/ui/sheet.tsx | 109 + components/ui/skeleton.tsx | 7 + components/ui/switch.tsx | 28 + components/ui/table.tsx | 117 + components/ui/tabs.tsx | 55 + components/ui/textarea.tsx | 22 + components/ui/toast.tsx | 113 + components/ui/toaster.tsx | 24 + components/ui/use-toast.ts | 194 + data/data.tsx | 18 + hooks/use-toast.ts | 189 + lib/actions.ts | 424 ++ lib/asaas.ts | 207 + lib/auth-context.tsx | 48 + lib/config.ts | 11 + lib/schema.ts | 29 + lib/supabase.ts | 81 + lib/utils.ts | 6 + next-env.d.ts | 5 + next.config.js | 13 + next.config.mjs | 15 + package-lock.json | 8419 ++++++++++++++++++++++ package.json | 65 + pnpm-lock.yaml | 5537 ++++++++++++++ postcss.config.js | 6 + postcss.config.mjs | 8 + public/placeholder-logo.png | Bin 0 -> 568 bytes public/placeholder-logo.svg | 1 + public/placeholder-user.jpg | Bin 0 -> 1635 bytes public/placeholder.jpg | Bin 0 -> 1064 bytes public/placeholder.svg | 1 + setup.sh | 99 + styles/globals.css | 90 + tailwind.config.js | 77 + tsconfig.json | 28 + vercel.json | 3 + 103 files changed, 20709 insertions(+) create mode 100644 .env.example create mode 100644 .gitignore create mode 100644 .specs/PROJECT.md create mode 100644 .specs/STATE.md create mode 100644 CLAUDE.md create mode 100644 Dockerfile create mode 100644 app/admin/columns.tsx create mode 100644 app/admin/layout.tsx create mode 100644 app/admin/page.tsx create mode 100644 app/api/agendamento/[id]/status/route.ts create mode 100644 app/api/asaas-webhook/route.ts create mode 100644 app/api/checkout/route.ts create mode 100644 app/api/pedido/[id]/route.ts create mode 100644 app/api/pedido/[id]/sync/route.ts create mode 100644 app/api/webhook/route.ts create mode 100644 app/comprar/page.tsx create mode 100644 app/globals.css create mode 100644 app/layout-client.tsx create mode 100644 app/layout.tsx create mode 100644 app/login/page.tsx create mode 100644 app/page.tsx create mode 100644 app/pedido/[id]/page.tsx create mode 100644 app/produtos/[id]/page.tsx create mode 100644 app/produtos/page.tsx create mode 100644 components.json create mode 100644 components/agendamentos-table.tsx create mode 100644 components/auth-guard.tsx create mode 100644 components/cupom-table.tsx create mode 100644 components/customer-form-dialog.tsx create mode 100644 components/customer-table.tsx create mode 100644 components/dashboard-header.tsx create mode 100644 components/dashboard-shell.tsx create mode 100644 components/dashboard-stats.tsx create mode 100644 components/data-table-column-header.tsx create mode 100644 components/data-table-faceted-filter.tsx create mode 100644 components/data-table-pagination.tsx create mode 100644 components/data-table-row-actions.tsx create mode 100644 components/data-table-toolbar.tsx create mode 100644 components/data-table-view-options.tsx create mode 100644 components/data-table.tsx create mode 100644 components/icons.tsx create mode 100644 components/overview.tsx create mode 100644 components/pedido-detail-modal.tsx create mode 100644 components/pedido-status.tsx create mode 100644 components/product-buy-form.tsx create mode 100644 components/product-form-dialog.tsx create mode 100644 components/product-table.tsx create mode 100644 components/products-table-skeleton.tsx create mode 100644 components/recent-sales.tsx create mode 100644 components/site-header.tsx create mode 100644 components/theme-provider.tsx create mode 100644 components/transaction-table.tsx create mode 100644 components/ui/accordion.tsx create mode 100644 components/ui/avatar.tsx create mode 100644 components/ui/badge.tsx create mode 100644 components/ui/button.tsx create mode 100644 components/ui/calendar.tsx create mode 100644 components/ui/card.tsx create mode 100644 components/ui/checkbox.tsx create mode 100644 components/ui/command.tsx create mode 100644 components/ui/dialog.tsx create mode 100644 components/ui/dropdown-menu.tsx create mode 100644 components/ui/input.tsx create mode 100644 components/ui/label.tsx create mode 100644 components/ui/popover.tsx create mode 100644 components/ui/select.tsx create mode 100644 components/ui/separator.tsx create mode 100644 components/ui/sheet.tsx create mode 100644 components/ui/skeleton.tsx create mode 100644 components/ui/switch.tsx create mode 100644 components/ui/table.tsx create mode 100644 components/ui/tabs.tsx create mode 100644 components/ui/textarea.tsx create mode 100644 components/ui/toast.tsx create mode 100644 components/ui/toaster.tsx create mode 100644 components/ui/use-toast.ts create mode 100644 data/data.tsx create mode 100644 hooks/use-toast.ts create mode 100644 lib/actions.ts create mode 100644 lib/asaas.ts create mode 100644 lib/auth-context.tsx create mode 100644 lib/config.ts create mode 100644 lib/schema.ts create mode 100644 lib/supabase.ts create mode 100644 lib/utils.ts create mode 100644 next-env.d.ts create mode 100644 next.config.js create mode 100644 next.config.mjs create mode 100644 package-lock.json create mode 100644 package.json create mode 100644 pnpm-lock.yaml create mode 100644 postcss.config.js create mode 100644 postcss.config.mjs create mode 100644 public/placeholder-logo.png create mode 100644 public/placeholder-logo.svg create mode 100644 public/placeholder-user.jpg create mode 100644 public/placeholder.jpg create mode 100644 public/placeholder.svg create mode 100755 setup.sh create mode 100644 styles/globals.css create mode 100644 tailwind.config.js create mode 100644 tsconfig.json create mode 100644 vercel.json diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..383f757 --- /dev/null +++ b/.env.example @@ -0,0 +1,44 @@ +# ============================================================ +# asaas-checkout — Configuração da instância +# Copie este arquivo para .env.local e preencha os valores +# ============================================================ + +# ------------------------------------------------------------ +# Identidade da aplicação +# ------------------------------------------------------------ +NEXT_PUBLIC_APP_NAME="Meu Negócio" +NEXT_PUBLIC_APP_LOGO_URL="" # URL da logo (deixar vazio para usar só o nome) +NEXT_PUBLIC_APP_PRIMARY_COLOR="#1d4ed8" + +# Redirect após pagamento confirmado (ex: link de agendamento, página de boas-vindas) +NEXT_PUBLIC_AFTER_PAYMENT_REDIRECT="/" + +# Suporte (exibido na página de produtos) +NEXT_PUBLIC_SUPPORT_EMAIL="" +NEXT_PUBLIC_SUPPORT_WHATSAPP="" # Formato: 5511999999999 + +# ------------------------------------------------------------ +# Admin +# ------------------------------------------------------------ +NEXT_PUBLIC_ADMIN_USER="admin" +NEXT_PUBLIC_ADMIN_PASSWORD="troque-esta-senha" + +# ------------------------------------------------------------ +# ASAAS — Gateway de pagamento +# ------------------------------------------------------------ +ASAAS_API_KEY="" +ASAAS_ENV="sandbox" # sandbox | production + +# ------------------------------------------------------------ +# Supabase — Banco de dados +# ------------------------------------------------------------ +NEXT_PUBLIC_SUPABASE_URL="" +NEXT_PUBLIC_SUPABASE_ANON_KEY="" +SUPABASE_SERVICE_ROLE_KEY="" + +# ------------------------------------------------------------ +# n8n — Automações (opcional) +# Deixar vazio para desativar notificações +# ------------------------------------------------------------ +N8N_WEBHOOK_URL="" # Webhook de pagamento confirmado +N8N_PIX_WEBHOOK_URL="" # Webhook de PIX gerado diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..238c4d9 --- /dev/null +++ b/.gitignore @@ -0,0 +1,6 @@ +.env.local +.env +node_modules/ +.next/ +stack.*.yaml +*.local diff --git a/.specs/PROJECT.md b/.specs/PROJECT.md new file mode 100644 index 0000000..fd727d2 --- /dev/null +++ b/.specs/PROJECT.md @@ -0,0 +1,57 @@ +# PROJECT.md — VixCert + +## Visão Geral +Plataforma de venda de certificados digitais (e-CPF, e-CNPJ, SSL, NFe) com checkout próprio integrado ao ASAAS. VixCert é AR credenciada ICP-Brasil. + +## Identidade +| Campo | Valor | +|-------|-------| +| Domínio | vixcert.oakia.com.br | +| Stack file | /root/vixcert.yaml | +| Código na VPS | /root/vixcert | +| Repo | github.com/felipesabores/vixcert-final | +| Branch | final | +| Imagem Docker | vixcert:latest (build local) | +| Referência visual | https://boneca.vixcert.com.br (repo vixcert sem -final — não modificar) | + +## Stack Técnica +- Next.js 15 + TypeScript + Tailwind + shadcn/ui + Framer Motion +- Docker Swarm + Traefik + +## Infraestrutura +| Recurso | Detalhe | +|---------|---------| +| Rede | network_public | +| Banco | Supabase cloud — exceção (ver abaixo) | +| TLS | Traefik + Let's Encrypt | + +## Exceções às Convenções +| Convenção | Exceção | Justificativa | +|-----------|---------|---------------| +| Self-hosted / PostgreSQL local | Supabase cloud (projeto xqckfgwskenseorytcxq) | Projeto iniciado antes da convenção self-hosted. Migração desejável, não prioritária. | +| Self-hosted | ASAAS (gateway de pagamento) | Necessário para PIX/Boleto/Cartão | +| Self-hosted | Resend (email transacional) | Necessário para entrega de emails | + +## Integrações Externas +| Serviço | Detalhe | +|---------|---------| +| Supabase | PostgreSQL + tabelas: produtos, clientes, pedidos, cupons, agendamentos | +| ASAAS | PIX, Boleto, Cartão — sandbox ativo | +| Resend | Email from: `VixCert ` | +| Evolution API v2 | WhatsApp self-hosted — `https://evolution2.oakia.com.br` — instância: `vixcert` | + +## Automações n8n +| Workflow | ID | Webhook | +|---------|-----|---------| +| VixCert — Notificação de Pagamento | tfuwr2t0NEo0dH5g | POST /webhook/vixcert-pagamento | +| VixCert — PIX Gerado | lKrE9vTDnSaUAjMq | POST /webhook/vixcert-pix | + +## Deploy +```bash +cd /root/vixcert && bash rebuild.sh +# OBRIGATÓRIO forçar restart (tag sempre latest): +docker service update --image vixcert:latest --force vixcert_vixcert +``` + +## Aviso Crítico — Edição de Workflows n8n +Sempre atualizar AMBAS as tabelas: `workflow_entity` E `workflow_history` + reiniciar serviço. diff --git a/.specs/STATE.md b/.specs/STATE.md new file mode 100644 index 0000000..7b95020 --- /dev/null +++ b/.specs/STATE.md @@ -0,0 +1,25 @@ +# STATE.md — VixCert + +Última atualização: 2026-04-15 + +## Status Atual +Em produção. PIX funcionando. ASAAS em modo sandbox. + +## Pendências +- [ ] ASAAS sandbox → produção (trocar `ASAAS_ENV` e key no vixcert.yaml) +- [ ] Proteger /admin com autenticação real +- [ ] Lembretes de vencimento de certificado +- [ ] Testar fluxo completo do checkout real (PIX → WhatsApp chega no cliente) + +## Decisões Tomadas +- Repo `vixcert` (sem -final) = boneca/referência visual apenas — nunca modificar +- Constraint CPF/CNPJ duplicada resolvida com upsert em lib/actions.ts +- Produto "teste de pix" desativado (valor abaixo do mínimo ASAAS R$5) +- HeroSection com layout boneca adotado como padrão visual + +## Aviso Crítico — Edição de Workflows n8n +Para editar workflows do VixCert no n8n, sempre atualizar AMBAS as tabelas: +`workflow_entity` E `workflow_history` + reiniciar serviço n8n. + +## Deferred Ideas +- Migração Supabase → PostgreSQL local (desejável, não prioritário) diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 0000000..27969e7 --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1,127 @@ +# CLAUDE.md — asaas-checkout (template) + +Este arquivo define as regras de comportamento do Claude Code neste projeto. + +## O que é este projeto + +`asaas-checkout` é um template white-label de checkout com ASAAS. +Não é uma aplicação de produto específico — é uma base reutilizável. + +Cada instância real (ex: vixcert, outra-empresa) é uma cópia/fork deste repo, +parametrizada via variáveis de ambiente. + +## Estrutura + +``` +app/ + page.tsx — homepage listando produtos + comprar/page.tsx — listagem de produtos + comprar/[id]/page.tsx — página de checkout do produto + pedido/[id]/page.tsx — acompanhamento de pedido + api/ + checkout/route.ts — cria pedido + pagamento ASAAS + pedido/[id]/route.ts — polling de status (consulta ASAAS se PENDING) + webhook/route.ts — recebe notificações ASAAS + admin/ — dashboard admin + login/page.tsx — login simples via env vars + +components/ + pedido-status.tsx — cliente component com polling 5s + product-buy-form.tsx — formulário de compra + site-header.tsx — cabeçalho parametrizado + dashboard-stats.tsx — métricas do admin + transaction-table.tsx — tabela de pedidos com filtros + pedido-detail-modal.tsx— modal de detalhes do pedido + +lib/ + config.ts — lê todas as env vars → appConfig + auth-context.tsx — autenticação admin via env vars + supabase.ts — cliente Supabase + asaas.ts — funções ASAAS (criar pagamento, buscar status) + actions.ts — server actions (getPedidos, etc.) +``` + +## Variáveis de ambiente + +| Variável | Obrigatória | Descrição | +|----------|-------------|-----------| +| `NEXT_PUBLIC_APP_NAME` | sim | Nome exibido na interface | +| `NEXT_PUBLIC_APP_LOGO_URL` | não | URL da logo (texto se vazio) | +| `NEXT_PUBLIC_APP_PRIMARY_COLOR` | não | Cor primária hex (default #1d4ed8) | +| `NEXT_PUBLIC_AFTER_PAYMENT_REDIRECT` | não | URL pós-pagamento (default /) | +| `NEXT_PUBLIC_SUPPORT_EMAIL` | não | Email de suporte | +| `NEXT_PUBLIC_SUPPORT_WHATSAPP` | não | WhatsApp de suporte (55119...) | +| `NEXT_PUBLIC_ADMIN_USER` | sim | Usuário do admin | +| `NEXT_PUBLIC_ADMIN_PASSWORD` | sim | Senha do admin | +| `ASAAS_API_KEY` | sim | Chave da API ASAAS | +| `ASAAS_ENV` | sim | `sandbox` ou `production` | +| `NEXT_PUBLIC_SUPABASE_URL` | sim | URL do projeto Supabase | +| `NEXT_PUBLIC_SUPABASE_ANON_KEY` | sim | Anon key Supabase | +| `SUPABASE_SERVICE_ROLE_KEY` | sim | Service role key Supabase | +| `N8N_WEBHOOK_URL` | não | Webhook pagamento confirmado | +| `N8N_PIX_WEBHOOK_URL` | não | Webhook PIX gerado | + +Ver `.env.example` para exemplo completo. + +## Como criar uma nova instância + +```bash +# 1. Clonar o template +git clone git@git.oakia.com.br:admin/asaas-checkout.git meu-projeto +cd meu-projeto + +# 2. Remover origin e apontar para novo repo +git remote remove origin +git remote add origin git@git.oakia.com.br:admin/meu-projeto.git + +# 3. Rodar o setup interativo (gera .env.local e stack.yaml) +bash setup.sh + +# 4. Instalar dependências e testar +npm install +npm run dev + +# 5. Build e deploy +docker build -t meu-projeto:latest . +docker stack deploy -c stack.meu-projeto.yaml meu-projeto +``` + +## Schema do banco (Supabase) + +O template espera as seguintes tabelas (DDL em `data/schema.sql` se existir): + +- `clientes` — nome, email, cpf_cnpj, telefone +- `produtos` — nome, descricao, valor_centavos, tipo, validade, midia, ativo +- `pedidos` — cliente_id, produto_id, status, valor_centavos, metodo_pagamento, + asaas_payment_id, pix_copia_cola, pix_qrcode_url, asaas_invoice_url, paid_at + +## Fluxo de pagamento + +1. Cliente preenche form → `POST /api/checkout` → cria pedido no Supabase + pagamento no ASAAS +2. Cliente é redirecionado para `/pedido/[id]` +3. Frontend faz polling em `GET /api/pedido/[id]` a cada 5s +4. Se status=PENDING, a API consulta ASAAS diretamente e atualiza o DB +5. ASAAS também dispara webhook para `POST /api/webhook` (registrar via ASAAS API) +6. Quando pago: exibe confirmação + botão para `AFTER_PAYMENT_REDIRECT` + +## Zonas de comportamento (herda do harness /root) + +### Verde +- Leitura de arquivos, logs, consultas +- Geração de specs e documentação + +### Amarelo (executa e avisa) +- Mudanças em componentes genéricos do template +- Atualização de dependências + +### Vermelho (nunca sem "confirmo, pode executar") +- Push para `origin` (afeta todas as instâncias futuras) +- Mudanças no schema SQL que quebram instâncias existentes +- Alteração nas variáveis obrigatórias (quebra instâncias sem a var) + +## Convenções + +- Todo texto visível ao usuário deve vir de `appConfig` (nunca hardcoded) +- Referências a produto específico (ex: "certificado digital", "ICP-Brasil") são proibidas +- Componentes novos: genéricos, sem assumir domínio de negócio +- Testar sempre com `ASAAS_ENV=sandbox` antes de production diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..29a141a --- /dev/null +++ b/Dockerfile @@ -0,0 +1,43 @@ +FROM node:20-alpine AS base + +# Dependências +FROM base AS deps +RUN apk add --no-cache libc6-compat +WORKDIR /app +COPY package.json package-lock.json ./ +RUN npm ci --legacy-peer-deps + +# Build +FROM base AS builder +WORKDIR /app +COPY --from=deps /app/node_modules ./node_modules +COPY . . + +ENV NEXT_TELEMETRY_DISABLED=1 + +ARG NEXT_PUBLIC_SUPABASE_URL +ARG NEXT_PUBLIC_SUPABASE_ANON_KEY + +RUN npm run build + +# Runner +FROM base AS runner +WORKDIR /app + +ENV NODE_ENV=production +ENV NEXT_TELEMETRY_DISABLED=1 + +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"] diff --git a/app/admin/columns.tsx b/app/admin/columns.tsx new file mode 100644 index 0000000..eed90fc --- /dev/null +++ b/app/admin/columns.tsx @@ -0,0 +1,73 @@ +"use client" + +import type { ColumnDef } from "@tanstack/react-table" +import { Checkbox } from "@/components/ui/checkbox" +import type { productSchema } from "@/lib/schema" +import { DataTableColumnHeader } from "@/components/data-table-column-header" +import { DataTableRowActions } from "@/components/data-table-row-actions" +import type { z } from "zod" + +export type Product = z.infer + +export const columns: ColumnDef[] = [ + { + id: "select", + header: ({ table }) => ( + table.toggleAllPageRowsSelected(!!value)} + aria-label="Selecionar tudo" + className="translate-y-[2px]" + /> + ), + cell: ({ row }) => ( + row.toggleSelected(!!value)} + aria-label="Selecionar linha" + className="translate-y-[2px]" + /> + ), + enableSorting: false, + enableHiding: false, + }, + { + accessorKey: "name", + header: ({ column }) => , + cell: ({ row }) =>
{row.getValue("name")}
, + enableSorting: true, + enableHiding: true, + }, + { + accessorKey: "price.display_amount", + header: ({ column }) => , + cell: ({ row }) => { + const amount = Number.parseFloat(row.getValue("price.display_amount")) + const formatted = new Intl.NumberFormat("pt-BR", { + style: "currency", + currency: "BRL", + }).format(amount) + return
{formatted}
+ }, + enableSorting: true, + enableHiding: true, + }, + { + accessorKey: "tipo", + header: ({ column }) => , + cell: ({ row }) =>
{row.getValue("tipo")}
, + enableSorting: true, + enableHiding: true, + }, + { + accessorKey: "validade", + header: ({ column }) => , + cell: ({ row }) =>
{row.getValue("validade")}
, + enableSorting: true, + enableHiding: true, + }, + { + id: "actions", + cell: ({ row }) => , + }, +] diff --git a/app/admin/layout.tsx b/app/admin/layout.tsx new file mode 100644 index 0000000..666219f --- /dev/null +++ b/app/admin/layout.tsx @@ -0,0 +1,14 @@ +import type React from "react" + +export default function AdminLayout({ + children, +}: { + children: React.ReactNode +}) { + return ( +
+ {/* Remover o SiteHeader e SiteFooter daqui, pois já estão no layout principal */} +
{children}
+
+ ) +} diff --git a/app/admin/page.tsx b/app/admin/page.tsx new file mode 100644 index 0000000..5fde957 --- /dev/null +++ b/app/admin/page.tsx @@ -0,0 +1,144 @@ +import { getProdutos, getPedidos, getClientes, getCupons, getAgendamentos } from "@/lib/actions" +import { ProductTable } from "@/components/product-table" +import { TransactionTable } from "@/components/transaction-table" +import { CustomerTable } from "@/components/customer-table" +import { DashboardHeader } from "@/components/dashboard-header" +import { DashboardShell } from "@/components/dashboard-shell" +import { DashboardStats } from "@/components/dashboard-stats" +import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs" +import { ProductFormDialog } from "@/components/product-form-dialog" +import { CustomerFormDialog } from "@/components/customer-form-dialog" +import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card" +import { Overview } from "@/components/overview" +import { RecentSales } from "@/components/recent-sales" +import { AuthGuard } from "@/components/auth-guard" +import { CupomTable } from "@/components/cupom-table" +import { AgendamentosTable } from "@/components/agendamentos-table" + +export const dynamic = "force-dynamic" + +export const metadata = { + title: "Dashboard — Admin", +} + +export default async function AdminDashboard() { + const [produtos, pedidos, clientes, cupons, agendamentos] = await Promise.all([ + getProdutos(), + getPedidos({ limit: 200 }), + getClientes({ limit: 200 }), + getCupons(), + getAgendamentos({ limit: 100 }), + ]) + + const pedidosConfirmados = pedidos.filter( + (p) => p.status === "RECEIVED" || p.status === "CONFIRMED" + ) + const pedidosPendentes = pedidos.filter((p) => p.status === "PENDING").length + const receitaTotal = pedidosConfirmados.reduce((acc, p) => acc + p.valor_centavos / 100, 0) + const ticketMedio = pedidosConfirmados.length > 0 ? receitaTotal / pedidosConfirmados.length : 0 + + // Receita mensal (12 meses) para o gráfico + const receitaMensal = Array(12).fill(0) + pedidosConfirmados.forEach((p) => { + const mes = new Date(p.created_at).getMonth() + receitaMensal[mes] += p.valor_centavos / 100 + }) + + // Últimas 5 vendas para o card de resumo + const ultimasVendas = pedidosConfirmados.slice(0, 5) as Parameters[0]["vendas"] + + // Contagem de agendamentos pendentes para badge + const agendamentosPendentes = agendamentos.filter( + (a) => a.status === "pendente" || a.status === "confirmado" + ).length + + return ( + + + + + + + {/* Cards de métricas */} +
+ +
+ + {/* Gráfico + últimas vendas */} +
+ + + Receita Mensal + + + + + + + + Últimas Vendas + + {pedidosConfirmados.length} venda{pedidosConfirmados.length !== 1 ? "s" : ""} confirmada{pedidosConfirmados.length !== 1 ? "s" : ""} no total. + + + + + + +
+ + {/* Abas */} + + + Pedidos + + Agendamentos + {agendamentosPendentes > 0 && ( + + {agendamentosPendentes} + + )} + + Produtos + Clientes + Cupons + + + + + + + + [0]["agendamentos"]} /> + + + +
+ +
+ +
+ + +
+ +
+ +
+ + + + +
+
+
+ ) +} diff --git a/app/api/agendamento/[id]/status/route.ts b/app/api/agendamento/[id]/status/route.ts new file mode 100644 index 0000000..62c76cd --- /dev/null +++ b/app/api/agendamento/[id]/status/route.ts @@ -0,0 +1,28 @@ +import { NextResponse } from "next/server" +import { createServiceClient } from "@/lib/supabase" + +export async function PATCH( + request: Request, + { params }: { params: Promise<{ id: string }> } +) { + const { id } = await params + const body = await request.json().catch(() => ({})) + const { status } = body as { status?: string } + + const STATUSES_VALIDOS = ["pendente", "confirmado", "realizado", "cancelado"] + if (!status || !STATUSES_VALIDOS.includes(status)) { + return NextResponse.json({ error: "Status inválido" }, { status: 400 }) + } + + const supabase = createServiceClient() + const { error } = await supabase + .from("agendamentos") + .update({ status, updated_at: new Date().toISOString() }) + .eq("id", id) + + if (error) { + return NextResponse.json({ error: error.message }, { status: 500 }) + } + + return NextResponse.json({ ok: true, status }) +} diff --git a/app/api/asaas-webhook/route.ts b/app/api/asaas-webhook/route.ts new file mode 100644 index 0000000..b98cff7 --- /dev/null +++ b/app/api/asaas-webhook/route.ts @@ -0,0 +1,102 @@ +import { NextResponse } from "next/server" +import { createServiceClient } from "@/lib/supabase" +import { parseWebhookPayload, mapearStatus } from "@/lib/asaas" + +const N8N_WEBHOOK_URL = process.env.N8N_WEBHOOK_URL ?? "" + +export async function POST(request: Request) { + let body: unknown + + try { + body = await request.json() + } catch { + return NextResponse.json({ error: "Invalid JSON" }, { status: 400 }) + } + + const supabase = createServiceClient() + + // Salva log do webhook para auditoria + await supabase.from("webhook_logs").insert({ + evento: (body as { event?: string }).event ?? "unknown", + payload: body, + processado: false, + }) + + try { + const event = parseWebhookPayload(body) + + // Processa apenas eventos de pagamento + if (!event.event.startsWith("PAYMENT_")) { + return NextResponse.json({ ok: true }) + } + + const novoStatus = mapearStatus(event.payment.status) + const isPago = + event.payment.status === "RECEIVED" || event.payment.status === "CONFIRMED" + const paidAt = isPago ? new Date().toISOString() : null + + const updateData: Record = { status: novoStatus } + if (paidAt) updateData.paid_at = paidAt + + // Atualiza pedido e busca dados completos para notificação + const { data: pedido } = await supabase + .from("pedidos") + .update(updateData) + .eq("asaas_payment_id", event.payment.id) + .select("id, valor_centavos, metodo_pagamento, cliente_id, produto_id") + .single() + + if (pedido) { + // Marca webhook como processado + await supabase + .from("webhook_logs") + .update({ processado: true }) + .eq("evento", event.event) + .order("created_at", { ascending: false }) + .limit(1) + + // Dispara automação n8n apenas em pagamentos confirmados + if (isPago && N8N_WEBHOOK_URL) { + // Busca cliente e produto para enriquecer o payload + const [{ data: cliente }, { data: produto }] = await Promise.all([ + supabase.from("clientes").select("nome, email, telefone, cpf_cnpj").eq("id", pedido.cliente_id).single(), + supabase.from("produtos").select("nome, tipo, validade, midia").eq("id", pedido.produto_id).single(), + ]) + + const notificacao = { + evento: event.event, + pedido_id: pedido.id, + asaas_payment_id: event.payment.id, + valor: (pedido.valor_centavos / 100).toLocaleString("pt-BR", { minimumFractionDigits: 2, style: "currency", currency: "BRL" }), + metodo: pedido.metodo_pagamento, + cliente: { + nome: cliente?.nome ?? "—", + email: cliente?.email ?? "—", + telefone: cliente?.telefone ?? "—", + cpf_cnpj: cliente?.cpf_cnpj ?? "—", + }, + produto: { + nome: produto?.nome ?? "—", + tipo: produto?.tipo ?? "—", + validade: produto?.validade ?? "—", + midia: produto?.midia ?? "—", + }, + pago_em: paidAt, + link_agendamento: process.env.NEXT_PUBLIC_AFTER_PAYMENT_REDIRECT ?? "/", + } + + // Fire-and-forget — não bloqueia a resposta ao ASAAS + fetch(N8N_WEBHOOK_URL, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(notificacao), + }).catch((err) => console.error("n8n webhook error:", err)) + } + } + + return NextResponse.json({ ok: true }) + } catch (error) { + console.error("Webhook processing error:", error) + return NextResponse.json({ error: "Processing failed" }, { status: 500 }) + } +} diff --git a/app/api/checkout/route.ts b/app/api/checkout/route.ts new file mode 100644 index 0000000..37bd861 --- /dev/null +++ b/app/api/checkout/route.ts @@ -0,0 +1,6 @@ +// Rota legada do Stripe — substituída por /api/asaas-webhook +import { NextResponse } from "next/server" + +export async function GET() { + return NextResponse.json({ message: "Use ASAAS checkout via Server Actions" }, { status: 410 }) +} diff --git a/app/api/pedido/[id]/route.ts b/app/api/pedido/[id]/route.ts new file mode 100644 index 0000000..ba7a53c --- /dev/null +++ b/app/api/pedido/[id]/route.ts @@ -0,0 +1,48 @@ +import { NextResponse } from "next/server" +import { createServiceClient } from "@/lib/supabase" +import { buscarPagamentoAsaas, mapearStatus } from "@/lib/asaas" + +export async function GET( + _request: Request, + { params }: { params: Promise<{ id: string }> } +) { + const { id } = await params + const supabase = createServiceClient() + + const { data, error } = await supabase + .from("pedidos") + .select( + "id, status, valor_centavos, metodo_pagamento, pix_copia_cola, pix_qrcode_url, asaas_invoice_url, asaas_payment_id, paid_at, clientes(nome, email), produtos(nome, validade, midia)" + ) + .eq("id", id) + .single() + + if (error || !data) { + return NextResponse.json({ error: "Pedido não encontrado" }, { status: 404 }) + } + + // Se ainda está pendente, consulta o ASAAS diretamente para pegar status atualizado + if (data.status === "PENDING" && data.asaas_payment_id) { + try { + const asaasPayment = await buscarPagamentoAsaas(data.asaas_payment_id) + const novoStatus = mapearStatus(asaasPayment.status) + + if (novoStatus !== "PENDING") { + const isPago = novoStatus === "RECEIVED" || novoStatus === "CONFIRMED" + const updateData: Record = { status: novoStatus } + if (isPago) updateData.paid_at = new Date().toISOString() + + await supabase + .from("pedidos") + .update(updateData) + .eq("id", id) + + return NextResponse.json({ ...data, status: novoStatus, paid_at: isPago ? updateData.paid_at : null }) + } + } catch { + // Falha silenciosa — retorna o que tem no banco + } + } + + return NextResponse.json(data) +} diff --git a/app/api/pedido/[id]/sync/route.ts b/app/api/pedido/[id]/sync/route.ts new file mode 100644 index 0000000..9a2b5d0 --- /dev/null +++ b/app/api/pedido/[id]/sync/route.ts @@ -0,0 +1,45 @@ +import { NextResponse } from "next/server" +import { createServiceClient } from "@/lib/supabase" +import { buscarPagamentoAsaas, mapearStatus } from "@/lib/asaas" + +export async function POST( + _request: Request, + { params }: { params: Promise<{ id: string }> } +) { + const { id } = await params + const supabase = createServiceClient() + + const { data: pedido } = await supabase + .from("pedidos") + .select("id, status, asaas_payment_id") + .eq("id", id) + .single() + + if (!pedido) { + return NextResponse.json({ error: "Pedido não encontrado" }, { status: 404 }) + } + + if (!pedido.asaas_payment_id) { + return NextResponse.json({ error: "Pedido sem ID ASAAS" }, { status: 400 }) + } + + try { + const asaasPayment = await buscarPagamentoAsaas(pedido.asaas_payment_id) + const novoStatus = mapearStatus(asaasPayment.status) + const isPago = novoStatus === "RECEIVED" || novoStatus === "CONFIRMED" + + const updateData: Record = { status: novoStatus } + if (isPago && !pedido.status.includes("RECEIVED") && !pedido.status.includes("CONFIRMED")) { + updateData.paid_at = new Date().toISOString() + } + + await supabase.from("pedidos").update(updateData).eq("id", id) + + return NextResponse.json({ status: novoStatus, asaas_status: asaasPayment.status }) + } catch (err) { + return NextResponse.json( + { error: err instanceof Error ? err.message : "Erro ao consultar ASAAS" }, + { status: 500 } + ) + } +} diff --git a/app/api/webhook/route.ts b/app/api/webhook/route.ts new file mode 100644 index 0000000..e0535c0 --- /dev/null +++ b/app/api/webhook/route.ts @@ -0,0 +1,6 @@ +// Rota legada do Stripe — substituída por /api/asaas-webhook +import { NextResponse } from "next/server" + +export async function POST() { + return NextResponse.json({ message: "Use /api/asaas-webhook" }, { status: 410 }) +} diff --git a/app/comprar/page.tsx b/app/comprar/page.tsx new file mode 100644 index 0000000..0634358 --- /dev/null +++ b/app/comprar/page.tsx @@ -0,0 +1,75 @@ +export const dynamic = "force-dynamic" + +import Link from "next/link" +import { getProdutos } from "@/lib/actions" +import { Button } from "@/components/ui/button" +import { Card, CardContent, CardFooter, CardHeader, CardTitle } from "@/components/ui/card" +import { appConfig } from "@/lib/config" + +export const metadata = { + title: "Produtos", +} + +export default async function ComprarPage() { + const produtos = await getProdutos() + + return ( +
+
+

Produtos

+

+ Escolha o produto e conclua sua compra em poucos minutos. +

+
+ + {produtos.length === 0 ? ( +
+ Nenhum produto disponível no momento. +
+ ) : ( +
+ {produtos.map((p) => ( + + + {p.nome} + {p.descricao && ( +

{p.descricao}

+ )} +
+ +

+ {(p.preco_centavos / 100).toLocaleString("pt-BR", { + style: "currency", + currency: "BRL", + })} +

+ {p.validade && ( +

Validade: {p.validade}

+ )} +
+ + + +
+ ))} +
+ )} + + {appConfig.supportWhatsapp && ( +

+ Dúvidas?{" "} + + Fale conosco no WhatsApp + +

+ )} +
+ ) +} diff --git a/app/globals.css b/app/globals.css new file mode 100644 index 0000000..8630d77 --- /dev/null +++ b/app/globals.css @@ -0,0 +1,84 @@ +@tailwind base; +@tailwind components; +@tailwind utilities; + +@layer base { + :root { + --background: 0 0% 100%; + --foreground: 222.2 84% 4.9%; + + --card: 0 0% 100%; + --card-foreground: 222.2 84% 4.9%; + + --popover: 0 0% 100%; + --popover-foreground: 222.2 84% 4.9%; + + --primary: 212 100% 18%; + --primary-foreground: 210 40% 98%; + + --secondary: 25 100% 50%; + --secondary-foreground: 210 40% 98%; + + --muted: 210 40% 96.1%; + --muted-foreground: 215.4 16.3% 46.9%; + + --accent: 210 40% 96.1%; + --accent-foreground: 222.2 47.4% 11.2%; + + --destructive: 0 84.2% 60.2%; + --destructive-foreground: 210 40% 98%; + + --border: 214.3 31.8% 91.4%; + --input: 214.3 31.8% 91.4%; + --ring: 222.2 84% 4.9%; + + --radius: 0.5rem; + } + + .dark { + --background: 222.2 84% 4.9%; + --foreground: 210 40% 98%; + + --card: 222.2 84% 4.9%; + --card-foreground: 210 40% 98%; + + --popover: 222.2 84% 4.9%; + --popover-foreground: 210 40% 98%; + + --primary: 210 40% 98%; + --primary-foreground: 222.2 47.4% 11.2%; + + --secondary: 25 100% 50%; + --secondary-foreground: 210 40% 98%; + + --muted: 217.2 32.6% 17.5%; + --muted-foreground: 215 20.2% 65.1%; + + --accent: 217.2 32.6% 17.5%; + --accent-foreground: 210 40% 98%; + + --destructive: 0 62.8% 30.6%; + --destructive-foreground: 210 40% 98%; + + --border: 217.2 32.6% 17.5%; + --input: 217.2 32.6% 17.5%; + --ring: 212.7 26.8% 83.9%; + } +} + +@layer base { + * { + @apply border-border; + } + body { + @apply bg-background text-foreground; + } + h1, + h2, + h3, + h4, + h5, + h6 { + @apply font-sans; + } +} diff --git a/app/layout-client.tsx b/app/layout-client.tsx new file mode 100644 index 0000000..e451896 --- /dev/null +++ b/app/layout-client.tsx @@ -0,0 +1,27 @@ +"use client" + +import { ThemeProvider } from "@/components/theme-provider" +import { SiteHeader } from "@/components/site-header" +import { SiteFooter } from "@/components/site-footer" +import { Toaster } from "@/components/ui/toaster" +import type React from "react" +import { AuthProvider } from "@/lib/auth-context" +import { usePathname } from "next/navigation" + +export default function ClientRootLayoutContent({ children }: { children: React.ReactNode }) { + const pathname = usePathname() + const isLoginPage = pathname === "/login" + + return ( + + +
+ {!isLoginPage && } +
{children}
+ {!isLoginPage && } +
+ +
+
+ ) +} diff --git a/app/layout.tsx b/app/layout.tsx new file mode 100644 index 0000000..ed0aae3 --- /dev/null +++ b/app/layout.tsx @@ -0,0 +1,40 @@ +import type React from "react" +import { cn } from "@/lib/utils" +import { Cairo, Inter } from "next/font/google" +import "./globals.css" +import ClientRootLayoutContent from "./layout-client" + +const kiro = Cairo({ + subsets: ["latin"], + variable: "--font-kiro", + weight: ["300", "400", "500", "700"], +}) + +const inter = Inter({ + subsets: ["latin"], + variable: "--font-inter", +}) + +export const metadata = { + title: process.env.NEXT_PUBLIC_APP_NAME ?? "Checkout", + description: "Soluções de certificação digital confiáveis e seguras para sua empresa", + icons: { + icon: [ + { + url: "https://hebbkx1anhila5yf.public.blob.vercel-storage.com/WhatsApp%20Image%202025-01-31%20at%2022.10.07-5kqQGcVq7IsS13DgbQ5wYwozgoQHHJ.jpeg", + type: "image/jpeg", + }, + ], + }, + generator: 'v0.app' +} + +export default function RootLayout({ children }: { children: React.ReactNode }) { + return ( + + + {children} + + + ) +} diff --git a/app/login/page.tsx b/app/login/page.tsx new file mode 100644 index 0000000..b3179cf --- /dev/null +++ b/app/login/page.tsx @@ -0,0 +1,70 @@ +"use client" + +import type React from "react" +import { useState } from "react" +import { useRouter } from "next/navigation" +import { Button } from "@/components/ui/button" +import { Card, CardContent, CardHeader, CardTitle, CardDescription, CardFooter } from "@/components/ui/card" +import { Input } from "@/components/ui/input" +import { Label } from "@/components/ui/label" +import { useToast } from "@/components/ui/use-toast" +import { useAuth } from "@/lib/auth-context" +import { appConfig } from "@/lib/config" +import { Icons } from "@/components/icons" + +export default function LoginPage() { + const { login } = useAuth() + const router = useRouter() + const { toast } = useToast() + const [isLoading, setIsLoading] = useState(false) + + async function onSubmit(event: React.FormEvent) { + event.preventDefault() + setIsLoading(true) + + const formData = new FormData(event.target as HTMLFormElement) + const username = formData.get("username") as string + const password = formData.get("password") as string + + const ok = await login(username, password) + if (ok) { + router.push("/admin") + } else { + toast({ + title: "Acesso negado", + description: "Usuário ou senha incorretos.", + variant: "destructive", + }) + setIsLoading(false) + } + } + + return ( +
+ + + {appConfig.name} + Área administrativa + +
+ +
+ + +
+
+ + +
+
+ + + +
+
+
+ ) +} diff --git a/app/page.tsx b/app/page.tsx new file mode 100644 index 0000000..40f4cdc --- /dev/null +++ b/app/page.tsx @@ -0,0 +1,64 @@ +export const dynamic = "force-dynamic" + +import Link from "next/link" +import { getProdutos } from "@/lib/actions" +import { Button } from "@/components/ui/button" +import { Card, CardContent, CardFooter, CardHeader, CardTitle } from "@/components/ui/card" +import { appConfig } from "@/lib/config" +import { ShoppingBag } from "lucide-react" + +export default async function HomePage() { + const produtos = await getProdutos() + + return ( +
+ {/* Hero */} +
+

+ {appConfig.name} +

+

+ Escolha um produto abaixo e conclua sua compra em poucos cliques. +

+ +
+ + {/* Produtos em destaque */} + {produtos.length > 0 && ( +
+

Produtos disponíveis

+
+ {produtos.map((p) => ( + + + {p.nome} + {p.descricao && ( +

{p.descricao}

+ )} +
+ +

+ {(p.preco_centavos / 100).toLocaleString("pt-BR", { + style: "currency", + currency: "BRL", + })} +

+
+ + + +
+ ))} +
+
+ )} +
+ ) +} diff --git a/app/pedido/[id]/page.tsx b/app/pedido/[id]/page.tsx new file mode 100644 index 0000000..b12fedc --- /dev/null +++ b/app/pedido/[id]/page.tsx @@ -0,0 +1,37 @@ +import { createServiceClient } from "@/lib/supabase" +import PedidoStatus from "@/components/pedido-status" +import { notFound } from "next/navigation" +import type { Metadata } from "next" + +type Props = { params: Promise<{ id: string }> } + +export const metadata: Metadata = { + title: "Seu Pedido", +} + +export default async function PedidoPage({ params }: Props) { + const { id } = await params + const supabase = createServiceClient() + + const { data: pedido, error } = await supabase + .from("pedidos") + .select( + "id, status, valor_centavos, metodo_pagamento, pix_copia_cola, pix_qrcode_url, asaas_invoice_url, paid_at, clientes(nome, email), produtos(nome, validade, midia)" + ) + .eq("id", id) + .single() + + if (error || !pedido) notFound() + + return ( +
+
+

Seu Pedido

+

+ Acompanhe o status do seu pedido +

+
+ +
+ ) +} diff --git a/app/produtos/[id]/page.tsx b/app/produtos/[id]/page.tsx new file mode 100644 index 0000000..d14eba0 --- /dev/null +++ b/app/produtos/[id]/page.tsx @@ -0,0 +1,71 @@ +import { getProdutoById } from "@/lib/actions" +import { ProductBuyForm } from "@/components/product-buy-form" +import { notFound } from "next/navigation" +import type { Metadata } from "next" +import { appConfig } from "@/lib/config" + +type Props = { params: Promise<{ id: string }> } + +export async function generateMetadata({ params }: Props): Promise { + try { + const { id } = await params + const produto = await getProdutoById(id) + if (!produto) return { title: "Produto não encontrado" } + return { + title: `${produto.nome} — ${appConfig.name}`, + description: produto.descricao ?? produto.nome, + } + } catch { + return { title: "Erro ao carregar produto" } + } +} + +export default async function ProductPage({ params }: Props) { + const { id } = await params + const produto = await getProdutoById(id) + if (!produto) notFound() + + const precoFormatado = (produto.preco_centavos / 100).toLocaleString("pt-BR", { + style: "currency", + currency: "BRL", + }) + + return ( +
+
+
+ {/* Detalhes do produto */} +
+

{produto.nome}

+ {produto.descricao && ( +

{produto.descricao}

+ )} +
+ {produto.tipo && ( + + Tipo: {produto.tipo} + + )} + {produto.validade && ( + + Validade: {produto.validade} + + )} + {produto.midia && ( + + Mídia: {produto.midia} + + )} +
+
+ + {/* Formulário de compra */} +
+

{precoFormatado}

+ +
+
+
+
+ ) +} diff --git a/app/produtos/page.tsx b/app/produtos/page.tsx new file mode 100644 index 0000000..575f523 --- /dev/null +++ b/app/produtos/page.tsx @@ -0,0 +1,5 @@ +import { redirect } from "next/navigation" + +export default function ProdutosPage() { + redirect("/comprar") +} diff --git a/components.json b/components.json new file mode 100644 index 0000000..d9ef0ae --- /dev/null +++ b/components.json @@ -0,0 +1,21 @@ +{ + "$schema": "https://ui.shadcn.com/schema.json", + "style": "default", + "rsc": true, + "tsx": true, + "tailwind": { + "config": "tailwind.config.ts", + "css": "app/globals.css", + "baseColor": "neutral", + "cssVariables": true, + "prefix": "" + }, + "aliases": { + "components": "@/components", + "utils": "@/lib/utils", + "ui": "@/components/ui", + "lib": "@/lib", + "hooks": "@/hooks" + }, + "iconLibrary": "lucide" +} \ No newline at end of file diff --git a/components/agendamentos-table.tsx b/components/agendamentos-table.tsx new file mode 100644 index 0000000..8e310e0 --- /dev/null +++ b/components/agendamentos-table.tsx @@ -0,0 +1,133 @@ +"use client" + +import { useState } from "react" +import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table" +import { Button } from "@/components/ui/button" +import { useToast } from "@/components/ui/use-toast" +import { Calendar, CheckCircle2, XCircle, Clock } from "lucide-react" + +const statusConfig: Record = { + pendente: { label: "Pendente", color: "bg-yellow-100 text-yellow-800", icon: }, + confirmado: { label: "Confirmado", color: "bg-blue-100 text-blue-800", icon: }, + realizado: { label: "Realizado", color: "bg-green-100 text-green-800", icon: }, + cancelado: { label: "Cancelado", color: "bg-red-100 text-red-800", icon: }, +} + +type Agendamento = { + id: string + data_hora: string + status: string + observacoes: string | null + created_at: string + clientes: { nome: string; email: string } | null + produtos: { nome: string } | null +} + +export function AgendamentosTable({ agendamentos: inicial }: { agendamentos: Agendamento[] }) { + const { toast } = useToast() + const [itens, setItens] = useState(inicial) + const [atualizando, setAtualizando] = useState(null) + + async function atualizarStatus(id: string, novoStatus: string) { + setAtualizando(id) + try { + const res = await fetch(`/api/agendamento/${id}/status`, { + method: "PATCH", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ status: novoStatus }), + }) + if (!res.ok) throw new Error("Erro ao atualizar") + setItens((prev) => + prev.map((a) => (a.id === id ? { ...a, status: novoStatus } : a)) + ) + toast({ title: `Status atualizado para "${novoStatus}"` }) + } catch { + toast({ title: "Erro ao atualizar status", variant: "destructive" }) + } finally { + setAtualizando(null) + } + } + + if (itens.length === 0) { + return ( +
+ Nenhum agendamento encontrado. +
+ ) + } + + return ( +
+ + + + Cliente + Produto + Data / Hora + Status + Observações + Ações + + + + {itens.map((a) => { + const cfg = statusConfig[a.status] ?? { label: a.status, color: "bg-gray-100 text-gray-800", icon: null } + const isLoading = atualizando === a.id + return ( + + +

{a.clientes?.nome ?? "—"}

+

{a.clientes?.email ?? ""}

+
+ {a.produtos?.nome ?? "—"} + + {new Date(a.data_hora).toLocaleString("pt-BR", { + day: "2-digit", month: "2-digit", year: "numeric", + hour: "2-digit", minute: "2-digit", + })} + + + + {cfg.icon} + {cfg.label} + + + + {a.observacoes ?? "—"} + + +
+ {a.status !== "realizado" && ( + + )} + {a.status !== "cancelado" && a.status !== "realizado" && ( + + )} +
+
+
+ ) + })} +
+
+
+ ) +} diff --git a/components/auth-guard.tsx b/components/auth-guard.tsx new file mode 100644 index 0000000..45e4f5a --- /dev/null +++ b/components/auth-guard.tsx @@ -0,0 +1,25 @@ +"use client" + +import { useEffect } from "react" +import { useRouter } from "next/navigation" +import { useToast } from "@/components/ui/use-toast" +import type React from "react" // Added import for React + +export function AuthGuard({ children }: { children: React.ReactNode }) { + const router = useRouter() + const { toast } = useToast() + + useEffect(() => { + const isLoggedIn = localStorage.getItem("isLoggedIn") + if (!isLoggedIn) { + toast({ + title: "Acesso negado", + description: "Faça login para acessar a área administrativa.", + variant: "destructive", + }) + router.push("/login") + } + }, [router, toast]) + + return <>{children} +} diff --git a/components/cupom-table.tsx b/components/cupom-table.tsx new file mode 100644 index 0000000..289b4c8 --- /dev/null +++ b/components/cupom-table.tsx @@ -0,0 +1,239 @@ +"use client" + +import { useState } from "react" +import { Button } from "@/components/ui/button" +import { Input } from "@/components/ui/input" +import { Label } from "@/components/ui/label" +import { Badge } from "@/components/ui/badge" +import { Switch } from "@/components/ui/switch" +import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card" +import { + Dialog, + DialogContent, + DialogHeader, + DialogTitle, + DialogTrigger, +} from "@/components/ui/dialog" +import { useToast } from "@/components/ui/use-toast" +import { criarCupom, atualizarCupom, deletarCupom } from "@/lib/actions" +import type { Cupom } from "@/lib/supabase" +import { Plus, Trash2, Star, Tag } from "lucide-react" +import { useRouter } from "next/navigation" + +interface CupomTableProps { + cupons: Cupom[] +} + +function NovoCupomDialog() { + const [open, setOpen] = useState(false) + const [loading, setLoading] = useState(false) + const { toast } = useToast() + const router = useRouter() + const [form, setForm] = useState({ + codigo: "", + descricao: "", + percentual: "15", + validade: "", + destaque: false, + }) + + const handleSubmit = async (e: React.FormEvent) => { + e.preventDefault() + setLoading(true) + try { + await criarCupom({ + codigo: form.codigo, + descricao: form.descricao, + percentual: Number(form.percentual), + validade: form.validade, + destaque: form.destaque, + }) + toast({ title: "Cupom criado!" }) + setOpen(false) + setForm({ codigo: "", descricao: "", percentual: "15", validade: "", destaque: false }) + router.refresh() + } catch (err) { + toast({ title: "Erro", description: err instanceof Error ? err.message : "Erro ao criar cupom", variant: "destructive" }) + } finally { + setLoading(false) + } + } + + return ( + + + + + + + Criar cupom de desconto + +
+
+ + setForm({ ...form, codigo: e.target.value.toUpperCase() })} + /> +
+
+ + setForm({ ...form, descricao: e.target.value })} + /> +
+
+
+ + setForm({ ...form, percentual: e.target.value })} + /> +
+
+ + setForm({ ...form, validade: e.target.value })} + /> +
+
+
+ setForm({ ...form, destaque: v })} + /> + +
+ +
+
+
+ ) +} + +export function CupomTable({ cupons }: CupomTableProps) { + const { toast } = useToast() + const router = useRouter() + + const handleToggleAtivo = async (cupom: Cupom) => { + try { + await atualizarCupom(cupom.id, { ativo: !cupom.ativo }) + router.refresh() + } catch { + toast({ title: "Erro ao atualizar", variant: "destructive" }) + } + } + + const handleToggleDestaque = async (cupom: Cupom) => { + try { + await atualizarCupom(cupom.id, { destaque: !cupom.destaque }) + router.refresh() + } catch { + toast({ title: "Erro ao atualizar", variant: "destructive" }) + } + } + + const handleDeletar = async (id: string) => { + if (!confirm("Deletar este cupom?")) return + try { + await deletarCupom(id) + toast({ title: "Cupom deletado" }) + router.refresh() + } catch { + toast({ title: "Erro ao deletar", variant: "destructive" }) + } + } + + const hoje = new Date().toISOString().slice(0, 10) + + return ( + + + + Cupons de Desconto + + + + + {cupons.length === 0 ? ( +

Nenhum cupom cadastrado.

+ ) : ( +
+ {cupons.map((cupom) => { + const vencido = cupom.validade < hoje + const validadeFormatada = new Date(cupom.validade + "T12:00:00").toLocaleDateString("pt-BR") + return ( +
+
+
+
+ {cupom.codigo} + {cupom.percentual}% OFF + {cupom.destaque && ( + + Destaque + + )} + {vencido && Vencido} + {!cupom.ativo && Inativo} +
+
{cupom.descricao} · Até {validadeFormatada}
+
+
+
+
+ handleToggleDestaque(cupom)} + title="Exibir em destaque" + /> + Destaque +
+
+ handleToggleAtivo(cupom)} + title="Ativo/Inativo" + /> + Ativo +
+ +
+
+ ) + })} +
+ )} +
+
+ ) +} diff --git a/components/customer-form-dialog.tsx b/components/customer-form-dialog.tsx new file mode 100644 index 0000000..e2a8c9b --- /dev/null +++ b/components/customer-form-dialog.tsx @@ -0,0 +1,86 @@ +"use client" + +import type React from "react" +import { useState } from "react" +import { useRouter } from "next/navigation" +import { Button } from "@/components/ui/button" +import { Input } from "@/components/ui/input" +import { Label } from "@/components/ui/label" +import { createCliente } from "@/lib/actions" +import { toast } from "@/components/ui/use-toast" +import { + Dialog, + DialogContent, + DialogHeader, + DialogTitle, + DialogTrigger, + DialogDescription, +} from "@/components/ui/dialog" + +export function CustomerFormDialog() { + const [open, setOpen] = useState(false) + const router = useRouter() + const [formData, setFormData] = useState({ + nome: "", + email: "", + telefone: "", + cpf_cnpj: "", + }) + + const handleSubmit = async (e: React.FormEvent) => { + e.preventDefault() + try { + await createCliente(formData) + toast({ title: "Cliente criado", description: "Adicionado com sucesso." }) + setOpen(false) + router.refresh() + } catch (error) { + toast({ + title: "Erro", + description: error instanceof Error ? error.message : "Tente novamente.", + variant: "destructive", + }) + } + } + + const handleChange = (e: React.ChangeEvent) => { + const { name, value } = e.target + setFormData((prev) => ({ ...prev, [name]: value })) + } + + return ( + + + + + + + Criar Novo Cliente + Preencha os dados do novo cliente. + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+
+
+ ) +} diff --git a/components/customer-table.tsx b/components/customer-table.tsx new file mode 100644 index 0000000..7b05e72 --- /dev/null +++ b/components/customer-table.tsx @@ -0,0 +1,47 @@ +"use client" + +import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table" +import type { Cliente } from "@/lib/supabase" + +interface CustomerTableProps { + customers: Cliente[] +} + +export function CustomerTable({ customers }: CustomerTableProps) { + if (customers.length === 0) { + return ( +
+ Nenhum cliente cadastrado. +
+ ) + } + + return ( +
+ + + + Nome + E-mail + CPF/CNPJ + Telefone + Cadastro + + + + {customers.map((c) => ( + + {c.nome} + {c.email} + {c.cpf_cnpj ?? "—"} + {c.telefone ?? "—"} + + {new Date(c.created_at).toLocaleDateString("pt-BR")} + + + ))} + +
+
+ ) +} diff --git a/components/dashboard-header.tsx b/components/dashboard-header.tsx new file mode 100644 index 0000000..673b806 --- /dev/null +++ b/components/dashboard-header.tsx @@ -0,0 +1,23 @@ +import type React from "react" // Added import for React + +interface DashboardHeaderProps { + heading: string + text?: string + children?: React.ReactNode +} + +export function DashboardHeader({ heading, text, children }: DashboardHeaderProps) { + return ( +
+
+

{heading}

+ {text &&

{text}

} +

+ Gerencie seus certificados digitais, acompanhe vendas e monitore o desempenho em tempo real. Todas as + ferramentas que você precisa em um só lugar. +

+
+
{children}
+
+ ) +} diff --git a/components/dashboard-shell.tsx b/components/dashboard-shell.tsx new file mode 100644 index 0000000..f114375 --- /dev/null +++ b/components/dashboard-shell.tsx @@ -0,0 +1,12 @@ +import { cn } from "@/lib/utils" +import type React from "react" + +interface DashboardShellProps extends React.HTMLAttributes {} + +export function DashboardShell({ children, className, ...props }: DashboardShellProps) { + return ( +
+ {children} +
+ ) +} diff --git a/components/dashboard-stats.tsx b/components/dashboard-stats.tsx new file mode 100644 index 0000000..9b9bba8 --- /dev/null +++ b/components/dashboard-stats.tsx @@ -0,0 +1,63 @@ +import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card" +import { DollarSign, Users, ShoppingCart, TrendingUp } from "lucide-react" + +interface DashboardStatsProps { + receitaTotal: number + totalPedidosConfirmados: number + ticketMedio: number + pedidosPendentes: number +} + +export function DashboardStats({ + receitaTotal, + totalPedidosConfirmados, + ticketMedio, + pedidosPendentes, +}: DashboardStatsProps) { + const fmt = (v: number) => + v.toLocaleString("pt-BR", { style: "currency", currency: "BRL" }) + + const stats = [ + { + title: "Receita Total", + value: fmt(receitaTotal), + icon: DollarSign, + description: "Pedidos pagos (todos os tempos)", + }, + { + title: "Vendas Confirmadas", + value: totalPedidosConfirmados.toString(), + icon: ShoppingCart, + description: "Pedidos recebidos ou confirmados", + }, + { + title: "Ticket Médio", + value: fmt(ticketMedio), + icon: TrendingUp, + description: "Por venda confirmada", + }, + { + title: "Pendentes", + value: pedidosPendentes.toString(), + icon: Users, + description: "Aguardando pagamento", + }, + ] + + return ( + <> + {stats.map((stat, index) => ( + + + {stat.title} + + + +
{stat.value}
+

{stat.description}

+
+
+ ))} + + ) +} diff --git a/components/data-table-column-header.tsx b/components/data-table-column-header.tsx new file mode 100644 index 0000000..e07a21f --- /dev/null +++ b/components/data-table-column-header.tsx @@ -0,0 +1,65 @@ +"use client" + +import type React from "react" + +import { ArrowDownIcon, ArrowUpIcon, CaretSortIcon, EyeNoneIcon } from "@radix-ui/react-icons" +import type { Column } from "@tanstack/react-table" + +import { cn } from "@/lib/utils" +import { Button } from "@/components/ui/button" +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuSeparator, + DropdownMenuTrigger, +} from "@/components/ui/dropdown-menu" + +interface DataTableColumnHeaderProps extends React.HTMLAttributes { + column: Column + title: string +} + +export function DataTableColumnHeader({ + column, + title, + className, +}: DataTableColumnHeaderProps) { + if (!column.getCanSort()) { + return
{title}
+ } + + return ( +
+ + + + + + column.toggleSorting(false)}> + + Crescente + + column.toggleSorting(true)}> + + Decrescente + + + column.toggleVisibility(false)}> + + Ocultar + + + +
+ ) +} diff --git a/components/data-table-faceted-filter.tsx b/components/data-table-faceted-filter.tsx new file mode 100644 index 0000000..452968b --- /dev/null +++ b/components/data-table-faceted-filter.tsx @@ -0,0 +1,129 @@ +"use client" + +import type * as React from "react" +import { CheckIcon, PlusCircledIcon } from "@radix-ui/react-icons" +import type { Column } from "@tanstack/react-table" + +import { cn } from "@/lib/utils" +import { Badge } from "@/components/ui/badge" +import { Button } from "@/components/ui/button" +import { + Command, + CommandEmpty, + CommandGroup, + CommandInput, + CommandItem, + CommandList, + CommandSeparator, +} from "@/components/ui/command" +import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover" +import { Separator } from "@/components/ui/separator" + +interface DataTableFacetedFilterProps { + column?: Column + title?: string + options: { + label: string + value: string + icon?: React.ComponentType<{ className?: string }> + }[] +} + +export function DataTableFacetedFilter({ + column, + title, + options, +}: DataTableFacetedFilterProps) { + const facets = column?.getFacetedUniqueValues() + const selectedValues = new Set(column?.getFilterValue() as string[]) + + return ( + + + + + + + + + Nenhum resultado encontrado. + + {options.map((option) => { + const isSelected = selectedValues.has(option.value) + return ( + { + if (isSelected) { + selectedValues.delete(option.value) + } else { + selectedValues.add(option.value) + } + const filterValues = Array.from(selectedValues) + column?.setFilterValue(filterValues.length ? filterValues : undefined) + }} + > +
+ +
+ {option.icon && } + {option.label} + {facets?.get(option.value) && ( + + {facets.get(option.value)} + + )} +
+ ) + })} +
+ {selectedValues.size > 0 && ( + <> + + + column?.setFilterValue(undefined)} + className="justify-center text-center" + > + Limpar filtros + + + + )} +
+
+
+
+ ) +} diff --git a/components/data-table-pagination.tsx b/components/data-table-pagination.tsx new file mode 100644 index 0000000..dacfe62 --- /dev/null +++ b/components/data-table-pagination.tsx @@ -0,0 +1,85 @@ +"use client" + +import { ChevronLeftIcon, ChevronRightIcon, DoubleArrowLeftIcon, DoubleArrowRightIcon } from "@radix-ui/react-icons" +import type { Table } from "@tanstack/react-table" + +import { Button } from "@/components/ui/button" +import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select" + +interface DataTablePaginationProps { + table: Table +} + +export function DataTablePagination({ table }: DataTablePaginationProps) { + return ( +
+
+ {table.getFilteredSelectedRowModel().rows.length} de {table.getFilteredRowModel().rows.length} linha(s) + selecionada(s). +
+
+
+

Linhas por página

+ +
+
+ Página {table.getState().pagination.pageIndex + 1} de {table.getPageCount()} +
+
+ + + + +
+
+
+ ) +} diff --git a/components/data-table-row-actions.tsx b/components/data-table-row-actions.tsx new file mode 100644 index 0000000..ed985fc --- /dev/null +++ b/components/data-table-row-actions.tsx @@ -0,0 +1,45 @@ +"use client" + +import { DotsHorizontalIcon } from "@radix-ui/react-icons" +import type { Row } from "@tanstack/react-table" + +import { Button } from "@/components/ui/button" +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuSeparator, + DropdownMenuTrigger, +} from "@/components/ui/dropdown-menu" + +import { productSchema } from "@/lib/schema" +import type { z } from "zod" + +type Product = z.infer + +interface DataTableRowActionsProps { + row: Row +} + +export function DataTableRowActions({ row }: DataTableRowActionsProps) { + const product = productSchema.parse(row.original) + + return ( + + + + + + Editar + Duplicar + + Arquivar + + Excluir + + + ) +} diff --git a/components/data-table-toolbar.tsx b/components/data-table-toolbar.tsx new file mode 100644 index 0000000..9dc9b2d --- /dev/null +++ b/components/data-table-toolbar.tsx @@ -0,0 +1,48 @@ +"use client" + +import { Cross2Icon } from "@radix-ui/react-icons" +import type { Table } from "@tanstack/react-table" + +import { Button } from "@/components/ui/button" +import { Input } from "@/components/ui/input" +import { DataTableViewOptions } from "@/components/data-table-view-options" +import { DataTableFacetedFilter } from "@/components/data-table-faceted-filter" + +interface DataTableToolbarProps { + table: Table +} + +export function DataTableToolbar({ table }: DataTableToolbarProps) { + const isFiltered = table.getState().columnFilters.length > 0 + + return ( +
+
+ table.getColumn("name")?.setFilterValue(event.target.value)} + className="h-8 w-[150px] lg:w-[250px]" + /> + {table.getColumn("tipo") && ( + + )} + {isFiltered && ( + + )} +
+ +
+ ) +} diff --git a/components/data-table-view-options.tsx b/components/data-table-view-options.tsx new file mode 100644 index 0000000..2956216 --- /dev/null +++ b/components/data-table-view-options.tsx @@ -0,0 +1,50 @@ +"use client" + +import { DropdownMenuTrigger } from "@radix-ui/react-dropdown-menu" +import { MixerHorizontalIcon } from "@radix-ui/react-icons" +import type { Table } from "@tanstack/react-table" + +import { Button } from "@/components/ui/button" +import { + DropdownMenu, + DropdownMenuCheckboxItem, + DropdownMenuContent, + DropdownMenuLabel, + DropdownMenuSeparator, +} from "@/components/ui/dropdown-menu" + +interface DataTableViewOptionsProps { + table: Table +} + +export function DataTableViewOptions({ table }: DataTableViewOptionsProps) { + return ( + + + + + + Alternar colunas + + {table + .getAllColumns() + .filter((column) => typeof column.accessorFn !== "undefined" && column.getCanHide()) + .map((column) => { + return ( + column.toggleVisibility(!!value)} + > + {column.id} + + ) + })} + + + ) +} diff --git a/components/data-table.tsx b/components/data-table.tsx new file mode 100644 index 0000000..d3694fe --- /dev/null +++ b/components/data-table.tsx @@ -0,0 +1,97 @@ +"use client" + +import * as React from "react" +import { + type ColumnDef, + type ColumnFiltersState, + type SortingState, + type VisibilityState, + flexRender, + getCoreRowModel, + getFacetedRowModel, + getFacetedUniqueValues, + getFilteredRowModel, + getPaginationRowModel, + getSortedRowModel, + useReactTable, +} from "@tanstack/react-table" + +import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table" + +import { DataTablePagination } from "@/components/data-table-pagination" +import { DataTableToolbar } from "@/components/data-table-toolbar" + +interface DataTableProps { + columns: ColumnDef[] + data: TData[] +} + +export function DataTable({ columns, data }: DataTableProps) { + const [rowSelection, setRowSelection] = React.useState({}) + const [columnVisibility, setColumnVisibility] = React.useState({}) + const [columnFilters, setColumnFilters] = React.useState([]) + const [sorting, setSorting] = React.useState([]) + + const table = useReactTable({ + data, + columns, + state: { + sorting, + columnVisibility, + rowSelection, + columnFilters, + }, + enableRowSelection: true, + onRowSelectionChange: setRowSelection, + onSortingChange: setSorting, + onColumnFiltersChange: setColumnFilters, + onColumnVisibilityChange: setColumnVisibility, + getCoreRowModel: getCoreRowModel(), + getFilteredRowModel: getFilteredRowModel(), + getPaginationRowModel: getPaginationRowModel(), + getSortedRowModel: getSortedRowModel(), + getFacetedRowModel: getFacetedRowModel(), + getFacetedUniqueValues: getFacetedUniqueValues(), + }) + + return ( +
+ +
+ + + {table.getHeaderGroups().map((headerGroup) => ( + + {headerGroup.headers.map((header) => { + return ( + + {header.isPlaceholder ? null : flexRender(header.column.columnDef.header, header.getContext())} + + ) + })} + + ))} + + + {table.getRowModel().rows?.length ? ( + table.getRowModel().rows.map((row) => ( + + {row.getVisibleCells().map((cell) => ( + {flexRender(cell.column.columnDef.cell, cell.getContext())} + ))} + + )) + ) : ( + + + Nenhum resultado. + + + )} + +
+
+ +
+ ) +} diff --git a/components/icons.tsx b/components/icons.tsx new file mode 100644 index 0000000..3d378b1 --- /dev/null +++ b/components/icons.tsx @@ -0,0 +1,60 @@ +import { + AlertTriangle, + ArrowRight, + Check, + ChevronLeft, + ChevronRight, + Command, + CreditCard, + File, + FileText, + Github, + HelpCircle, + Image, + Laptop, + Loader2, + Moon, + MoreVertical, + Pizza, + Plus, + Settings, + SunMedium, + Trash, + Twitter, + User, + X, + type LucideIcon, +} from "lucide-react" + +import { Apple, Google } from "@/components/brand-icons" + +export type Icon = LucideIcon + +export const Icons = { + logo: Command, + close: X, + spinner: Loader2, + chevronLeft: ChevronLeft, + chevronRight: ChevronRight, + trash: Trash, + post: FileText, + page: File, + media: Image, + settings: Settings, + billing: CreditCard, + ellipsis: MoreVertical, + add: Plus, + warning: AlertTriangle, + user: User, + arrowRight: ArrowRight, + help: HelpCircle, + pizza: Pizza, + sun: SunMedium, + moon: Moon, + laptop: Laptop, + gitHub: Github, + twitter: Twitter, + check: Check, + google: Google, + apple: Apple, +} diff --git a/components/overview.tsx b/components/overview.tsx new file mode 100644 index 0000000..0e3c814 --- /dev/null +++ b/components/overview.tsx @@ -0,0 +1,32 @@ +"use client" + +import { Bar, BarChart, ResponsiveContainer, XAxis, YAxis } from "recharts" + +interface OverviewProps { + data: number[] +} + +export function Overview({ data }: OverviewProps) { + const months = ["Jan", "Fev", "Mar", "Abr", "Mai", "Jun", "Jul", "Ago", "Set", "Out", "Nov", "Dez"] + + const chartData = data.map((value, index) => ({ + name: months[index], + total: value, + })) + + return ( + + + + `R$${value}`} + /> + + + + ) +} diff --git a/components/pedido-detail-modal.tsx b/components/pedido-detail-modal.tsx new file mode 100644 index 0000000..c7a8f29 --- /dev/null +++ b/components/pedido-detail-modal.tsx @@ -0,0 +1,226 @@ +"use client" + +import { useState } from "react" +import { Dialog, DialogContent, DialogHeader, DialogTitle } from "@/components/ui/dialog" +import { Button } from "@/components/ui/button" +import { useToast } from "@/components/ui/use-toast" +import { RefreshCw, ExternalLink, Copy } from "lucide-react" + +const statusColors: Record = { + PENDING: "bg-yellow-100 text-yellow-800", + RECEIVED: "bg-green-100 text-green-800", + CONFIRMED: "bg-green-100 text-green-800", + OVERDUE: "bg-red-100 text-red-800", + REFUNDED: "bg-gray-100 text-gray-800", + CANCELLED: "bg-red-100 text-red-800", +} + +const statusLabel: Record = { + PENDING: "Pendente", + RECEIVED: "Pago", + CONFIRMED: "Confirmado", + OVERDUE: "Vencido", + REFUNDED: "Estornado", + CANCELLED: "Cancelado", +} + +type Pedido = { + id: string + status: string + valor_centavos: number + metodo_pagamento: string | null + asaas_payment_id: string | null + asaas_invoice_url: string | null + pix_copia_cola: string | null + pix_qrcode_url: string | null + paid_at: string | null + created_at: string + clientes: { + nome: string + email: string + telefone: string | null + cpf_cnpj: string | null + } | null + produtos: { + nome: string + tipo: string + validade: string + midia: string | null + } | null +} + +export function PedidoDetailModal({ + pedido: pedidoInicial, + open, + onClose, +}: { + pedido: Pedido + open: boolean + onClose: () => void +}) { + const { toast } = useToast() + const [pedido, setPedido] = useState(pedidoInicial) + const [syncing, setSyncing] = useState(false) + + const fmt = (v: number) => + (v / 100).toLocaleString("pt-BR", { style: "currency", currency: "BRL" }) + + async function syncStatus() { + setSyncing(true) + try { + const res = await fetch(`/api/pedido/${pedido.id}/sync`, { method: "POST" }) + const data = await res.json() + if (!res.ok) throw new Error(data.error) + setPedido((p) => ({ ...p, status: data.status })) + toast({ title: `Status atualizado: ${statusLabel[data.status] ?? data.status}` }) + } catch (err) { + toast({ + title: "Erro ao sincronizar", + description: err instanceof Error ? err.message : "Tente novamente", + variant: "destructive", + }) + } finally { + setSyncing(false) + } + } + + function copiar(texto: string, label: string) { + navigator.clipboard.writeText(texto) + toast({ title: `${label} copiado!` }) + } + + return ( + + + + + Pedido {pedido.id.slice(0, 8).toUpperCase()} + + {statusLabel[pedido.status] ?? pedido.status} + + + + +
+ {/* Cliente */} +
+ + + + +
+ + {/* Produto */} +
+ + + + +
+ + {/* Pagamento */} +
+ + + + + {pedido.paid_at && ( + + )} +
+ + {/* Ações */} +
+ + + {pedido.asaas_invoice_url && ( + + )} + + {pedido.pix_copia_cola && ( + + )} + + {pedido.asaas_payment_id && ( + + )} +
+
+
+
+ ) +} + +function Section({ title, children }: { title: string; children: React.ReactNode }) { + return ( +
+

+ {title} +

+
{children}
+
+ ) +} + +function Row({ + label, + value, + mono, + highlight, +}: { + label: string + value?: string | null + mono?: boolean + highlight?: boolean +}) { + return ( +
+ {label} + + {value ?? "—"} + +
+ ) +} diff --git a/components/pedido-status.tsx b/components/pedido-status.tsx new file mode 100644 index 0000000..2775739 --- /dev/null +++ b/components/pedido-status.tsx @@ -0,0 +1,263 @@ +"use client" + +import { useEffect, useState, useCallback } from "react" +import { useRouter } from "next/navigation" +import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card" +import { Button } from "@/components/ui/button" +import { useToast } from "@/components/ui/use-toast" +import { + CheckCircle2, + Clock, + XCircle, + Copy, + ExternalLink, + Calendar, + Loader2, +} from "lucide-react" + +type Pedido = { + id: string + status: string + valor_centavos: number + metodo_pagamento: string + pix_copia_cola: string | null + pix_qrcode_url: string | null + asaas_invoice_url: string | null + paid_at: string | null + clientes: { nome: string; email: string } | null + produtos: { nome: string; validade: string; midia: string | null } | null +} + +const STATUS_PAGO = ["PAID", "RECEIVED", "CONFIRMED"] +const STATUS_CANCELADO = ["CANCELED", "CANCELLED", "OVERDUE", "REFUNDED"] +const LINK_AGENDAMENTO = process.env.NEXT_PUBLIC_AFTER_PAYMENT_REDIRECT ?? "/" + +export default function PedidoStatus({ pedidoInicial }: { pedidoInicial: Pedido }) { + const router = useRouter() + const { toast } = useToast() + const [pedido, setPedido] = useState(pedidoInicial) + const [polling, setPolling] = useState(true) + + const isPago = STATUS_PAGO.includes(pedido.status) + const isCancelado = STATUS_CANCELADO.includes(pedido.status) + + const valorFormatado = (pedido.valor_centavos / 100).toLocaleString("pt-BR", { + style: "currency", + currency: "BRL", + }) + + const fetchStatus = useCallback(async () => { + try { + const res = await fetch(`/api/pedido/${pedido.id}`) + if (!res.ok) return + const data: Pedido = await res.json() + setPedido(data) + if (STATUS_PAGO.includes(data.status) || STATUS_CANCELADO.includes(data.status)) { + setPolling(false) + } + } catch { + // silencioso — tenta de novo no próximo ciclo + } + }, [pedido.id]) + + useEffect(() => { + if (!polling || isPago || isCancelado) return + const interval = setInterval(fetchStatus, 5000) + return () => clearInterval(interval) + }, [polling, isPago, isCancelado, fetchStatus]) + + function copiarPix() { + if (!pedido.pix_copia_cola) return + navigator.clipboard.writeText(pedido.pix_copia_cola) + toast({ title: "Código PIX copiado!" }) + } + + // ── Pago ────────────────────────────────────────────────────────────── + if (isPago) { + return ( + + + + Pagamento confirmado! +

+ {pedido.clientes?.nome}, seu certificado está sendo processado. +

+
+ + + +
+ +

Próximo passo: agende sua validação

+

+ Para emitir o certificado, é obrigatório realizar uma videoconferência de + Clique no botão abaixo para continuar para o próximo passo. +

+ +

+ O link também foi enviado para {pedido.clientes?.email} +

+
+
+
+ ) + } + + // ── Cancelado / Vencido ─────────────────────────────────────────────── + if (isCancelado) { + return ( + + + + Pagamento não realizado +

+ Este pedido foi cancelado ou expirou. +

+
+ + + + +
+ ) + } + + // ── PIX ─────────────────────────────────────────────────────────────── + if (pedido.metodo_pagamento === "PIX") { + return ( + + + + Aguardando pagamento PIX +

+ + Confirmação automática em segundos após o pagamento +

+
+ + + + {pedido.pix_qrcode_url && ( +
+ QR Code PIX +
+ )} + + {pedido.pix_copia_cola && ( +
+

+ PIX Copia e Cola +

+
+ {pedido.pix_copia_cola} +
+ +
+ )} + +

+ Valor: {valorFormatado} +

+
+
+ ) + } + + // ── BOLETO ──────────────────────────────────────────────────────────── + if (pedido.metodo_pagamento === "BOLETO") { + return ( + + + + Boleto gerado +

+ Após o pagamento, a confirmação pode levar até 2 dias úteis. +

+
+ + + + {pedido.asaas_invoice_url && ( + + )} + +

+ O link do boleto também foi enviado para{" "} + {pedido.clientes?.email} +

+
+
+ ) + } + + // ── CARTÃO (pendente/processando) ──────────────────────────────────── + return ( + + + + Processando pagamento +

Aguarde enquanto confirmamos seu cartão.

+
+ + + +
+ ) +} + +function ResumoCompra({ + pedido, + valorFormatado, +}: { + pedido: Pedido + valorFormatado: string +}) { + return ( +
+
+ Produto + {pedido.produtos?.nome ?? "—"} +
+
+ Validade + {pedido.produtos?.validade ?? "—"} +
+ {pedido.produtos?.midia && ( +
+ Mídia + {pedido.produtos.midia} +
+ )} +
+ Valor + {valorFormatado} +
+
+ Pedido + {pedido.id.slice(0, 8).toUpperCase()} +
+
+ ) +} diff --git a/components/product-buy-form.tsx b/components/product-buy-form.tsx new file mode 100644 index 0000000..a537bec --- /dev/null +++ b/components/product-buy-form.tsx @@ -0,0 +1,122 @@ +"use client" + +import { useState } from "react" +import { useRouter } from "next/navigation" +import { Button } from "@/components/ui/button" +import { Input } from "@/components/ui/input" +import { Label } from "@/components/ui/label" +import { useToast } from "@/components/ui/use-toast" +import { criarCheckout } from "@/lib/actions" + +type Metodo = "PIX" | "BOLETO" | "CREDIT_CARD" + +export function ProductBuyForm({ + produtoId, +}: { + produtoId: string +}) { + const router = useRouter() + const { toast } = useToast() + const [isLoading, setIsLoading] = useState(false) + const [metodo, setMetodo] = useState("PIX") + + const [form, setForm] = useState({ + nome: "", + email: "", + cpfCnpj: "", + telefone: "", + }) + + const handleSubmit = async (e: React.FormEvent) => { + e.preventDefault() + setIsLoading(true) + try { + const res = await criarCheckout({ + produtoId, + cliente: { + nome: form.nome, + email: form.email, + cpfCnpj: form.cpfCnpj.replace(/\D/g, ""), + telefone: form.telefone, + }, + metodo, + }) + + router.push(`/pedido/${res.pedidoId}`) + } catch (error) { + toast({ + title: "Erro ao processar pagamento", + description: error instanceof Error ? error.message : "Tente novamente.", + variant: "destructive", + }) + setIsLoading(false) + } + } + + return ( +
+
+ + setForm({ ...form, nome: e.target.value })} + /> +
+
+ + setForm({ ...form, email: e.target.value })} + /> +
+
+ + setForm({ ...form, cpfCnpj: e.target.value })} + /> +
+
+ + setForm({ ...form, telefone: e.target.value })} + /> +
+ +
+ +
+ {(["PIX", "BOLETO", "CREDIT_CARD"] as Metodo[]).map((m) => ( + + ))} +
+
+ + +
+ ) +} diff --git a/components/product-form-dialog.tsx b/components/product-form-dialog.tsx new file mode 100644 index 0000000..6867066 --- /dev/null +++ b/components/product-form-dialog.tsx @@ -0,0 +1,171 @@ +"use client" + +import type React from "react" +import { useState, useEffect } from "react" +import { useRouter } from "next/navigation" +import { Button } from "@/components/ui/button" +import { Input } from "@/components/ui/input" +import { Label } from "@/components/ui/label" +import { Textarea } from "@/components/ui/textarea" +import { createProduto, updateProduto } from "@/lib/actions" +import { toast } from "@/components/ui/use-toast" +import { + Dialog, + DialogContent, + DialogHeader, + DialogTitle, + DialogTrigger, + DialogDescription, +} from "@/components/ui/dialog" +import type { Produto } from "@/lib/supabase" + +type TipoOptions = "PF" | "PJ" | "SSL" | "NFe" + +interface ProductFormDialogProps { + product?: Produto | null +} + +const parseCurrency = (value: string) => Number(value.replace(/\D/g, "")) +const formatCurrency = (value: string) => { + const amount = Number(value.replace(/\D/g, "")) / 100 + return new Intl.NumberFormat("pt-BR", { style: "currency", currency: "BRL" }).format(amount) +} + +export function ProductFormDialog({ product }: ProductFormDialogProps) { + const [open, setOpen] = useState(false) + const router = useRouter() + const [formData, setFormData] = useState({ + nome: "", + descricao: "", + price: "0", + tipo: "PF" as TipoOptions, + validade: "", + midia: "", + }) + + useEffect(() => { + if (product) { + setFormData({ + nome: product.nome, + descricao: product.descricao ?? "", + price: formatCurrency((product.preco_centavos).toString()), + tipo: product.tipo, + validade: product.validade, + midia: product.midia ?? "", + }) + } + }, [product]) + + const handleSubmit = async (e: React.FormEvent) => { + e.preventDefault() + try { + const preco_centavos = parseCurrency(formData.price) + if (isNaN(preco_centavos) || preco_centavos <= 0) throw new Error("Preço inválido") + + const payload = { + nome: formData.nome, + descricao: formData.descricao, + tipo: formData.tipo, + validade: formData.validade, + midia: formData.midia || undefined, + preco_centavos, + } + + if (product) { + await updateProduto(product.id, payload) + toast({ title: "Produto atualizado", description: "Alterações salvas com sucesso." }) + } else { + await createProduto(payload) + toast({ title: "Produto criado", description: "Adicionado com sucesso." }) + } + + setOpen(false) + router.refresh() + } catch (error) { + toast({ + title: "Erro", + description: error instanceof Error ? error.message : "Tente novamente.", + variant: "destructive", + }) + } + } + + const handleChange = (e: React.ChangeEvent) => { + const { name, value } = e.target + setFormData((prev) => ({ ...prev, [name]: value })) + } + + return ( + + + + + + + {product ? "Editar Produto" : "Criar Novo Produto"} + Preencha os dados do certificado. + +
+
+ + +
+
+ +