feat: initial commit — asaas-checkout template white-label
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 <noreply@anthropic.com>
This commit is contained in:
44
.env.example
Normal file
44
.env.example
Normal file
@@ -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
|
||||||
6
.gitignore
vendored
Normal file
6
.gitignore
vendored
Normal file
@@ -0,0 +1,6 @@
|
|||||||
|
.env.local
|
||||||
|
.env
|
||||||
|
node_modules/
|
||||||
|
.next/
|
||||||
|
stack.*.yaml
|
||||||
|
*.local
|
||||||
57
.specs/PROJECT.md
Normal file
57
.specs/PROJECT.md
Normal file
@@ -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 <noreply@oakia.com.br>` |
|
||||||
|
| 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.
|
||||||
25
.specs/STATE.md
Normal file
25
.specs/STATE.md
Normal file
@@ -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)
|
||||||
127
CLAUDE.md
Normal file
127
CLAUDE.md
Normal file
@@ -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
|
||||||
43
Dockerfile
Normal file
43
Dockerfile
Normal file
@@ -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"]
|
||||||
73
app/admin/columns.tsx
Normal file
73
app/admin/columns.tsx
Normal file
@@ -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<typeof productSchema>
|
||||||
|
|
||||||
|
export const columns: ColumnDef<Product>[] = [
|
||||||
|
{
|
||||||
|
id: "select",
|
||||||
|
header: ({ table }) => (
|
||||||
|
<Checkbox
|
||||||
|
checked={table.getIsAllPageRowsSelected()}
|
||||||
|
onCheckedChange={(value) => table.toggleAllPageRowsSelected(!!value)}
|
||||||
|
aria-label="Selecionar tudo"
|
||||||
|
className="translate-y-[2px]"
|
||||||
|
/>
|
||||||
|
),
|
||||||
|
cell: ({ row }) => (
|
||||||
|
<Checkbox
|
||||||
|
checked={row.getIsSelected()}
|
||||||
|
onCheckedChange={(value) => row.toggleSelected(!!value)}
|
||||||
|
aria-label="Selecionar linha"
|
||||||
|
className="translate-y-[2px]"
|
||||||
|
/>
|
||||||
|
),
|
||||||
|
enableSorting: false,
|
||||||
|
enableHiding: false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
accessorKey: "name",
|
||||||
|
header: ({ column }) => <DataTableColumnHeader column={column} title="Nome" />,
|
||||||
|
cell: ({ row }) => <div className="w-[180px]">{row.getValue("name")}</div>,
|
||||||
|
enableSorting: true,
|
||||||
|
enableHiding: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
accessorKey: "price.display_amount",
|
||||||
|
header: ({ column }) => <DataTableColumnHeader column={column} title="Preço" />,
|
||||||
|
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 <div className="font-medium">{formatted}</div>
|
||||||
|
},
|
||||||
|
enableSorting: true,
|
||||||
|
enableHiding: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
accessorKey: "tipo",
|
||||||
|
header: ({ column }) => <DataTableColumnHeader column={column} title="Tipo" />,
|
||||||
|
cell: ({ row }) => <div>{row.getValue("tipo")}</div>,
|
||||||
|
enableSorting: true,
|
||||||
|
enableHiding: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
accessorKey: "validade",
|
||||||
|
header: ({ column }) => <DataTableColumnHeader column={column} title="Validade" />,
|
||||||
|
cell: ({ row }) => <div>{row.getValue("validade")}</div>,
|
||||||
|
enableSorting: true,
|
||||||
|
enableHiding: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "actions",
|
||||||
|
cell: ({ row }) => <DataTableRowActions row={row} />,
|
||||||
|
},
|
||||||
|
]
|
||||||
14
app/admin/layout.tsx
Normal file
14
app/admin/layout.tsx
Normal file
@@ -0,0 +1,14 @@
|
|||||||
|
import type React from "react"
|
||||||
|
|
||||||
|
export default function AdminLayout({
|
||||||
|
children,
|
||||||
|
}: {
|
||||||
|
children: React.ReactNode
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<div className="flex min-h-screen flex-col">
|
||||||
|
{/* Remover o SiteHeader e SiteFooter daqui, pois já estão no layout principal */}
|
||||||
|
<main className="flex-1">{children}</main>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
144
app/admin/page.tsx
Normal file
144
app/admin/page.tsx
Normal file
@@ -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<typeof RecentSales>[0]["vendas"]
|
||||||
|
|
||||||
|
// Contagem de agendamentos pendentes para badge
|
||||||
|
const agendamentosPendentes = agendamentos.filter(
|
||||||
|
(a) => a.status === "pendente" || a.status === "confirmado"
|
||||||
|
).length
|
||||||
|
|
||||||
|
return (
|
||||||
|
<AuthGuard>
|
||||||
|
<DashboardShell>
|
||||||
|
<DashboardHeader
|
||||||
|
heading="Dashboard de Administração"
|
||||||
|
text="Gerencie seus produtos, clientes e visualize estatísticas de vendas."
|
||||||
|
>
|
||||||
|
<ProductFormDialog />
|
||||||
|
</DashboardHeader>
|
||||||
|
|
||||||
|
{/* Cards de métricas */}
|
||||||
|
<div className="grid gap-4 md:grid-cols-2 lg:grid-cols-4">
|
||||||
|
<DashboardStats
|
||||||
|
receitaTotal={receitaTotal}
|
||||||
|
totalPedidosConfirmados={pedidosConfirmados.length}
|
||||||
|
ticketMedio={ticketMedio}
|
||||||
|
pedidosPendentes={pedidosPendentes}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Gráfico + últimas vendas */}
|
||||||
|
<div className="grid gap-4 md:grid-cols-2 lg:grid-cols-7">
|
||||||
|
<Card className="col-span-4">
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle className="font-heading">Receita Mensal</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="pl-2">
|
||||||
|
<Overview data={receitaMensal} />
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
<Card className="col-span-3">
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle className="font-heading">Últimas Vendas</CardTitle>
|
||||||
|
<CardDescription className="font-sans">
|
||||||
|
{pedidosConfirmados.length} venda{pedidosConfirmados.length !== 1 ? "s" : ""} confirmada{pedidosConfirmados.length !== 1 ? "s" : ""} no total.
|
||||||
|
</CardDescription>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<RecentSales vendas={ultimasVendas} />
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Abas */}
|
||||||
|
<Tabs defaultValue="pedidos" className="space-y-4">
|
||||||
|
<TabsList>
|
||||||
|
<TabsTrigger value="pedidos">Pedidos</TabsTrigger>
|
||||||
|
<TabsTrigger value="agendamentos">
|
||||||
|
Agendamentos
|
||||||
|
{agendamentosPendentes > 0 && (
|
||||||
|
<span className="ml-1.5 inline-flex items-center justify-center rounded-full bg-primary text-primary-foreground text-[10px] font-bold w-4 h-4">
|
||||||
|
{agendamentosPendentes}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</TabsTrigger>
|
||||||
|
<TabsTrigger value="products">Produtos</TabsTrigger>
|
||||||
|
<TabsTrigger value="customers">Clientes</TabsTrigger>
|
||||||
|
<TabsTrigger value="cupons">Cupons</TabsTrigger>
|
||||||
|
</TabsList>
|
||||||
|
|
||||||
|
<TabsContent value="pedidos" className="space-y-4">
|
||||||
|
<TransactionTable transactions={pedidos} />
|
||||||
|
</TabsContent>
|
||||||
|
|
||||||
|
<TabsContent value="agendamentos" className="space-y-4">
|
||||||
|
<AgendamentosTable agendamentos={agendamentos as Parameters<typeof AgendamentosTable>[0]["agendamentos"]} />
|
||||||
|
</TabsContent>
|
||||||
|
|
||||||
|
<TabsContent value="products" className="space-y-4">
|
||||||
|
<div className="flex justify-end mb-4">
|
||||||
|
<ProductFormDialog />
|
||||||
|
</div>
|
||||||
|
<ProductTable products={produtos} />
|
||||||
|
</TabsContent>
|
||||||
|
|
||||||
|
<TabsContent value="customers" className="space-y-4">
|
||||||
|
<div className="flex justify-end mb-4">
|
||||||
|
<CustomerFormDialog />
|
||||||
|
</div>
|
||||||
|
<CustomerTable customers={clientes} />
|
||||||
|
</TabsContent>
|
||||||
|
|
||||||
|
<TabsContent value="cupons" className="space-y-4">
|
||||||
|
<CupomTable cupons={cupons} />
|
||||||
|
</TabsContent>
|
||||||
|
</Tabs>
|
||||||
|
</DashboardShell>
|
||||||
|
</AuthGuard>
|
||||||
|
)
|
||||||
|
}
|
||||||
28
app/api/agendamento/[id]/status/route.ts
Normal file
28
app/api/agendamento/[id]/status/route.ts
Normal file
@@ -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 })
|
||||||
|
}
|
||||||
102
app/api/asaas-webhook/route.ts
Normal file
102
app/api/asaas-webhook/route.ts
Normal file
@@ -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<string, unknown> = { 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 })
|
||||||
|
}
|
||||||
|
}
|
||||||
6
app/api/checkout/route.ts
Normal file
6
app/api/checkout/route.ts
Normal file
@@ -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 })
|
||||||
|
}
|
||||||
48
app/api/pedido/[id]/route.ts
Normal file
48
app/api/pedido/[id]/route.ts
Normal file
@@ -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<string, unknown> = { 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)
|
||||||
|
}
|
||||||
45
app/api/pedido/[id]/sync/route.ts
Normal file
45
app/api/pedido/[id]/sync/route.ts
Normal file
@@ -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<string, unknown> = { 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 }
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
6
app/api/webhook/route.ts
Normal file
6
app/api/webhook/route.ts
Normal file
@@ -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 })
|
||||||
|
}
|
||||||
75
app/comprar/page.tsx
Normal file
75
app/comprar/page.tsx
Normal file
@@ -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 (
|
||||||
|
<div className="container mx-auto py-12 px-4 max-w-5xl">
|
||||||
|
<div className="text-center mb-10">
|
||||||
|
<h1 className="text-4xl font-bold mb-3">Produtos</h1>
|
||||||
|
<p className="text-lg text-muted-foreground">
|
||||||
|
Escolha o produto e conclua sua compra em poucos minutos.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{produtos.length === 0 ? (
|
||||||
|
<div className="text-center py-20 text-muted-foreground">
|
||||||
|
Nenhum produto disponível no momento.
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-6">
|
||||||
|
{produtos.map((p) => (
|
||||||
|
<Card key={p.id} className="flex flex-col hover:shadow-lg transition-shadow">
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle className="text-lg">{p.nome}</CardTitle>
|
||||||
|
{p.descricao && (
|
||||||
|
<p className="text-sm text-muted-foreground">{p.descricao}</p>
|
||||||
|
)}
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="flex-1 space-y-1">
|
||||||
|
<p className="text-3xl font-bold text-primary">
|
||||||
|
{(p.preco_centavos / 100).toLocaleString("pt-BR", {
|
||||||
|
style: "currency",
|
||||||
|
currency: "BRL",
|
||||||
|
})}
|
||||||
|
</p>
|
||||||
|
{p.validade && (
|
||||||
|
<p className="text-sm text-muted-foreground">Validade: {p.validade}</p>
|
||||||
|
)}
|
||||||
|
</CardContent>
|
||||||
|
<CardFooter>
|
||||||
|
<Button asChild className="w-full">
|
||||||
|
<Link href={`/produtos/${p.id}`}>Comprar agora</Link>
|
||||||
|
</Button>
|
||||||
|
</CardFooter>
|
||||||
|
</Card>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{appConfig.supportWhatsapp && (
|
||||||
|
<p className="text-center mt-12 text-sm text-muted-foreground">
|
||||||
|
Dúvidas?{" "}
|
||||||
|
<a
|
||||||
|
href={`https://wa.me/${appConfig.supportWhatsapp}`}
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener noreferrer"
|
||||||
|
className="text-primary underline"
|
||||||
|
>
|
||||||
|
Fale conosco no WhatsApp
|
||||||
|
</a>
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
84
app/globals.css
Normal file
84
app/globals.css
Normal file
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
27
app/layout-client.tsx
Normal file
27
app/layout-client.tsx
Normal file
@@ -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 (
|
||||||
|
<ThemeProvider attribute="class" defaultTheme="light" enableSystem>
|
||||||
|
<AuthProvider>
|
||||||
|
<div className="relative flex min-h-screen flex-col">
|
||||||
|
{!isLoginPage && <SiteHeader />}
|
||||||
|
<main className="flex-1">{children}</main>
|
||||||
|
{!isLoginPage && <SiteFooter />}
|
||||||
|
</div>
|
||||||
|
<Toaster />
|
||||||
|
</AuthProvider>
|
||||||
|
</ThemeProvider>
|
||||||
|
)
|
||||||
|
}
|
||||||
40
app/layout.tsx
Normal file
40
app/layout.tsx
Normal file
@@ -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 (
|
||||||
|
<html lang="pt-BR" suppressHydrationWarning>
|
||||||
|
<body className={cn("min-h-screen bg-background font-sans antialiased", kiro.variable, inter.variable)}>
|
||||||
|
<ClientRootLayoutContent>{children}</ClientRootLayoutContent>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
|
)
|
||||||
|
}
|
||||||
70
app/login/page.tsx
Normal file
70
app/login/page.tsx
Normal file
@@ -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 (
|
||||||
|
<div className="container flex h-screen w-screen flex-col items-center justify-center">
|
||||||
|
<Card className="w-[350px]">
|
||||||
|
<CardHeader className="space-y-1">
|
||||||
|
<CardTitle className="text-2xl">{appConfig.name}</CardTitle>
|
||||||
|
<CardDescription>Área administrativa</CardDescription>
|
||||||
|
</CardHeader>
|
||||||
|
<form onSubmit={onSubmit}>
|
||||||
|
<CardContent className="grid gap-4">
|
||||||
|
<div className="grid gap-2">
|
||||||
|
<Label htmlFor="username">Usuário</Label>
|
||||||
|
<Input id="username" name="username" type="text" required disabled={isLoading} />
|
||||||
|
</div>
|
||||||
|
<div className="grid gap-2">
|
||||||
|
<Label htmlFor="password">Senha</Label>
|
||||||
|
<Input id="password" name="password" type="password" required disabled={isLoading} />
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
<CardFooter>
|
||||||
|
<Button className="w-full" type="submit" disabled={isLoading}>
|
||||||
|
{isLoading && <Icons.spinner className="mr-2 h-4 w-4 animate-spin" />}
|
||||||
|
Entrar
|
||||||
|
</Button>
|
||||||
|
</CardFooter>
|
||||||
|
</form>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
64
app/page.tsx
Normal file
64
app/page.tsx
Normal file
@@ -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 (
|
||||||
|
<div className="min-h-screen bg-gradient-to-b from-gray-50 to-white">
|
||||||
|
{/* Hero */}
|
||||||
|
<section className="container mx-auto py-20 px-4 text-center">
|
||||||
|
<h1 className="text-4xl sm:text-5xl font-bold text-primary mb-4">
|
||||||
|
{appConfig.name}
|
||||||
|
</h1>
|
||||||
|
<p className="text-lg text-muted-foreground mb-8 max-w-xl mx-auto">
|
||||||
|
Escolha um produto abaixo e conclua sua compra em poucos cliques.
|
||||||
|
</p>
|
||||||
|
<Button asChild size="lg">
|
||||||
|
<Link href="/comprar">
|
||||||
|
<ShoppingBag className="mr-2 h-5 w-5" />
|
||||||
|
Ver todos os produtos
|
||||||
|
</Link>
|
||||||
|
</Button>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
{/* Produtos em destaque */}
|
||||||
|
{produtos.length > 0 && (
|
||||||
|
<section className="container mx-auto pb-20 px-4">
|
||||||
|
<h2 className="text-2xl font-bold text-center mb-10">Produtos disponíveis</h2>
|
||||||
|
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-6 max-w-5xl mx-auto">
|
||||||
|
{produtos.map((p) => (
|
||||||
|
<Card key={p.id} className="flex flex-col hover:shadow-lg transition-shadow">
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle className="text-lg">{p.nome}</CardTitle>
|
||||||
|
{p.descricao && (
|
||||||
|
<p className="text-sm text-muted-foreground">{p.descricao}</p>
|
||||||
|
)}
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="flex-1">
|
||||||
|
<p className="text-3xl font-bold text-primary">
|
||||||
|
{(p.preco_centavos / 100).toLocaleString("pt-BR", {
|
||||||
|
style: "currency",
|
||||||
|
currency: "BRL",
|
||||||
|
})}
|
||||||
|
</p>
|
||||||
|
</CardContent>
|
||||||
|
<CardFooter>
|
||||||
|
<Button asChild className="w-full">
|
||||||
|
<Link href={`/produtos/${p.id}`}>Comprar agora</Link>
|
||||||
|
</Button>
|
||||||
|
</CardFooter>
|
||||||
|
</Card>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
37
app/pedido/[id]/page.tsx
Normal file
37
app/pedido/[id]/page.tsx
Normal file
@@ -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 (
|
||||||
|
<div className="container py-16 px-4">
|
||||||
|
<div className="max-w-2xl mx-auto mb-8 text-center">
|
||||||
|
<h1 className="text-3xl font-bold mb-2">Seu Pedido</h1>
|
||||||
|
<p className="text-muted-foreground">
|
||||||
|
Acompanhe o status do seu pedido
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<PedidoStatus pedidoInicial={pedido} />
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
71
app/produtos/[id]/page.tsx
Normal file
71
app/produtos/[id]/page.tsx
Normal file
@@ -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<Metadata> {
|
||||||
|
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 (
|
||||||
|
<div className="bg-gradient-to-b from-gray-50 to-white min-h-screen">
|
||||||
|
<div className="container mx-auto py-16 px-4 sm:px-6 lg:px-8 max-w-5xl">
|
||||||
|
<div className="grid grid-cols-1 lg:grid-cols-2 gap-12">
|
||||||
|
{/* Detalhes do produto */}
|
||||||
|
<div>
|
||||||
|
<h1 className="text-3xl font-bold text-primary mb-4">{produto.nome}</h1>
|
||||||
|
{produto.descricao && (
|
||||||
|
<p className="text-gray-600 text-lg mb-6">{produto.descricao}</p>
|
||||||
|
)}
|
||||||
|
<div className="flex flex-wrap gap-3 text-sm text-gray-500">
|
||||||
|
{produto.tipo && (
|
||||||
|
<span className="bg-gray-100 px-3 py-1 rounded-full">
|
||||||
|
Tipo: <strong>{produto.tipo}</strong>
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
{produto.validade && (
|
||||||
|
<span className="bg-gray-100 px-3 py-1 rounded-full">
|
||||||
|
Validade: <strong>{produto.validade}</strong>
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
{produto.midia && (
|
||||||
|
<span className="bg-gray-100 px-3 py-1 rounded-full">
|
||||||
|
Mídia: <strong>{produto.midia}</strong>
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Formulário de compra */}
|
||||||
|
<div className="bg-white p-8 rounded-2xl shadow-lg">
|
||||||
|
<p className="text-4xl font-bold mb-6 text-primary">{precoFormatado}</p>
|
||||||
|
<ProductBuyForm produtoId={produto.id} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
5
app/produtos/page.tsx
Normal file
5
app/produtos/page.tsx
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
import { redirect } from "next/navigation"
|
||||||
|
|
||||||
|
export default function ProdutosPage() {
|
||||||
|
redirect("/comprar")
|
||||||
|
}
|
||||||
21
components.json
Normal file
21
components.json
Normal file
@@ -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"
|
||||||
|
}
|
||||||
133
components/agendamentos-table.tsx
Normal file
133
components/agendamentos-table.tsx
Normal file
@@ -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<string, { label: string; color: string; icon: React.ReactNode }> = {
|
||||||
|
pendente: { label: "Pendente", color: "bg-yellow-100 text-yellow-800", icon: <Clock className="h-3 w-3" /> },
|
||||||
|
confirmado: { label: "Confirmado", color: "bg-blue-100 text-blue-800", icon: <Calendar className="h-3 w-3" /> },
|
||||||
|
realizado: { label: "Realizado", color: "bg-green-100 text-green-800", icon: <CheckCircle2 className="h-3 w-3" /> },
|
||||||
|
cancelado: { label: "Cancelado", color: "bg-red-100 text-red-800", icon: <XCircle className="h-3 w-3" /> },
|
||||||
|
}
|
||||||
|
|
||||||
|
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<string | null>(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 (
|
||||||
|
<div className="rounded-md border p-8 text-center text-muted-foreground">
|
||||||
|
Nenhum agendamento encontrado.
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="rounded-md border">
|
||||||
|
<Table>
|
||||||
|
<TableHeader>
|
||||||
|
<TableRow>
|
||||||
|
<TableHead>Cliente</TableHead>
|
||||||
|
<TableHead>Produto</TableHead>
|
||||||
|
<TableHead>Data / Hora</TableHead>
|
||||||
|
<TableHead>Status</TableHead>
|
||||||
|
<TableHead>Observações</TableHead>
|
||||||
|
<TableHead>Ações</TableHead>
|
||||||
|
</TableRow>
|
||||||
|
</TableHeader>
|
||||||
|
<TableBody>
|
||||||
|
{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 (
|
||||||
|
<TableRow key={a.id}>
|
||||||
|
<TableCell>
|
||||||
|
<p className="font-medium text-sm">{a.clientes?.nome ?? "—"}</p>
|
||||||
|
<p className="text-xs text-muted-foreground">{a.clientes?.email ?? ""}</p>
|
||||||
|
</TableCell>
|
||||||
|
<TableCell className="text-sm">{a.produtos?.nome ?? "—"}</TableCell>
|
||||||
|
<TableCell className="text-sm whitespace-nowrap">
|
||||||
|
{new Date(a.data_hora).toLocaleString("pt-BR", {
|
||||||
|
day: "2-digit", month: "2-digit", year: "numeric",
|
||||||
|
hour: "2-digit", minute: "2-digit",
|
||||||
|
})}
|
||||||
|
</TableCell>
|
||||||
|
<TableCell>
|
||||||
|
<span className={`inline-flex items-center gap-1 px-2 py-0.5 rounded-full text-xs font-medium ${cfg.color}`}>
|
||||||
|
{cfg.icon}
|
||||||
|
{cfg.label}
|
||||||
|
</span>
|
||||||
|
</TableCell>
|
||||||
|
<TableCell className="text-sm text-muted-foreground max-w-[180px] truncate">
|
||||||
|
{a.observacoes ?? "—"}
|
||||||
|
</TableCell>
|
||||||
|
<TableCell>
|
||||||
|
<div className="flex gap-1">
|
||||||
|
{a.status !== "realizado" && (
|
||||||
|
<Button
|
||||||
|
size="sm"
|
||||||
|
variant="outline"
|
||||||
|
className="h-7 text-xs"
|
||||||
|
disabled={isLoading}
|
||||||
|
onClick={() => atualizarStatus(a.id, "realizado")}
|
||||||
|
>
|
||||||
|
<CheckCircle2 className="mr-1 h-3 w-3" />
|
||||||
|
Realizado
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
{a.status !== "cancelado" && a.status !== "realizado" && (
|
||||||
|
<Button
|
||||||
|
size="sm"
|
||||||
|
variant="outline"
|
||||||
|
className="h-7 text-xs text-red-600 hover:text-red-700"
|
||||||
|
disabled={isLoading}
|
||||||
|
onClick={() => atualizarStatus(a.id, "cancelado")}
|
||||||
|
>
|
||||||
|
<XCircle className="mr-1 h-3 w-3" />
|
||||||
|
Cancelar
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</TableCell>
|
||||||
|
</TableRow>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</TableBody>
|
||||||
|
</Table>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
25
components/auth-guard.tsx
Normal file
25
components/auth-guard.tsx
Normal file
@@ -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}</>
|
||||||
|
}
|
||||||
239
components/cupom-table.tsx
Normal file
239
components/cupom-table.tsx
Normal file
@@ -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 (
|
||||||
|
<Dialog open={open} onOpenChange={setOpen}>
|
||||||
|
<DialogTrigger asChild>
|
||||||
|
<Button className="gap-2"><Plus className="w-4 h-4" /> Novo Cupom</Button>
|
||||||
|
</DialogTrigger>
|
||||||
|
<DialogContent>
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle>Criar cupom de desconto</DialogTitle>
|
||||||
|
</DialogHeader>
|
||||||
|
<form onSubmit={handleSubmit} className="space-y-4 mt-2">
|
||||||
|
<div className="space-y-1">
|
||||||
|
<Label>Código</Label>
|
||||||
|
<Input
|
||||||
|
required
|
||||||
|
placeholder="VIXCERT20"
|
||||||
|
value={form.codigo}
|
||||||
|
onChange={(e) => setForm({ ...form, codigo: e.target.value.toUpperCase() })}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="space-y-1">
|
||||||
|
<Label>Descrição</Label>
|
||||||
|
<Input
|
||||||
|
required
|
||||||
|
placeholder="Ex: Renovação de Certificado"
|
||||||
|
value={form.descricao}
|
||||||
|
onChange={(e) => setForm({ ...form, descricao: e.target.value })}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="grid grid-cols-2 gap-4">
|
||||||
|
<div className="space-y-1">
|
||||||
|
<Label>Desconto (%)</Label>
|
||||||
|
<Input
|
||||||
|
required
|
||||||
|
type="number"
|
||||||
|
min="1"
|
||||||
|
max="100"
|
||||||
|
value={form.percentual}
|
||||||
|
onChange={(e) => setForm({ ...form, percentual: e.target.value })}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="space-y-1">
|
||||||
|
<Label>Válido até</Label>
|
||||||
|
<Input
|
||||||
|
required
|
||||||
|
type="date"
|
||||||
|
value={form.validade}
|
||||||
|
onChange={(e) => setForm({ ...form, validade: e.target.value })}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<Switch
|
||||||
|
id="destaque"
|
||||||
|
checked={form.destaque}
|
||||||
|
onCheckedChange={(v) => setForm({ ...form, destaque: v })}
|
||||||
|
/>
|
||||||
|
<Label htmlFor="destaque" className="cursor-pointer flex items-center gap-1">
|
||||||
|
<Star className="w-4 h-4 text-yellow-500" /> Exibir em destaque no site
|
||||||
|
</Label>
|
||||||
|
</div>
|
||||||
|
<Button type="submit" className="w-full" disabled={loading}>
|
||||||
|
{loading ? "Criando..." : "Criar cupom"}
|
||||||
|
</Button>
|
||||||
|
</form>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
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 (
|
||||||
|
<Card>
|
||||||
|
<CardHeader className="flex flex-row items-center justify-between">
|
||||||
|
<CardTitle className="flex items-center gap-2">
|
||||||
|
<Tag className="w-5 h-5" /> Cupons de Desconto
|
||||||
|
</CardTitle>
|
||||||
|
<NovoCupomDialog />
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
{cupons.length === 0 ? (
|
||||||
|
<p className="text-center text-gray-500 py-8">Nenhum cupom cadastrado.</p>
|
||||||
|
) : (
|
||||||
|
<div className="space-y-3">
|
||||||
|
{cupons.map((cupom) => {
|
||||||
|
const vencido = cupom.validade < hoje
|
||||||
|
const validadeFormatada = new Date(cupom.validade + "T12:00:00").toLocaleDateString("pt-BR")
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
key={cupom.id}
|
||||||
|
className={`flex items-center justify-between p-4 rounded-lg border ${
|
||||||
|
cupom.destaque ? "border-secondary/50 bg-secondary/5" : "border-gray-200"
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<div className="flex items-center gap-4">
|
||||||
|
<div>
|
||||||
|
<div className="flex items-center gap-2 mb-1">
|
||||||
|
<span className="font-mono font-bold text-gray-800">{cupom.codigo}</span>
|
||||||
|
<Badge variant="secondary">{cupom.percentual}% OFF</Badge>
|
||||||
|
{cupom.destaque && (
|
||||||
|
<Badge className="bg-yellow-100 text-yellow-800 border-yellow-300">
|
||||||
|
<Star className="w-3 h-3 mr-1" /> Destaque
|
||||||
|
</Badge>
|
||||||
|
)}
|
||||||
|
{vencido && <Badge variant="destructive">Vencido</Badge>}
|
||||||
|
{!cupom.ativo && <Badge variant="outline">Inativo</Badge>}
|
||||||
|
</div>
|
||||||
|
<div className="text-sm text-gray-500">{cupom.descricao} · Até {validadeFormatada}</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<div className="flex items-center gap-1 text-xs text-gray-500">
|
||||||
|
<Switch
|
||||||
|
checked={cupom.destaque}
|
||||||
|
onCheckedChange={() => handleToggleDestaque(cupom)}
|
||||||
|
title="Exibir em destaque"
|
||||||
|
/>
|
||||||
|
<span>Destaque</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-1 text-xs text-gray-500">
|
||||||
|
<Switch
|
||||||
|
checked={cupom.ativo}
|
||||||
|
onCheckedChange={() => handleToggleAtivo(cupom)}
|
||||||
|
title="Ativo/Inativo"
|
||||||
|
/>
|
||||||
|
<span>Ativo</span>
|
||||||
|
</div>
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="icon"
|
||||||
|
className="text-red-500 hover:text-red-700"
|
||||||
|
onClick={() => handleDeletar(cupom.id)}
|
||||||
|
>
|
||||||
|
<Trash2 className="w-4 h-4" />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
)
|
||||||
|
}
|
||||||
86
components/customer-form-dialog.tsx
Normal file
86
components/customer-form-dialog.tsx
Normal file
@@ -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<HTMLFormElement>) => {
|
||||||
|
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<HTMLInputElement>) => {
|
||||||
|
const { name, value } = e.target
|
||||||
|
setFormData((prev) => ({ ...prev, [name]: value }))
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Dialog open={open} onOpenChange={setOpen}>
|
||||||
|
<DialogTrigger asChild>
|
||||||
|
<Button>Novo Cliente</Button>
|
||||||
|
</DialogTrigger>
|
||||||
|
<DialogContent className="sm:max-w-[425px]">
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle>Criar Novo Cliente</DialogTitle>
|
||||||
|
<DialogDescription>Preencha os dados do novo cliente.</DialogDescription>
|
||||||
|
</DialogHeader>
|
||||||
|
<form onSubmit={handleSubmit} className="space-y-4">
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label htmlFor="nome">Nome</Label>
|
||||||
|
<Input id="nome" name="nome" value={formData.nome} onChange={handleChange} required />
|
||||||
|
</div>
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label htmlFor="email">E-mail</Label>
|
||||||
|
<Input id="email" name="email" type="email" value={formData.email} onChange={handleChange} required />
|
||||||
|
</div>
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label htmlFor="cpf_cnpj">CPF / CNPJ</Label>
|
||||||
|
<Input id="cpf_cnpj" name="cpf_cnpj" value={formData.cpf_cnpj} onChange={handleChange} />
|
||||||
|
</div>
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label htmlFor="telefone">Telefone</Label>
|
||||||
|
<Input id="telefone" name="telefone" value={formData.telefone} onChange={handleChange} />
|
||||||
|
</div>
|
||||||
|
<div className="flex justify-end space-x-2">
|
||||||
|
<Button type="button" variant="outline" onClick={() => setOpen(false)}>Cancelar</Button>
|
||||||
|
<Button type="submit">Criar Cliente</Button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
)
|
||||||
|
}
|
||||||
47
components/customer-table.tsx
Normal file
47
components/customer-table.tsx
Normal file
@@ -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 (
|
||||||
|
<div className="rounded-md border p-8 text-center text-muted-foreground">
|
||||||
|
Nenhum cliente cadastrado.
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="rounded-md border">
|
||||||
|
<Table>
|
||||||
|
<TableHeader>
|
||||||
|
<TableRow>
|
||||||
|
<TableHead>Nome</TableHead>
|
||||||
|
<TableHead>E-mail</TableHead>
|
||||||
|
<TableHead>CPF/CNPJ</TableHead>
|
||||||
|
<TableHead>Telefone</TableHead>
|
||||||
|
<TableHead>Cadastro</TableHead>
|
||||||
|
</TableRow>
|
||||||
|
</TableHeader>
|
||||||
|
<TableBody>
|
||||||
|
{customers.map((c) => (
|
||||||
|
<TableRow key={c.id}>
|
||||||
|
<TableCell className="font-medium">{c.nome}</TableCell>
|
||||||
|
<TableCell>{c.email}</TableCell>
|
||||||
|
<TableCell>{c.cpf_cnpj ?? "—"}</TableCell>
|
||||||
|
<TableCell>{c.telefone ?? "—"}</TableCell>
|
||||||
|
<TableCell className="text-sm text-muted-foreground">
|
||||||
|
{new Date(c.created_at).toLocaleDateString("pt-BR")}
|
||||||
|
</TableCell>
|
||||||
|
</TableRow>
|
||||||
|
))}
|
||||||
|
</TableBody>
|
||||||
|
</Table>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
23
components/dashboard-header.tsx
Normal file
23
components/dashboard-header.tsx
Normal file
@@ -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 (
|
||||||
|
<div className="flex items-center justify-between px-2">
|
||||||
|
<div className="grid gap-1">
|
||||||
|
<h1 className="font-heading text-3xl font-bold tracking-tight">{heading}</h1>
|
||||||
|
{text && <p className="font-sans text-lg text-muted-foreground">{text}</p>}
|
||||||
|
<p className="text-sm text-muted-foreground mt-2 font-sans">
|
||||||
|
Gerencie seus certificados digitais, acompanhe vendas e monitore o desempenho em tempo real. Todas as
|
||||||
|
ferramentas que você precisa em um só lugar.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-2">{children}</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
12
components/dashboard-shell.tsx
Normal file
12
components/dashboard-shell.tsx
Normal file
@@ -0,0 +1,12 @@
|
|||||||
|
import { cn } from "@/lib/utils"
|
||||||
|
import type React from "react"
|
||||||
|
|
||||||
|
interface DashboardShellProps extends React.HTMLAttributes<HTMLDivElement> {}
|
||||||
|
|
||||||
|
export function DashboardShell({ children, className, ...props }: DashboardShellProps) {
|
||||||
|
return (
|
||||||
|
<div className={cn("container space-y-8 py-8", className)} {...props}>
|
||||||
|
{children}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
63
components/dashboard-stats.tsx
Normal file
63
components/dashboard-stats.tsx
Normal file
@@ -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) => (
|
||||||
|
<Card key={index}>
|
||||||
|
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
|
||||||
|
<CardTitle className="font-heading text-sm font-medium">{stat.title}</CardTitle>
|
||||||
|
<stat.icon className="h-4 w-4 text-muted-foreground" />
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<div className="font-sans text-2xl font-bold">{stat.value}</div>
|
||||||
|
<p className="font-sans text-xs text-muted-foreground">{stat.description}</p>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
))}
|
||||||
|
</>
|
||||||
|
)
|
||||||
|
}
|
||||||
65
components/data-table-column-header.tsx
Normal file
65
components/data-table-column-header.tsx
Normal file
@@ -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<TData, TValue> extends React.HTMLAttributes<HTMLDivElement> {
|
||||||
|
column: Column<TData, TValue>
|
||||||
|
title: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export function DataTableColumnHeader<TData, TValue>({
|
||||||
|
column,
|
||||||
|
title,
|
||||||
|
className,
|
||||||
|
}: DataTableColumnHeaderProps<TData, TValue>) {
|
||||||
|
if (!column.getCanSort()) {
|
||||||
|
return <div className={cn(className)}>{title}</div>
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className={cn("flex items-center space-x-2", className)}>
|
||||||
|
<DropdownMenu>
|
||||||
|
<DropdownMenuTrigger asChild>
|
||||||
|
<Button variant="ghost" size="sm" className="-ml-3 h-8 data-[state=open]:bg-accent">
|
||||||
|
<span>{title}</span>
|
||||||
|
{column.getIsSorted() === "desc" ? (
|
||||||
|
<ArrowDownIcon className="ml-2 h-4 w-4" />
|
||||||
|
) : column.getIsSorted() === "asc" ? (
|
||||||
|
<ArrowUpIcon className="ml-2 h-4 w-4" />
|
||||||
|
) : (
|
||||||
|
<CaretSortIcon className="ml-2 h-4 w-4" />
|
||||||
|
)}
|
||||||
|
</Button>
|
||||||
|
</DropdownMenuTrigger>
|
||||||
|
<DropdownMenuContent align="start">
|
||||||
|
<DropdownMenuItem onClick={() => column.toggleSorting(false)}>
|
||||||
|
<ArrowUpIcon className="mr-2 h-3.5 w-3.5 text-muted-foreground/70" />
|
||||||
|
Crescente
|
||||||
|
</DropdownMenuItem>
|
||||||
|
<DropdownMenuItem onClick={() => column.toggleSorting(true)}>
|
||||||
|
<ArrowDownIcon className="mr-2 h-3.5 w-3.5 text-muted-foreground/70" />
|
||||||
|
Decrescente
|
||||||
|
</DropdownMenuItem>
|
||||||
|
<DropdownMenuSeparator />
|
||||||
|
<DropdownMenuItem onClick={() => column.toggleVisibility(false)}>
|
||||||
|
<EyeNoneIcon className="mr-2 h-3.5 w-3.5 text-muted-foreground/70" />
|
||||||
|
Ocultar
|
||||||
|
</DropdownMenuItem>
|
||||||
|
</DropdownMenuContent>
|
||||||
|
</DropdownMenu>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
129
components/data-table-faceted-filter.tsx
Normal file
129
components/data-table-faceted-filter.tsx
Normal file
@@ -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<TData, TValue> {
|
||||||
|
column?: Column<TData, TValue>
|
||||||
|
title?: string
|
||||||
|
options: {
|
||||||
|
label: string
|
||||||
|
value: string
|
||||||
|
icon?: React.ComponentType<{ className?: string }>
|
||||||
|
}[]
|
||||||
|
}
|
||||||
|
|
||||||
|
export function DataTableFacetedFilter<TData, TValue>({
|
||||||
|
column,
|
||||||
|
title,
|
||||||
|
options,
|
||||||
|
}: DataTableFacetedFilterProps<TData, TValue>) {
|
||||||
|
const facets = column?.getFacetedUniqueValues()
|
||||||
|
const selectedValues = new Set(column?.getFilterValue() as string[])
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Popover>
|
||||||
|
<PopoverTrigger asChild>
|
||||||
|
<Button variant="outline" size="sm" className="h-8 border-dashed">
|
||||||
|
<PlusCircledIcon className="mr-2 h-4 w-4" />
|
||||||
|
{title}
|
||||||
|
{selectedValues?.size > 0 && (
|
||||||
|
<>
|
||||||
|
<Separator orientation="vertical" className="mx-2 h-4" />
|
||||||
|
<Badge variant="secondary" className="rounded-sm px-1 font-normal lg:hidden">
|
||||||
|
{selectedValues.size}
|
||||||
|
</Badge>
|
||||||
|
<div className="hidden space-x-1 lg:flex">
|
||||||
|
{selectedValues.size > 2 ? (
|
||||||
|
<Badge variant="secondary" className="rounded-sm px-1 font-normal">
|
||||||
|
{selectedValues.size} selecionados
|
||||||
|
</Badge>
|
||||||
|
) : (
|
||||||
|
options
|
||||||
|
.filter((option) => selectedValues.has(option.value))
|
||||||
|
.map((option) => (
|
||||||
|
<Badge variant="secondary" key={option.value} className="rounded-sm px-1 font-normal">
|
||||||
|
{option.label}
|
||||||
|
</Badge>
|
||||||
|
))
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</Button>
|
||||||
|
</PopoverTrigger>
|
||||||
|
<PopoverContent className="w-[200px] p-0" align="start">
|
||||||
|
<Command>
|
||||||
|
<CommandInput placeholder={title} />
|
||||||
|
<CommandList>
|
||||||
|
<CommandEmpty>Nenhum resultado encontrado.</CommandEmpty>
|
||||||
|
<CommandGroup>
|
||||||
|
{options.map((option) => {
|
||||||
|
const isSelected = selectedValues.has(option.value)
|
||||||
|
return (
|
||||||
|
<CommandItem
|
||||||
|
key={option.value}
|
||||||
|
onSelect={() => {
|
||||||
|
if (isSelected) {
|
||||||
|
selectedValues.delete(option.value)
|
||||||
|
} else {
|
||||||
|
selectedValues.add(option.value)
|
||||||
|
}
|
||||||
|
const filterValues = Array.from(selectedValues)
|
||||||
|
column?.setFilterValue(filterValues.length ? filterValues : undefined)
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
className={cn(
|
||||||
|
"mr-2 flex h-4 w-4 items-center justify-center rounded-sm border border-primary",
|
||||||
|
isSelected ? "bg-primary text-primary-foreground" : "opacity-50 [&_svg]:invisible",
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<CheckIcon className={cn("h-4 w-4")} />
|
||||||
|
</div>
|
||||||
|
{option.icon && <option.icon className="mr-2 h-4 w-4 text-muted-foreground" />}
|
||||||
|
<span>{option.label}</span>
|
||||||
|
{facets?.get(option.value) && (
|
||||||
|
<span className="ml-auto flex h-4 w-4 items-center justify-center font-mono text-xs">
|
||||||
|
{facets.get(option.value)}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</CommandItem>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</CommandGroup>
|
||||||
|
{selectedValues.size > 0 && (
|
||||||
|
<>
|
||||||
|
<CommandSeparator />
|
||||||
|
<CommandGroup>
|
||||||
|
<CommandItem
|
||||||
|
onSelect={() => column?.setFilterValue(undefined)}
|
||||||
|
className="justify-center text-center"
|
||||||
|
>
|
||||||
|
Limpar filtros
|
||||||
|
</CommandItem>
|
||||||
|
</CommandGroup>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</CommandList>
|
||||||
|
</Command>
|
||||||
|
</PopoverContent>
|
||||||
|
</Popover>
|
||||||
|
)
|
||||||
|
}
|
||||||
85
components/data-table-pagination.tsx
Normal file
85
components/data-table-pagination.tsx
Normal file
@@ -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<TData> {
|
||||||
|
table: Table<TData>
|
||||||
|
}
|
||||||
|
|
||||||
|
export function DataTablePagination<TData>({ table }: DataTablePaginationProps<TData>) {
|
||||||
|
return (
|
||||||
|
<div className="flex items-center justify-between px-2">
|
||||||
|
<div className="flex-1 text-sm text-muted-foreground">
|
||||||
|
{table.getFilteredSelectedRowModel().rows.length} de {table.getFilteredRowModel().rows.length} linha(s)
|
||||||
|
selecionada(s).
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center space-x-6 lg:space-x-8">
|
||||||
|
<div className="flex items-center space-x-2">
|
||||||
|
<p className="text-sm font-medium">Linhas por página</p>
|
||||||
|
<Select
|
||||||
|
value={`${table.getState().pagination.pageSize}`}
|
||||||
|
onValueChange={(value) => {
|
||||||
|
table.setPageSize(Number(value))
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<SelectTrigger className="h-8 w-[70px]">
|
||||||
|
<SelectValue placeholder={table.getState().pagination.pageSize} />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent side="top">
|
||||||
|
{[10, 20, 30, 40, 50].map((pageSize) => (
|
||||||
|
<SelectItem key={pageSize} value={`${pageSize}`}>
|
||||||
|
{pageSize}
|
||||||
|
</SelectItem>
|
||||||
|
))}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
<div className="flex w-[100px] items-center justify-center text-sm font-medium">
|
||||||
|
Página {table.getState().pagination.pageIndex + 1} de {table.getPageCount()}
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center space-x-2">
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
className="hidden h-8 w-8 p-0 lg:flex"
|
||||||
|
onClick={() => table.setPageIndex(0)}
|
||||||
|
disabled={!table.getCanPreviousPage()}
|
||||||
|
>
|
||||||
|
<span className="sr-only">Ir para primeira página</span>
|
||||||
|
<DoubleArrowLeftIcon className="h-4 w-4" />
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
className="h-8 w-8 p-0"
|
||||||
|
onClick={() => table.previousPage()}
|
||||||
|
disabled={!table.getCanPreviousPage()}
|
||||||
|
>
|
||||||
|
<span className="sr-only">Ir para página anterior</span>
|
||||||
|
<ChevronLeftIcon className="h-4 w-4" />
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
className="h-8 w-8 p-0"
|
||||||
|
onClick={() => table.nextPage()}
|
||||||
|
disabled={!table.getCanNextPage()}
|
||||||
|
>
|
||||||
|
<span className="sr-only">Ir para próxima página</span>
|
||||||
|
<ChevronRightIcon className="h-4 w-4" />
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
className="hidden h-8 w-8 p-0 lg:flex"
|
||||||
|
onClick={() => table.setPageIndex(table.getPageCount() - 1)}
|
||||||
|
disabled={!table.getCanNextPage()}
|
||||||
|
>
|
||||||
|
<span className="sr-only">Ir para última página</span>
|
||||||
|
<DoubleArrowRightIcon className="h-4 w-4" />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
45
components/data-table-row-actions.tsx
Normal file
45
components/data-table-row-actions.tsx
Normal file
@@ -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<typeof productSchema>
|
||||||
|
|
||||||
|
interface DataTableRowActionsProps<TData> {
|
||||||
|
row: Row<TData>
|
||||||
|
}
|
||||||
|
|
||||||
|
export function DataTableRowActions<TData>({ row }: DataTableRowActionsProps<TData>) {
|
||||||
|
const product = productSchema.parse(row.original)
|
||||||
|
|
||||||
|
return (
|
||||||
|
<DropdownMenu>
|
||||||
|
<DropdownMenuTrigger asChild>
|
||||||
|
<Button variant="ghost" className="flex h-8 w-8 p-0 data-[state=open]:bg-muted">
|
||||||
|
<DotsHorizontalIcon className="h-4 w-4" />
|
||||||
|
<span className="sr-only">Abrir menu</span>
|
||||||
|
</Button>
|
||||||
|
</DropdownMenuTrigger>
|
||||||
|
<DropdownMenuContent align="end" className="w-[160px]">
|
||||||
|
<DropdownMenuItem>Editar</DropdownMenuItem>
|
||||||
|
<DropdownMenuItem>Duplicar</DropdownMenuItem>
|
||||||
|
<DropdownMenuSeparator />
|
||||||
|
<DropdownMenuItem>Arquivar</DropdownMenuItem>
|
||||||
|
<DropdownMenuSeparator />
|
||||||
|
<DropdownMenuItem>Excluir</DropdownMenuItem>
|
||||||
|
</DropdownMenuContent>
|
||||||
|
</DropdownMenu>
|
||||||
|
)
|
||||||
|
}
|
||||||
48
components/data-table-toolbar.tsx
Normal file
48
components/data-table-toolbar.tsx
Normal file
@@ -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<TData> {
|
||||||
|
table: Table<TData>
|
||||||
|
}
|
||||||
|
|
||||||
|
export function DataTableToolbar<TData>({ table }: DataTableToolbarProps<TData>) {
|
||||||
|
const isFiltered = table.getState().columnFilters.length > 0
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<div className="flex flex-1 items-center space-x-2">
|
||||||
|
<Input
|
||||||
|
placeholder="Filtrar produtos..."
|
||||||
|
value={(table.getColumn("name")?.getFilterValue() as string) ?? ""}
|
||||||
|
onChange={(event) => table.getColumn("name")?.setFilterValue(event.target.value)}
|
||||||
|
className="h-8 w-[150px] lg:w-[250px]"
|
||||||
|
/>
|
||||||
|
{table.getColumn("tipo") && (
|
||||||
|
<DataTableFacetedFilter
|
||||||
|
column={table.getColumn("tipo")}
|
||||||
|
title="Tipo"
|
||||||
|
options={[
|
||||||
|
{ label: "e-CNPJ", value: "e-CNPJ" },
|
||||||
|
{ label: "e-CPF", value: "e-CPF" },
|
||||||
|
{ label: "NF-e", value: "NF-e" },
|
||||||
|
]}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
{isFiltered && (
|
||||||
|
<Button variant="ghost" onClick={() => table.resetColumnFilters()} className="h-8 px-2 lg:px-3">
|
||||||
|
Limpar
|
||||||
|
<Cross2Icon className="ml-2 h-4 w-4" />
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<DataTableViewOptions table={table} />
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
50
components/data-table-view-options.tsx
Normal file
50
components/data-table-view-options.tsx
Normal file
@@ -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<TData> {
|
||||||
|
table: Table<TData>
|
||||||
|
}
|
||||||
|
|
||||||
|
export function DataTableViewOptions<TData>({ table }: DataTableViewOptionsProps<TData>) {
|
||||||
|
return (
|
||||||
|
<DropdownMenu>
|
||||||
|
<DropdownMenuTrigger asChild>
|
||||||
|
<Button variant="outline" size="sm" className="ml-auto hidden h-8 lg:flex">
|
||||||
|
<MixerHorizontalIcon className="mr-2 h-4 w-4" />
|
||||||
|
Visualização
|
||||||
|
</Button>
|
||||||
|
</DropdownMenuTrigger>
|
||||||
|
<DropdownMenuContent align="end" className="w-[150px]">
|
||||||
|
<DropdownMenuLabel>Alternar colunas</DropdownMenuLabel>
|
||||||
|
<DropdownMenuSeparator />
|
||||||
|
{table
|
||||||
|
.getAllColumns()
|
||||||
|
.filter((column) => typeof column.accessorFn !== "undefined" && column.getCanHide())
|
||||||
|
.map((column) => {
|
||||||
|
return (
|
||||||
|
<DropdownMenuCheckboxItem
|
||||||
|
key={column.id}
|
||||||
|
className="capitalize"
|
||||||
|
checked={column.getIsVisible()}
|
||||||
|
onCheckedChange={(value) => column.toggleVisibility(!!value)}
|
||||||
|
>
|
||||||
|
{column.id}
|
||||||
|
</DropdownMenuCheckboxItem>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</DropdownMenuContent>
|
||||||
|
</DropdownMenu>
|
||||||
|
)
|
||||||
|
}
|
||||||
97
components/data-table.tsx
Normal file
97
components/data-table.tsx
Normal file
@@ -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<TData, TValue> {
|
||||||
|
columns: ColumnDef<TData, TValue>[]
|
||||||
|
data: TData[]
|
||||||
|
}
|
||||||
|
|
||||||
|
export function DataTable<TData, TValue>({ columns, data }: DataTableProps<TData, TValue>) {
|
||||||
|
const [rowSelection, setRowSelection] = React.useState({})
|
||||||
|
const [columnVisibility, setColumnVisibility] = React.useState<VisibilityState>({})
|
||||||
|
const [columnFilters, setColumnFilters] = React.useState<ColumnFiltersState>([])
|
||||||
|
const [sorting, setSorting] = React.useState<SortingState>([])
|
||||||
|
|
||||||
|
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 (
|
||||||
|
<div className="space-y-4">
|
||||||
|
<DataTableToolbar table={table} />
|
||||||
|
<div className="rounded-md border">
|
||||||
|
<Table>
|
||||||
|
<TableHeader>
|
||||||
|
{table.getHeaderGroups().map((headerGroup) => (
|
||||||
|
<TableRow key={headerGroup.id}>
|
||||||
|
{headerGroup.headers.map((header) => {
|
||||||
|
return (
|
||||||
|
<TableHead key={header.id} colSpan={header.colSpan}>
|
||||||
|
{header.isPlaceholder ? null : flexRender(header.column.columnDef.header, header.getContext())}
|
||||||
|
</TableHead>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</TableRow>
|
||||||
|
))}
|
||||||
|
</TableHeader>
|
||||||
|
<TableBody>
|
||||||
|
{table.getRowModel().rows?.length ? (
|
||||||
|
table.getRowModel().rows.map((row) => (
|
||||||
|
<TableRow key={row.id} data-state={row.getIsSelected() && "selected"}>
|
||||||
|
{row.getVisibleCells().map((cell) => (
|
||||||
|
<TableCell key={cell.id}>{flexRender(cell.column.columnDef.cell, cell.getContext())}</TableCell>
|
||||||
|
))}
|
||||||
|
</TableRow>
|
||||||
|
))
|
||||||
|
) : (
|
||||||
|
<TableRow>
|
||||||
|
<TableCell colSpan={columns.length} className="h-24 text-center">
|
||||||
|
Nenhum resultado.
|
||||||
|
</TableCell>
|
||||||
|
</TableRow>
|
||||||
|
)}
|
||||||
|
</TableBody>
|
||||||
|
</Table>
|
||||||
|
</div>
|
||||||
|
<DataTablePagination table={table} />
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
60
components/icons.tsx
Normal file
60
components/icons.tsx
Normal file
@@ -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,
|
||||||
|
}
|
||||||
32
components/overview.tsx
Normal file
32
components/overview.tsx
Normal file
@@ -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 (
|
||||||
|
<ResponsiveContainer width="100%" height={350}>
|
||||||
|
<BarChart data={chartData}>
|
||||||
|
<XAxis dataKey="name" stroke="#888888" fontSize={12} tickLine={false} axisLine={false} />
|
||||||
|
<YAxis
|
||||||
|
stroke="#888888"
|
||||||
|
fontSize={12}
|
||||||
|
tickLine={false}
|
||||||
|
axisLine={false}
|
||||||
|
tickFormatter={(value) => `R$${value}`}
|
||||||
|
/>
|
||||||
|
<Bar dataKey="total" fill="#adfa1d" radius={[4, 4, 0, 0]} />
|
||||||
|
</BarChart>
|
||||||
|
</ResponsiveContainer>
|
||||||
|
)
|
||||||
|
}
|
||||||
226
components/pedido-detail-modal.tsx
Normal file
226
components/pedido-detail-modal.tsx
Normal file
@@ -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<string, string> = {
|
||||||
|
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<string, string> = {
|
||||||
|
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 (
|
||||||
|
<Dialog open={open} onOpenChange={onClose}>
|
||||||
|
<DialogContent className="max-w-lg max-h-[90vh] overflow-y-auto">
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle className="flex items-center justify-between gap-2 pr-6">
|
||||||
|
<span>Pedido <span className="font-mono text-sm">{pedido.id.slice(0, 8).toUpperCase()}</span></span>
|
||||||
|
<span className={`px-2 py-0.5 rounded-full text-xs font-medium ${statusColors[pedido.status] ?? ""}`}>
|
||||||
|
{statusLabel[pedido.status] ?? pedido.status}
|
||||||
|
</span>
|
||||||
|
</DialogTitle>
|
||||||
|
</DialogHeader>
|
||||||
|
|
||||||
|
<div className="space-y-5 mt-2">
|
||||||
|
{/* Cliente */}
|
||||||
|
<Section title="Cliente">
|
||||||
|
<Row label="Nome" value={pedido.clientes?.nome} />
|
||||||
|
<Row label="E-mail" value={pedido.clientes?.email} />
|
||||||
|
<Row label="Telefone" value={pedido.clientes?.telefone} />
|
||||||
|
<Row label="CPF/CNPJ" value={pedido.clientes?.cpf_cnpj} />
|
||||||
|
</Section>
|
||||||
|
|
||||||
|
{/* Produto */}
|
||||||
|
<Section title="Produto">
|
||||||
|
<Row label="Nome" value={pedido.produtos?.nome} />
|
||||||
|
<Row label="Tipo" value={pedido.produtos?.tipo} />
|
||||||
|
<Row label="Validade" value={pedido.produtos?.validade} />
|
||||||
|
<Row label="Mídia" value={pedido.produtos?.midia} />
|
||||||
|
</Section>
|
||||||
|
|
||||||
|
{/* Pagamento */}
|
||||||
|
<Section title="Pagamento">
|
||||||
|
<Row label="Valor" value={fmt(pedido.valor_centavos)} highlight />
|
||||||
|
<Row label="Método" value={pedido.metodo_pagamento} />
|
||||||
|
<Row label="ASAAS ID" value={pedido.asaas_payment_id} mono />
|
||||||
|
<Row
|
||||||
|
label="Criado em"
|
||||||
|
value={new Date(pedido.created_at).toLocaleString("pt-BR")}
|
||||||
|
/>
|
||||||
|
{pedido.paid_at && (
|
||||||
|
<Row
|
||||||
|
label="Pago em"
|
||||||
|
value={new Date(pedido.paid_at).toLocaleString("pt-BR")}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</Section>
|
||||||
|
|
||||||
|
{/* Ações */}
|
||||||
|
<div className="flex flex-wrap gap-2 pt-1">
|
||||||
|
<Button
|
||||||
|
size="sm"
|
||||||
|
variant="outline"
|
||||||
|
onClick={syncStatus}
|
||||||
|
disabled={syncing}
|
||||||
|
>
|
||||||
|
<RefreshCw className={`mr-2 h-3.5 w-3.5 ${syncing ? "animate-spin" : ""}`} />
|
||||||
|
Sync ASAAS
|
||||||
|
</Button>
|
||||||
|
|
||||||
|
{pedido.asaas_invoice_url && (
|
||||||
|
<Button
|
||||||
|
size="sm"
|
||||||
|
variant="outline"
|
||||||
|
onClick={() => window.open(pedido.asaas_invoice_url!, "_blank")}
|
||||||
|
>
|
||||||
|
<ExternalLink className="mr-2 h-3.5 w-3.5" />
|
||||||
|
Ver boleto
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{pedido.pix_copia_cola && (
|
||||||
|
<Button
|
||||||
|
size="sm"
|
||||||
|
variant="outline"
|
||||||
|
onClick={() => copiar(pedido.pix_copia_cola!, "PIX copia-e-cola")}
|
||||||
|
>
|
||||||
|
<Copy className="mr-2 h-3.5 w-3.5" />
|
||||||
|
Copiar PIX
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{pedido.asaas_payment_id && (
|
||||||
|
<Button
|
||||||
|
size="sm"
|
||||||
|
variant="outline"
|
||||||
|
onClick={() =>
|
||||||
|
window.open(
|
||||||
|
`https://www.asaas.com/index?tab=payment&id=${pedido.asaas_payment_id}`,
|
||||||
|
"_blank"
|
||||||
|
)
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<ExternalLink className="mr-2 h-3.5 w-3.5" />
|
||||||
|
Ver no ASAAS
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function Section({ title, children }: { title: string; children: React.ReactNode }) {
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<p className="text-xs font-semibold text-muted-foreground uppercase tracking-wider mb-2">
|
||||||
|
{title}
|
||||||
|
</p>
|
||||||
|
<div className="rounded-lg border divide-y text-sm">{children}</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function Row({
|
||||||
|
label,
|
||||||
|
value,
|
||||||
|
mono,
|
||||||
|
highlight,
|
||||||
|
}: {
|
||||||
|
label: string
|
||||||
|
value?: string | null
|
||||||
|
mono?: boolean
|
||||||
|
highlight?: boolean
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<div className="flex justify-between items-center px-3 py-2 gap-4">
|
||||||
|
<span className="text-muted-foreground shrink-0">{label}</span>
|
||||||
|
<span
|
||||||
|
className={`text-right truncate ${mono ? "font-mono text-xs" : ""} ${highlight ? "font-semibold text-primary" : ""}`}
|
||||||
|
>
|
||||||
|
{value ?? "—"}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
263
components/pedido-status.tsx
Normal file
263
components/pedido-status.tsx
Normal file
@@ -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 (
|
||||||
|
<Card className="max-w-2xl mx-auto border-green-200 bg-green-50">
|
||||||
|
<CardHeader className="text-center pb-2">
|
||||||
|
<CheckCircle2 className="h-14 w-14 text-green-500 mx-auto mb-3" />
|
||||||
|
<CardTitle className="text-2xl text-green-800">Pagamento confirmado!</CardTitle>
|
||||||
|
<p className="text-green-700 text-sm mt-1">
|
||||||
|
{pedido.clientes?.nome}, seu certificado está sendo processado.
|
||||||
|
</p>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="space-y-5">
|
||||||
|
<ResumoCompra pedido={pedido} valorFormatado={valorFormatado} />
|
||||||
|
|
||||||
|
<div className="bg-white border border-green-200 rounded-xl p-5 text-center space-y-3">
|
||||||
|
<Calendar className="h-8 w-8 text-primary mx-auto" />
|
||||||
|
<p className="font-semibold text-lg">Próximo passo: agende sua validação</p>
|
||||||
|
<p className="text-sm text-muted-foreground">
|
||||||
|
Para emitir o certificado, é obrigatório realizar uma videoconferência de
|
||||||
|
Clique no botão abaixo para continuar para o próximo passo.
|
||||||
|
</p>
|
||||||
|
<Button
|
||||||
|
size="lg"
|
||||||
|
className="w-full mt-2"
|
||||||
|
onClick={() => router.push(LINK_AGENDAMENTO)}
|
||||||
|
>
|
||||||
|
<Calendar className="mr-2 h-4 w-4" />
|
||||||
|
Continuar
|
||||||
|
</Button>
|
||||||
|
<p className="text-xs text-muted-foreground">
|
||||||
|
O link também foi enviado para {pedido.clientes?.email}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Cancelado / Vencido ───────────────────────────────────────────────
|
||||||
|
if (isCancelado) {
|
||||||
|
return (
|
||||||
|
<Card className="max-w-2xl mx-auto border-red-200 bg-red-50">
|
||||||
|
<CardHeader className="text-center pb-2">
|
||||||
|
<XCircle className="h-14 w-14 text-red-400 mx-auto mb-3" />
|
||||||
|
<CardTitle className="text-2xl text-red-800">Pagamento não realizado</CardTitle>
|
||||||
|
<p className="text-red-700 text-sm mt-1">
|
||||||
|
Este pedido foi cancelado ou expirou.
|
||||||
|
</p>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="space-y-4">
|
||||||
|
<ResumoCompra pedido={pedido} valorFormatado={valorFormatado} />
|
||||||
|
<Button variant="outline" className="w-full" onClick={() => router.push("/comprar")}>
|
||||||
|
Fazer novo pedido
|
||||||
|
</Button>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── PIX ───────────────────────────────────────────────────────────────
|
||||||
|
if (pedido.metodo_pagamento === "PIX") {
|
||||||
|
return (
|
||||||
|
<Card className="max-w-2xl mx-auto">
|
||||||
|
<CardHeader className="text-center pb-2">
|
||||||
|
<Clock className="h-10 w-10 text-yellow-500 mx-auto mb-2" />
|
||||||
|
<CardTitle className="text-xl">Aguardando pagamento PIX</CardTitle>
|
||||||
|
<p className="text-sm text-muted-foreground flex items-center justify-center gap-1">
|
||||||
|
<Loader2 className="h-3 w-3 animate-spin" />
|
||||||
|
Confirmação automática em segundos após o pagamento
|
||||||
|
</p>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="space-y-5">
|
||||||
|
<ResumoCompra pedido={pedido} valorFormatado={valorFormatado} />
|
||||||
|
|
||||||
|
{pedido.pix_qrcode_url && (
|
||||||
|
<div className="flex justify-center">
|
||||||
|
<img
|
||||||
|
src={`data:image/png;base64,${pedido.pix_qrcode_url}`}
|
||||||
|
alt="QR Code PIX"
|
||||||
|
className="w-52 h-52 rounded-xl border"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{pedido.pix_copia_cola && (
|
||||||
|
<div className="space-y-2">
|
||||||
|
<p className="text-xs font-medium text-muted-foreground uppercase tracking-wide">
|
||||||
|
PIX Copia e Cola
|
||||||
|
</p>
|
||||||
|
<div className="bg-muted rounded-lg p-3 text-xs break-all select-all font-mono leading-relaxed">
|
||||||
|
{pedido.pix_copia_cola}
|
||||||
|
</div>
|
||||||
|
<Button variant="outline" className="w-full" onClick={copiarPix}>
|
||||||
|
<Copy className="mr-2 h-4 w-4" />
|
||||||
|
Copiar código PIX
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<p className="text-xs text-center text-muted-foreground">
|
||||||
|
Valor: <strong>{valorFormatado}</strong>
|
||||||
|
</p>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── BOLETO ────────────────────────────────────────────────────────────
|
||||||
|
if (pedido.metodo_pagamento === "BOLETO") {
|
||||||
|
return (
|
||||||
|
<Card className="max-w-2xl mx-auto">
|
||||||
|
<CardHeader className="text-center pb-2">
|
||||||
|
<Clock className="h-10 w-10 text-yellow-500 mx-auto mb-2" />
|
||||||
|
<CardTitle className="text-xl">Boleto gerado</CardTitle>
|
||||||
|
<p className="text-sm text-muted-foreground">
|
||||||
|
Após o pagamento, a confirmação pode levar até 2 dias úteis.
|
||||||
|
</p>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="space-y-5">
|
||||||
|
<ResumoCompra pedido={pedido} valorFormatado={valorFormatado} />
|
||||||
|
|
||||||
|
{pedido.asaas_invoice_url && (
|
||||||
|
<Button
|
||||||
|
className="w-full"
|
||||||
|
onClick={() => window.open(pedido.asaas_invoice_url!, "_blank")}
|
||||||
|
>
|
||||||
|
<ExternalLink className="mr-2 h-4 w-4" />
|
||||||
|
Abrir boleto
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<p className="text-xs text-center text-muted-foreground">
|
||||||
|
O link do boleto também foi enviado para{" "}
|
||||||
|
<strong>{pedido.clientes?.email}</strong>
|
||||||
|
</p>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── CARTÃO (pendente/processando) ────────────────────────────────────
|
||||||
|
return (
|
||||||
|
<Card className="max-w-2xl mx-auto">
|
||||||
|
<CardHeader className="text-center pb-2">
|
||||||
|
<Loader2 className="h-10 w-10 text-blue-500 mx-auto mb-2 animate-spin" />
|
||||||
|
<CardTitle className="text-xl">Processando pagamento</CardTitle>
|
||||||
|
<p className="text-sm text-muted-foreground">Aguarde enquanto confirmamos seu cartão.</p>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<ResumoCompra pedido={pedido} valorFormatado={valorFormatado} />
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function ResumoCompra({
|
||||||
|
pedido,
|
||||||
|
valorFormatado,
|
||||||
|
}: {
|
||||||
|
pedido: Pedido
|
||||||
|
valorFormatado: string
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<div className="bg-muted rounded-xl p-4 space-y-1 text-sm">
|
||||||
|
<div className="flex justify-between">
|
||||||
|
<span className="text-muted-foreground">Produto</span>
|
||||||
|
<span className="font-medium">{pedido.produtos?.nome ?? "—"}</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex justify-between">
|
||||||
|
<span className="text-muted-foreground">Validade</span>
|
||||||
|
<span>{pedido.produtos?.validade ?? "—"}</span>
|
||||||
|
</div>
|
||||||
|
{pedido.produtos?.midia && (
|
||||||
|
<div className="flex justify-between">
|
||||||
|
<span className="text-muted-foreground">Mídia</span>
|
||||||
|
<span>{pedido.produtos.midia}</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<div className="flex justify-between">
|
||||||
|
<span className="text-muted-foreground">Valor</span>
|
||||||
|
<span className="font-semibold text-primary">{valorFormatado}</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex justify-between">
|
||||||
|
<span className="text-muted-foreground">Pedido</span>
|
||||||
|
<span className="font-mono text-xs">{pedido.id.slice(0, 8).toUpperCase()}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
122
components/product-buy-form.tsx
Normal file
122
components/product-buy-form.tsx
Normal file
@@ -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<Metodo>("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 (
|
||||||
|
<form onSubmit={handleSubmit} className="space-y-4">
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label htmlFor="nome">Nome completo</Label>
|
||||||
|
<Input
|
||||||
|
id="nome"
|
||||||
|
required
|
||||||
|
value={form.nome}
|
||||||
|
onChange={(e) => setForm({ ...form, nome: e.target.value })}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label htmlFor="email">E-mail</Label>
|
||||||
|
<Input
|
||||||
|
id="email"
|
||||||
|
type="email"
|
||||||
|
required
|
||||||
|
value={form.email}
|
||||||
|
onChange={(e) => setForm({ ...form, email: e.target.value })}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label htmlFor="cpfCnpj">CPF / CNPJ</Label>
|
||||||
|
<Input
|
||||||
|
id="cpfCnpj"
|
||||||
|
required
|
||||||
|
placeholder="000.000.000-00"
|
||||||
|
value={form.cpfCnpj}
|
||||||
|
onChange={(e) => setForm({ ...form, cpfCnpj: e.target.value })}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label htmlFor="telefone">Telefone</Label>
|
||||||
|
<Input
|
||||||
|
id="telefone"
|
||||||
|
placeholder="(11) 99999-9999"
|
||||||
|
value={form.telefone}
|
||||||
|
onChange={(e) => setForm({ ...form, telefone: e.target.value })}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label>Forma de pagamento</Label>
|
||||||
|
<div className="flex gap-2">
|
||||||
|
{(["PIX", "BOLETO", "CREDIT_CARD"] as Metodo[]).map((m) => (
|
||||||
|
<button
|
||||||
|
key={m}
|
||||||
|
type="button"
|
||||||
|
onClick={() => setMetodo(m)}
|
||||||
|
className={`flex-1 py-2 rounded-lg border text-sm font-medium transition-colors ${
|
||||||
|
metodo === m
|
||||||
|
? "border-primary bg-primary text-white"
|
||||||
|
: "border-gray-300 hover:border-primary"
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{m === "PIX" ? "PIX" : m === "BOLETO" ? "Boleto" : "Cartão"}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Button type="submit" className="w-full" disabled={isLoading}>
|
||||||
|
{isLoading ? "Processando..." : `Pagar com ${metodo === "PIX" ? "PIX" : metodo === "BOLETO" ? "Boleto" : "Cartão"}`}
|
||||||
|
</Button>
|
||||||
|
</form>
|
||||||
|
)
|
||||||
|
}
|
||||||
171
components/product-form-dialog.tsx
Normal file
171
components/product-form-dialog.tsx
Normal file
@@ -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<HTMLFormElement>) => {
|
||||||
|
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<HTMLInputElement | HTMLTextAreaElement | HTMLSelectElement>) => {
|
||||||
|
const { name, value } = e.target
|
||||||
|
setFormData((prev) => ({ ...prev, [name]: value }))
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Dialog open={open} onOpenChange={setOpen}>
|
||||||
|
<DialogTrigger asChild>
|
||||||
|
<Button onClick={() => setOpen(true)}>{product ? "Editar Produto" : "Novo Produto"}</Button>
|
||||||
|
</DialogTrigger>
|
||||||
|
<DialogContent className="sm:max-w-[425px]">
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle>{product ? "Editar Produto" : "Criar Novo Produto"}</DialogTitle>
|
||||||
|
<DialogDescription>Preencha os dados do certificado.</DialogDescription>
|
||||||
|
</DialogHeader>
|
||||||
|
<form onSubmit={handleSubmit} className="space-y-4">
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label htmlFor="nome">Nome</Label>
|
||||||
|
<Input id="nome" name="nome" value={formData.nome} onChange={handleChange} required />
|
||||||
|
</div>
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label htmlFor="descricao">Descrição</Label>
|
||||||
|
<Textarea id="descricao" name="descricao" value={formData.descricao} onChange={handleChange} />
|
||||||
|
</div>
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label htmlFor="price">Preço</Label>
|
||||||
|
<Input
|
||||||
|
id="price"
|
||||||
|
name="price"
|
||||||
|
value={formData.price}
|
||||||
|
onChange={(e) => setFormData((prev) => ({ ...prev, price: formatCurrency(e.target.value) }))}
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label htmlFor="tipo">Tipo</Label>
|
||||||
|
<select
|
||||||
|
id="tipo"
|
||||||
|
name="tipo"
|
||||||
|
value={formData.tipo}
|
||||||
|
onChange={handleChange}
|
||||||
|
className="w-full rounded-md border border-input bg-background px-3 py-2 text-sm"
|
||||||
|
required
|
||||||
|
>
|
||||||
|
<option value="PF">PF — Pessoa Física</option>
|
||||||
|
<option value="PJ">PJ — Pessoa Jurídica</option>
|
||||||
|
<option value="SSL">SSL</option>
|
||||||
|
<option value="NFe">NFe</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label htmlFor="validade">Validade</Label>
|
||||||
|
<Input id="validade" name="validade" placeholder="ex: 1 ano" value={formData.validade} onChange={handleChange} required />
|
||||||
|
</div>
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label htmlFor="midia">Mídia</Label>
|
||||||
|
<select
|
||||||
|
id="midia"
|
||||||
|
name="midia"
|
||||||
|
value={formData.midia}
|
||||||
|
onChange={handleChange}
|
||||||
|
className="w-full rounded-md border border-input bg-background px-3 py-2 text-sm"
|
||||||
|
>
|
||||||
|
<option value="">Selecione...</option>
|
||||||
|
<option value="Token">Token</option>
|
||||||
|
<option value="Cartão">Cartão</option>
|
||||||
|
<option value="Nuvem">Nuvem</option>
|
||||||
|
<option value="Sem mídia">Sem mídia</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div className="flex justify-end gap-2">
|
||||||
|
<Button type="button" variant="outline" onClick={() => setOpen(false)}>Cancelar</Button>
|
||||||
|
<Button type="submit">{product ? "Salvar" : "Criar"}</Button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
)
|
||||||
|
}
|
||||||
139
components/product-table.tsx
Normal file
139
components/product-table.tsx
Normal file
@@ -0,0 +1,139 @@
|
|||||||
|
"use client"
|
||||||
|
|
||||||
|
import { useState } from "react"
|
||||||
|
import Link from "next/link"
|
||||||
|
import { useRouter } from "next/navigation"
|
||||||
|
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table"
|
||||||
|
import { Button } from "@/components/ui/button"
|
||||||
|
import { Badge } from "@/components/ui/badge"
|
||||||
|
import {
|
||||||
|
Dialog,
|
||||||
|
DialogContent,
|
||||||
|
DialogHeader,
|
||||||
|
DialogTitle,
|
||||||
|
DialogDescription,
|
||||||
|
DialogFooter,
|
||||||
|
} from "@/components/ui/dialog"
|
||||||
|
import { deleteProduto } from "@/lib/actions"
|
||||||
|
import { toast } from "@/components/ui/use-toast"
|
||||||
|
import type { Produto } from "@/lib/supabase"
|
||||||
|
|
||||||
|
interface ProductTableProps {
|
||||||
|
products: Produto[]
|
||||||
|
}
|
||||||
|
|
||||||
|
export function ProductTable({ products }: ProductTableProps) {
|
||||||
|
const router = useRouter()
|
||||||
|
const [selectedProduct, setSelectedProduct] = useState<Produto | null>(null)
|
||||||
|
const [isDeleteDialogOpen, setIsDeleteDialogOpen] = useState(false)
|
||||||
|
|
||||||
|
const handleDelete = async () => {
|
||||||
|
if (!selectedProduct) return
|
||||||
|
const result = await deleteProduto(selectedProduct.id)
|
||||||
|
if (result.success) {
|
||||||
|
toast({ title: "Produto excluído com sucesso." })
|
||||||
|
setIsDeleteDialogOpen(false)
|
||||||
|
setSelectedProduct(null)
|
||||||
|
router.refresh()
|
||||||
|
} else {
|
||||||
|
toast({ title: "Erro ao excluir produto.", variant: "destructive" })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const precoFormatado = (centavos: number) =>
|
||||||
|
(centavos / 100).toLocaleString("pt-BR", { style: "currency", currency: "BRL" })
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<div className="rounded-md border">
|
||||||
|
<Table>
|
||||||
|
<TableHeader>
|
||||||
|
<TableRow>
|
||||||
|
<TableHead>Nome</TableHead>
|
||||||
|
<TableHead>Tipo</TableHead>
|
||||||
|
<TableHead>Validade</TableHead>
|
||||||
|
<TableHead>Mídia</TableHead>
|
||||||
|
<TableHead>Preço</TableHead>
|
||||||
|
<TableHead>Status</TableHead>
|
||||||
|
<TableHead></TableHead>
|
||||||
|
</TableRow>
|
||||||
|
</TableHeader>
|
||||||
|
<TableBody>
|
||||||
|
{products.length === 0 ? (
|
||||||
|
<TableRow>
|
||||||
|
<TableCell colSpan={7} className="h-24 text-center text-muted-foreground">
|
||||||
|
Nenhum produto cadastrado.
|
||||||
|
</TableCell>
|
||||||
|
</TableRow>
|
||||||
|
) : (
|
||||||
|
products.map((product) => (
|
||||||
|
<TableRow key={product.id} className="cursor-pointer hover:bg-muted/50" onClick={() => setSelectedProduct(product)}>
|
||||||
|
<TableCell className="font-medium">{product.nome}</TableCell>
|
||||||
|
<TableCell><Badge variant="outline">{product.tipo}</Badge></TableCell>
|
||||||
|
<TableCell>{product.validade}</TableCell>
|
||||||
|
<TableCell>{product.midia ?? "—"}</TableCell>
|
||||||
|
<TableCell>{precoFormatado(product.preco_centavos)}</TableCell>
|
||||||
|
<TableCell>
|
||||||
|
<Badge variant={product.ativo ? "default" : "secondary"}>
|
||||||
|
{product.ativo ? "Ativo" : "Inativo"}
|
||||||
|
</Badge>
|
||||||
|
</TableCell>
|
||||||
|
<TableCell>
|
||||||
|
<Button variant="ghost" size="sm" onClick={(e) => { e.stopPropagation(); setSelectedProduct(product) }}>
|
||||||
|
Detalhes
|
||||||
|
</Button>
|
||||||
|
</TableCell>
|
||||||
|
</TableRow>
|
||||||
|
))
|
||||||
|
)}
|
||||||
|
</TableBody>
|
||||||
|
</Table>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Modal de detalhes */}
|
||||||
|
<Dialog open={!!selectedProduct && !isDeleteDialogOpen} onOpenChange={(open) => !open && setSelectedProduct(null)}>
|
||||||
|
<DialogContent className="sm:max-w-[600px]">
|
||||||
|
{selectedProduct && (
|
||||||
|
<>
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle>{selectedProduct.nome}</DialogTitle>
|
||||||
|
<DialogDescription>{selectedProduct.descricao ?? "Sem descrição"}</DialogDescription>
|
||||||
|
</DialogHeader>
|
||||||
|
<div className="grid grid-cols-2 gap-4 mt-4 text-sm">
|
||||||
|
<div><span className="font-medium">Tipo:</span> {selectedProduct.tipo}</div>
|
||||||
|
<div><span className="font-medium">Validade:</span> {selectedProduct.validade}</div>
|
||||||
|
<div><span className="font-medium">Mídia:</span> {selectedProduct.midia ?? "—"}</div>
|
||||||
|
<div><span className="font-medium">Preço:</span> {precoFormatado(selectedProduct.preco_centavos)}</div>
|
||||||
|
<div><span className="font-medium">Status:</span> {selectedProduct.ativo ? "Ativo" : "Inativo"}</div>
|
||||||
|
</div>
|
||||||
|
<div className="flex justify-end gap-2 mt-6">
|
||||||
|
<Button asChild variant="outline">
|
||||||
|
<Link href={`/produtos/${selectedProduct.id}`}>Ver página</Link>
|
||||||
|
</Button>
|
||||||
|
<Button variant="destructive" onClick={() => setIsDeleteDialogOpen(true)}>
|
||||||
|
Excluir
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
|
||||||
|
{/* Confirm delete */}
|
||||||
|
<Dialog open={isDeleteDialogOpen} onOpenChange={setIsDeleteDialogOpen}>
|
||||||
|
<DialogContent>
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle>Confirmar Exclusão</DialogTitle>
|
||||||
|
<DialogDescription>
|
||||||
|
Tem certeza que deseja excluir "{selectedProduct?.nome}"? Esta ação não pode ser desfeita.
|
||||||
|
</DialogDescription>
|
||||||
|
</DialogHeader>
|
||||||
|
<DialogFooter>
|
||||||
|
<Button variant="outline" onClick={() => setIsDeleteDialogOpen(false)}>Cancelar</Button>
|
||||||
|
<Button variant="destructive" onClick={handleDelete}>Excluir</Button>
|
||||||
|
</DialogFooter>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
</>
|
||||||
|
)
|
||||||
|
}
|
||||||
67
components/products-table-skeleton.tsx
Normal file
67
components/products-table-skeleton.tsx
Normal file
@@ -0,0 +1,67 @@
|
|||||||
|
import { Skeleton } from "@/components/ui/skeleton"
|
||||||
|
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table"
|
||||||
|
|
||||||
|
export function ProductsTableSkeleton() {
|
||||||
|
return (
|
||||||
|
<div className="w-full">
|
||||||
|
<div className="flex items-center py-4">
|
||||||
|
<Skeleton className="h-8 w-[250px]" />
|
||||||
|
<Skeleton className="ml-auto h-8 w-[100px]" />
|
||||||
|
</div>
|
||||||
|
<div className="rounded-md border">
|
||||||
|
<Table>
|
||||||
|
<TableHeader>
|
||||||
|
<TableRow>
|
||||||
|
<TableHead className="w-[50px]">
|
||||||
|
<Skeleton className="h-4 w-4" />
|
||||||
|
</TableHead>
|
||||||
|
<TableHead>
|
||||||
|
<Skeleton className="h-4 w-[100px]" />
|
||||||
|
</TableHead>
|
||||||
|
<TableHead>
|
||||||
|
<Skeleton className="h-4 w-[100px]" />
|
||||||
|
</TableHead>
|
||||||
|
<TableHead>
|
||||||
|
<Skeleton className="h-4 w-[100px]" />
|
||||||
|
</TableHead>
|
||||||
|
<TableHead>
|
||||||
|
<Skeleton className="h-4 w-[100px]" />
|
||||||
|
</TableHead>
|
||||||
|
<TableHead className="text-right">
|
||||||
|
<Skeleton className="h-4 w-[100px] ml-auto" />
|
||||||
|
</TableHead>
|
||||||
|
</TableRow>
|
||||||
|
</TableHeader>
|
||||||
|
<TableBody>
|
||||||
|
{Array.from({ length: 5 }).map((_, i) => (
|
||||||
|
<TableRow key={i}>
|
||||||
|
<TableCell>
|
||||||
|
<Skeleton className="h-4 w-4" />
|
||||||
|
</TableCell>
|
||||||
|
<TableCell>
|
||||||
|
<Skeleton className="h-4 w-[200px]" />
|
||||||
|
</TableCell>
|
||||||
|
<TableCell>
|
||||||
|
<Skeleton className="h-4 w-[150px]" />
|
||||||
|
</TableCell>
|
||||||
|
<TableCell>
|
||||||
|
<Skeleton className="h-4 w-[100px]" />
|
||||||
|
</TableCell>
|
||||||
|
<TableCell>
|
||||||
|
<Skeleton className="h-4 w-[100px]" />
|
||||||
|
</TableCell>
|
||||||
|
<TableCell className="text-right">
|
||||||
|
<Skeleton className="h-8 w-8 ml-auto" />
|
||||||
|
</TableCell>
|
||||||
|
</TableRow>
|
||||||
|
))}
|
||||||
|
</TableBody>
|
||||||
|
</Table>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center justify-end space-x-2 py-4">
|
||||||
|
<Skeleton className="h-8 w-[100px]" />
|
||||||
|
<Skeleton className="h-8 w-[70px]" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
60
components/recent-sales.tsx
Normal file
60
components/recent-sales.tsx
Normal file
@@ -0,0 +1,60 @@
|
|||||||
|
import { Avatar, AvatarFallback } from "@/components/ui/avatar"
|
||||||
|
|
||||||
|
interface VendaRecente {
|
||||||
|
id: string
|
||||||
|
valor_centavos: number
|
||||||
|
created_at: string
|
||||||
|
metodo_pagamento: string | null
|
||||||
|
clientes: { nome: string; email: string } | null
|
||||||
|
produtos: { nome: string } | null
|
||||||
|
}
|
||||||
|
|
||||||
|
export function RecentSales({ vendas }: { vendas: VendaRecente[] }) {
|
||||||
|
if (vendas.length === 0) {
|
||||||
|
return (
|
||||||
|
<p className="text-sm text-muted-foreground text-center py-4">
|
||||||
|
Nenhuma venda confirmada ainda.
|
||||||
|
</p>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-6">
|
||||||
|
{vendas.map((v) => {
|
||||||
|
const initials = (v.clientes?.nome ?? "?")
|
||||||
|
.split(" ")
|
||||||
|
.slice(0, 2)
|
||||||
|
.map((n) => n[0])
|
||||||
|
.join("")
|
||||||
|
.toUpperCase()
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div key={v.id} className="flex items-center gap-4">
|
||||||
|
<Avatar className="h-9 w-9">
|
||||||
|
<AvatarFallback className="text-xs">{initials}</AvatarFallback>
|
||||||
|
</Avatar>
|
||||||
|
<div className="flex-1 min-w-0">
|
||||||
|
<p className="text-sm font-medium leading-none truncate">
|
||||||
|
{v.clientes?.nome ?? "—"}
|
||||||
|
</p>
|
||||||
|
<p className="text-xs text-muted-foreground truncate mt-0.5">
|
||||||
|
{v.produtos?.nome ?? "—"}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div className="text-right shrink-0">
|
||||||
|
<p className="text-sm font-semibold">
|
||||||
|
{(v.valor_centavos / 100).toLocaleString("pt-BR", {
|
||||||
|
style: "currency",
|
||||||
|
currency: "BRL",
|
||||||
|
})}
|
||||||
|
</p>
|
||||||
|
<p className="text-xs text-muted-foreground">
|
||||||
|
{new Date(v.created_at).toLocaleDateString("pt-BR")}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
69
components/site-header.tsx
Normal file
69
components/site-header.tsx
Normal file
@@ -0,0 +1,69 @@
|
|||||||
|
"use client"
|
||||||
|
|
||||||
|
import { useState } from "react"
|
||||||
|
import Link from "next/link"
|
||||||
|
import { useRouter } from "next/navigation"
|
||||||
|
import { Button } from "@/components/ui/button"
|
||||||
|
import { Sheet, SheetContent, SheetTitle, SheetTrigger } from "@/components/ui/sheet"
|
||||||
|
import { useAuth } from "@/lib/auth-context"
|
||||||
|
import { User, Menu, X, ShoppingBag } from "lucide-react"
|
||||||
|
import { appConfig } from "@/lib/config"
|
||||||
|
|
||||||
|
export function SiteHeader() {
|
||||||
|
const { isLoggedIn } = useAuth()
|
||||||
|
const router = useRouter()
|
||||||
|
const [isMenuOpen, setIsMenuOpen] = useState(false)
|
||||||
|
|
||||||
|
return (
|
||||||
|
<header className="sticky top-0 z-50 w-full bg-white border-b">
|
||||||
|
<div className="container flex h-16 items-center justify-between">
|
||||||
|
<Link href="/" className="flex items-center space-x-2">
|
||||||
|
{appConfig.logoUrl ? (
|
||||||
|
<img src={appConfig.logoUrl} alt={appConfig.name} className="h-8 w-auto" />
|
||||||
|
) : (
|
||||||
|
<span className="text-xl font-bold text-primary">{appConfig.name}</span>
|
||||||
|
)}
|
||||||
|
</Link>
|
||||||
|
|
||||||
|
<nav className="hidden md:flex items-center space-x-6">
|
||||||
|
<Button asChild>
|
||||||
|
<Link href="/comprar">
|
||||||
|
<ShoppingBag className="mr-2 h-4 w-4" />
|
||||||
|
Ver produtos
|
||||||
|
</Link>
|
||||||
|
</Button>
|
||||||
|
</nav>
|
||||||
|
|
||||||
|
<Sheet open={isMenuOpen} onOpenChange={setIsMenuOpen}>
|
||||||
|
<SheetTrigger asChild>
|
||||||
|
<Button variant="ghost" size="icon" className="md:hidden">
|
||||||
|
{isMenuOpen ? <X className="h-5 w-5" /> : <Menu className="h-5 w-5" />}
|
||||||
|
</Button>
|
||||||
|
</SheetTrigger>
|
||||||
|
<SheetContent side="right" className="w-[300px]">
|
||||||
|
<SheetTitle className="sr-only">Menu</SheetTitle>
|
||||||
|
<nav className="flex flex-col space-y-4 mt-4">
|
||||||
|
<Button
|
||||||
|
className="w-full"
|
||||||
|
onClick={() => { setIsMenuOpen(false); router.push("/comprar") }}
|
||||||
|
>
|
||||||
|
<ShoppingBag className="mr-2 h-4 w-4" />
|
||||||
|
Ver produtos
|
||||||
|
</Button>
|
||||||
|
{isLoggedIn && (
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
onClick={() => { setIsMenuOpen(false); router.push("/admin") }}
|
||||||
|
className="w-full"
|
||||||
|
>
|
||||||
|
<User className="h-4 w-4 mr-2" />
|
||||||
|
Dashboard
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</nav>
|
||||||
|
</SheetContent>
|
||||||
|
</Sheet>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
)
|
||||||
|
}
|
||||||
7
components/theme-provider.tsx
Normal file
7
components/theme-provider.tsx
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
"use client"
|
||||||
|
import { ThemeProvider as NextThemesProvider } from "next-themes"
|
||||||
|
import type { ThemeProviderProps } from "next-themes"
|
||||||
|
|
||||||
|
export function ThemeProvider({ children, ...props }: ThemeProviderProps) {
|
||||||
|
return <NextThemesProvider {...props}>{children}</NextThemesProvider>
|
||||||
|
}
|
||||||
197
components/transaction-table.tsx
Normal file
197
components/transaction-table.tsx
Normal file
@@ -0,0 +1,197 @@
|
|||||||
|
"use client"
|
||||||
|
|
||||||
|
import { useState, useMemo } from "react"
|
||||||
|
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table"
|
||||||
|
import { Input } from "@/components/ui/input"
|
||||||
|
import { Button } from "@/components/ui/button"
|
||||||
|
import { PedidoDetailModal } from "@/components/pedido-detail-modal"
|
||||||
|
import { Search, Download } from "lucide-react"
|
||||||
|
import type { Pedido } from "@/lib/supabase"
|
||||||
|
|
||||||
|
const statusColors: Record<string, string> = {
|
||||||
|
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<string, string> = {
|
||||||
|
PENDING: "Pendente",
|
||||||
|
RECEIVED: "Pago",
|
||||||
|
CONFIRMED: "Confirmado",
|
||||||
|
OVERDUE: "Vencido",
|
||||||
|
REFUNDED: "Estornado",
|
||||||
|
CANCELLED: "Cancelado",
|
||||||
|
}
|
||||||
|
|
||||||
|
const ALL_STATUSES = ["PENDING", "RECEIVED", "CONFIRMED", "OVERDUE", "REFUNDED", "CANCELLED"]
|
||||||
|
|
||||||
|
type PedidoRich = Pedido & {
|
||||||
|
clientes?: { nome: string; email: string; telefone?: string | null; cpf_cnpj?: string | null } | null
|
||||||
|
produtos?: { nome: string; tipo: string; validade?: string; midia?: string | null } | null
|
||||||
|
asaas_invoice_url?: string | null
|
||||||
|
pix_copia_cola?: string | null
|
||||||
|
pix_qrcode_url?: string | null
|
||||||
|
paid_at?: string | null
|
||||||
|
}
|
||||||
|
|
||||||
|
export function TransactionTable({ transactions }: { transactions: PedidoRich[] }) {
|
||||||
|
const [busca, setBusca] = useState("")
|
||||||
|
const [filtroStatus, setFiltroStatus] = useState<string | null>(null)
|
||||||
|
const [pedidoSelecionado, setPedidoSelecionado] = useState<PedidoRich | null>(null)
|
||||||
|
|
||||||
|
const fmt = (v: number) =>
|
||||||
|
(v / 100).toLocaleString("pt-BR", { style: "currency", currency: "BRL" })
|
||||||
|
|
||||||
|
const filtrados = useMemo(() => {
|
||||||
|
return transactions.filter((t) => {
|
||||||
|
const matchStatus = !filtroStatus || t.status === filtroStatus
|
||||||
|
const q = busca.toLowerCase()
|
||||||
|
const matchBusca =
|
||||||
|
!q ||
|
||||||
|
t.id.toLowerCase().includes(q) ||
|
||||||
|
(t.clientes?.nome ?? "").toLowerCase().includes(q) ||
|
||||||
|
(t.clientes?.email ?? "").toLowerCase().includes(q) ||
|
||||||
|
(t.produtos?.nome ?? "").toLowerCase().includes(q)
|
||||||
|
return matchStatus && matchBusca
|
||||||
|
})
|
||||||
|
}, [transactions, busca, filtroStatus])
|
||||||
|
|
||||||
|
function exportCSV() {
|
||||||
|
const header = ["ID", "Cliente", "Email", "Produto", "Valor", "Método", "Status", "Data"]
|
||||||
|
const rows = filtrados.map((t) => [
|
||||||
|
t.id,
|
||||||
|
t.clientes?.nome ?? "",
|
||||||
|
t.clientes?.email ?? "",
|
||||||
|
t.produtos?.nome ?? "",
|
||||||
|
(t.valor_centavos / 100).toFixed(2),
|
||||||
|
t.metodo_pagamento ?? "",
|
||||||
|
t.status,
|
||||||
|
new Date(t.created_at).toLocaleDateString("pt-BR"),
|
||||||
|
])
|
||||||
|
const csv = [header, ...rows].map((r) => r.map((c) => `"${c}"`).join(",")).join("\n")
|
||||||
|
const blob = new Blob([csv], { type: "text/csv;charset=utf-8;" })
|
||||||
|
const url = URL.createObjectURL(blob)
|
||||||
|
const a = document.createElement("a")
|
||||||
|
a.href = url
|
||||||
|
a.download = `pedidos-${new Date().toISOString().slice(0, 10)}.csv`
|
||||||
|
a.click()
|
||||||
|
URL.revokeObjectURL(url)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (transactions.length === 0) {
|
||||||
|
return (
|
||||||
|
<div className="rounded-md border p-8 text-center text-muted-foreground">
|
||||||
|
Nenhum pedido encontrado.
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-3">
|
||||||
|
{/* Toolbar */}
|
||||||
|
<div className="flex flex-wrap gap-2 items-center justify-between">
|
||||||
|
<div className="flex gap-2 flex-wrap">
|
||||||
|
<div className="relative">
|
||||||
|
<Search className="absolute left-2.5 top-2.5 h-4 w-4 text-muted-foreground" />
|
||||||
|
<Input
|
||||||
|
placeholder="Buscar cliente, produto, ID..."
|
||||||
|
className="pl-8 h-9 w-64"
|
||||||
|
value={busca}
|
||||||
|
onChange={(e) => setBusca(e.target.value)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="flex gap-1 flex-wrap">
|
||||||
|
<Button
|
||||||
|
size="sm"
|
||||||
|
variant={filtroStatus === null ? "default" : "outline"}
|
||||||
|
className="h-9 text-xs"
|
||||||
|
onClick={() => setFiltroStatus(null)}
|
||||||
|
>
|
||||||
|
Todos
|
||||||
|
</Button>
|
||||||
|
{ALL_STATUSES.map((s) => (
|
||||||
|
<Button
|
||||||
|
key={s}
|
||||||
|
size="sm"
|
||||||
|
variant={filtroStatus === s ? "default" : "outline"}
|
||||||
|
className="h-9 text-xs"
|
||||||
|
onClick={() => setFiltroStatus(filtroStatus === s ? null : s)}
|
||||||
|
>
|
||||||
|
{statusLabel[s]}
|
||||||
|
</Button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<Button size="sm" variant="outline" className="h-9" onClick={exportCSV}>
|
||||||
|
<Download className="mr-2 h-4 w-4" />
|
||||||
|
Exportar CSV
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Contagem */}
|
||||||
|
<p className="text-xs text-muted-foreground">
|
||||||
|
{filtrados.length} pedido{filtrados.length !== 1 ? "s" : ""} encontrado{filtrados.length !== 1 ? "s" : ""}
|
||||||
|
</p>
|
||||||
|
|
||||||
|
{/* Tabela */}
|
||||||
|
<div className="rounded-md border">
|
||||||
|
<Table>
|
||||||
|
<TableHeader>
|
||||||
|
<TableRow>
|
||||||
|
<TableHead>ID</TableHead>
|
||||||
|
<TableHead>Cliente</TableHead>
|
||||||
|
<TableHead>Produto</TableHead>
|
||||||
|
<TableHead>Valor</TableHead>
|
||||||
|
<TableHead>Método</TableHead>
|
||||||
|
<TableHead>Status</TableHead>
|
||||||
|
<TableHead>Data</TableHead>
|
||||||
|
</TableRow>
|
||||||
|
</TableHeader>
|
||||||
|
<TableBody>
|
||||||
|
{filtrados.length === 0 ? (
|
||||||
|
<TableRow>
|
||||||
|
<TableCell colSpan={7} className="text-center text-muted-foreground py-8">
|
||||||
|
Nenhum pedido encontrado com esses filtros.
|
||||||
|
</TableCell>
|
||||||
|
</TableRow>
|
||||||
|
) : (
|
||||||
|
filtrados.map((t) => (
|
||||||
|
<TableRow
|
||||||
|
key={t.id}
|
||||||
|
className="cursor-pointer hover:bg-muted/50"
|
||||||
|
onClick={() => setPedidoSelecionado(t)}
|
||||||
|
>
|
||||||
|
<TableCell className="font-mono text-xs">{t.id.slice(0, 8).toUpperCase()}</TableCell>
|
||||||
|
<TableCell className="max-w-[120px] truncate">{t.clientes?.nome ?? "—"}</TableCell>
|
||||||
|
<TableCell className="max-w-[140px] truncate">{t.produtos?.nome ?? "—"}</TableCell>
|
||||||
|
<TableCell className="font-medium">{fmt(t.valor_centavos)}</TableCell>
|
||||||
|
<TableCell className="text-xs">{t.metodo_pagamento ?? "—"}</TableCell>
|
||||||
|
<TableCell>
|
||||||
|
<span className={`px-2 py-0.5 rounded-full text-xs font-medium ${statusColors[t.status] ?? ""}`}>
|
||||||
|
{statusLabel[t.status] ?? t.status}
|
||||||
|
</span>
|
||||||
|
</TableCell>
|
||||||
|
<TableCell className="text-sm text-muted-foreground whitespace-nowrap">
|
||||||
|
{new Date(t.created_at).toLocaleDateString("pt-BR")}
|
||||||
|
</TableCell>
|
||||||
|
</TableRow>
|
||||||
|
))
|
||||||
|
)}
|
||||||
|
</TableBody>
|
||||||
|
</Table>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Modal detalhe */}
|
||||||
|
{pedidoSelecionado && (
|
||||||
|
<PedidoDetailModal
|
||||||
|
pedido={pedidoSelecionado as Parameters<typeof PedidoDetailModal>[0]["pedido"]}
|
||||||
|
open={!!pedidoSelecionado}
|
||||||
|
onClose={() => setPedidoSelecionado(null)}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
58
components/ui/accordion.tsx
Normal file
58
components/ui/accordion.tsx
Normal file
@@ -0,0 +1,58 @@
|
|||||||
|
"use client"
|
||||||
|
|
||||||
|
import * as React from "react"
|
||||||
|
import * as AccordionPrimitive from "@radix-ui/react-accordion"
|
||||||
|
import { ChevronDown } from "lucide-react"
|
||||||
|
|
||||||
|
import { cn } from "@/lib/utils"
|
||||||
|
|
||||||
|
const Accordion = AccordionPrimitive.Root
|
||||||
|
|
||||||
|
const AccordionItem = React.forwardRef<
|
||||||
|
React.ElementRef<typeof AccordionPrimitive.Item>,
|
||||||
|
React.ComponentPropsWithoutRef<typeof AccordionPrimitive.Item>
|
||||||
|
>(({ className, ...props }, ref) => (
|
||||||
|
<AccordionPrimitive.Item
|
||||||
|
ref={ref}
|
||||||
|
className={cn("border-b", className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
))
|
||||||
|
AccordionItem.displayName = "AccordionItem"
|
||||||
|
|
||||||
|
const AccordionTrigger = React.forwardRef<
|
||||||
|
React.ElementRef<typeof AccordionPrimitive.Trigger>,
|
||||||
|
React.ComponentPropsWithoutRef<typeof AccordionPrimitive.Trigger>
|
||||||
|
>(({ className, children, ...props }, ref) => (
|
||||||
|
<AccordionPrimitive.Header className="flex">
|
||||||
|
<AccordionPrimitive.Trigger
|
||||||
|
ref={ref}
|
||||||
|
className={cn(
|
||||||
|
"flex flex-1 items-center justify-between py-4 font-medium transition-all hover:underline [&[data-state=open]>svg]:rotate-180",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
<ChevronDown className="h-4 w-4 shrink-0 transition-transform duration-200" />
|
||||||
|
</AccordionPrimitive.Trigger>
|
||||||
|
</AccordionPrimitive.Header>
|
||||||
|
))
|
||||||
|
AccordionTrigger.displayName = AccordionPrimitive.Trigger.displayName
|
||||||
|
|
||||||
|
const AccordionContent = React.forwardRef<
|
||||||
|
React.ElementRef<typeof AccordionPrimitive.Content>,
|
||||||
|
React.ComponentPropsWithoutRef<typeof AccordionPrimitive.Content>
|
||||||
|
>(({ className, children, ...props }, ref) => (
|
||||||
|
<AccordionPrimitive.Content
|
||||||
|
ref={ref}
|
||||||
|
className="overflow-hidden text-sm transition-all data-[state=closed]:animate-accordion-up data-[state=open]:animate-accordion-down"
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
<div className={cn("pb-4 pt-0", className)}>{children}</div>
|
||||||
|
</AccordionPrimitive.Content>
|
||||||
|
))
|
||||||
|
|
||||||
|
AccordionContent.displayName = AccordionPrimitive.Content.displayName
|
||||||
|
|
||||||
|
export { Accordion, AccordionItem, AccordionTrigger, AccordionContent }
|
||||||
50
components/ui/avatar.tsx
Normal file
50
components/ui/avatar.tsx
Normal file
@@ -0,0 +1,50 @@
|
|||||||
|
"use client"
|
||||||
|
|
||||||
|
import * as React from "react"
|
||||||
|
import * as AvatarPrimitive from "@radix-ui/react-avatar"
|
||||||
|
|
||||||
|
import { cn } from "@/lib/utils"
|
||||||
|
|
||||||
|
const Avatar = React.forwardRef<
|
||||||
|
React.ElementRef<typeof AvatarPrimitive.Root>,
|
||||||
|
React.ComponentPropsWithoutRef<typeof AvatarPrimitive.Root>
|
||||||
|
>(({ className, ...props }, ref) => (
|
||||||
|
<AvatarPrimitive.Root
|
||||||
|
ref={ref}
|
||||||
|
className={cn(
|
||||||
|
"relative flex h-10 w-10 shrink-0 overflow-hidden rounded-full",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
))
|
||||||
|
Avatar.displayName = AvatarPrimitive.Root.displayName
|
||||||
|
|
||||||
|
const AvatarImage = React.forwardRef<
|
||||||
|
React.ElementRef<typeof AvatarPrimitive.Image>,
|
||||||
|
React.ComponentPropsWithoutRef<typeof AvatarPrimitive.Image>
|
||||||
|
>(({ className, ...props }, ref) => (
|
||||||
|
<AvatarPrimitive.Image
|
||||||
|
ref={ref}
|
||||||
|
className={cn("aspect-square h-full w-full", className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
))
|
||||||
|
AvatarImage.displayName = AvatarPrimitive.Image.displayName
|
||||||
|
|
||||||
|
const AvatarFallback = React.forwardRef<
|
||||||
|
React.ElementRef<typeof AvatarPrimitive.Fallback>,
|
||||||
|
React.ComponentPropsWithoutRef<typeof AvatarPrimitive.Fallback>
|
||||||
|
>(({ className, ...props }, ref) => (
|
||||||
|
<AvatarPrimitive.Fallback
|
||||||
|
ref={ref}
|
||||||
|
className={cn(
|
||||||
|
"flex h-full w-full items-center justify-center rounded-full bg-muted",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
))
|
||||||
|
AvatarFallback.displayName = AvatarPrimitive.Fallback.displayName
|
||||||
|
|
||||||
|
export { Avatar, AvatarImage, AvatarFallback }
|
||||||
36
components/ui/badge.tsx
Normal file
36
components/ui/badge.tsx
Normal file
@@ -0,0 +1,36 @@
|
|||||||
|
import * as React from "react"
|
||||||
|
import { cva, type VariantProps } from "class-variance-authority"
|
||||||
|
|
||||||
|
import { cn } from "@/lib/utils"
|
||||||
|
|
||||||
|
const badgeVariants = cva(
|
||||||
|
"inline-flex items-center rounded-full border px-2.5 py-0.5 text-xs font-semibold transition-colors focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2",
|
||||||
|
{
|
||||||
|
variants: {
|
||||||
|
variant: {
|
||||||
|
default:
|
||||||
|
"border-transparent bg-primary text-primary-foreground hover:bg-primary/80",
|
||||||
|
secondary:
|
||||||
|
"border-transparent bg-secondary text-secondary-foreground hover:bg-secondary/80",
|
||||||
|
destructive:
|
||||||
|
"border-transparent bg-destructive text-destructive-foreground hover:bg-destructive/80",
|
||||||
|
outline: "text-foreground",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
defaultVariants: {
|
||||||
|
variant: "default",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
export interface BadgeProps
|
||||||
|
extends React.HTMLAttributes<HTMLDivElement>,
|
||||||
|
VariantProps<typeof badgeVariants> {}
|
||||||
|
|
||||||
|
function Badge({ className, variant, ...props }: BadgeProps) {
|
||||||
|
return (
|
||||||
|
<div className={cn(badgeVariants({ variant }), className)} {...props} />
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export { Badge, badgeVariants }
|
||||||
47
components/ui/button.tsx
Normal file
47
components/ui/button.tsx
Normal file
@@ -0,0 +1,47 @@
|
|||||||
|
import * as React from "react"
|
||||||
|
import { Slot } from "@radix-ui/react-slot"
|
||||||
|
import { cva, type VariantProps } from "class-variance-authority"
|
||||||
|
|
||||||
|
import { cn } from "@/lib/utils"
|
||||||
|
|
||||||
|
const buttonVariants = cva(
|
||||||
|
"inline-flex items-center justify-center whitespace-nowrap rounded-md text-sm font-medium ring-offset-background transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50",
|
||||||
|
{
|
||||||
|
variants: {
|
||||||
|
variant: {
|
||||||
|
default: "bg-primary text-primary-foreground hover:bg-primary/90",
|
||||||
|
destructive: "bg-destructive text-destructive-foreground hover:bg-destructive/90",
|
||||||
|
outline: "border border-input bg-background hover:bg-accent hover:text-accent-foreground",
|
||||||
|
secondary: "bg-secondary text-secondary-foreground hover:bg-secondary/80",
|
||||||
|
ghost: "hover:bg-accent hover:text-accent-foreground",
|
||||||
|
link: "text-primary underline-offset-4 hover:underline",
|
||||||
|
},
|
||||||
|
size: {
|
||||||
|
default: "h-10 px-4 py-2",
|
||||||
|
sm: "h-9 rounded-md px-3",
|
||||||
|
lg: "h-11 rounded-md px-8",
|
||||||
|
icon: "h-10 w-10",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
defaultVariants: {
|
||||||
|
variant: "default",
|
||||||
|
size: "default",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
export interface ButtonProps
|
||||||
|
extends React.ButtonHTMLAttributes<HTMLButtonElement>,
|
||||||
|
VariantProps<typeof buttonVariants> {
|
||||||
|
asChild?: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
const Button = React.forwardRef<HTMLButtonElement, ButtonProps>(
|
||||||
|
({ className, variant, size, asChild = false, ...props }, ref) => {
|
||||||
|
const Comp = asChild ? Slot : "button"
|
||||||
|
return <Comp className={cn(buttonVariants({ variant, size, className }))} ref={ref} {...props} />
|
||||||
|
},
|
||||||
|
)
|
||||||
|
Button.displayName = "Button"
|
||||||
|
|
||||||
|
export { Button, buttonVariants }
|
||||||
21
components/ui/calendar.tsx
Normal file
21
components/ui/calendar.tsx
Normal file
@@ -0,0 +1,21 @@
|
|||||||
|
"use client"
|
||||||
|
|
||||||
|
import * as React from "react"
|
||||||
|
import { DayPicker } from "react-day-picker"
|
||||||
|
import { cn } from "@/lib/utils"
|
||||||
|
|
||||||
|
export type CalendarProps = React.ComponentProps<typeof DayPicker>
|
||||||
|
|
||||||
|
function Calendar({ className, classNames, showOutsideDays = true, ...props }: CalendarProps) {
|
||||||
|
return (
|
||||||
|
<DayPicker
|
||||||
|
showOutsideDays={showOutsideDays}
|
||||||
|
className={cn("p-3", className)}
|
||||||
|
classNames={classNames}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
Calendar.displayName = "Calendar"
|
||||||
|
|
||||||
|
export { Calendar }
|
||||||
79
components/ui/card.tsx
Normal file
79
components/ui/card.tsx
Normal file
@@ -0,0 +1,79 @@
|
|||||||
|
import * as React from "react"
|
||||||
|
|
||||||
|
import { cn } from "@/lib/utils"
|
||||||
|
|
||||||
|
const Card = React.forwardRef<
|
||||||
|
HTMLDivElement,
|
||||||
|
React.HTMLAttributes<HTMLDivElement>
|
||||||
|
>(({ className, ...props }, ref) => (
|
||||||
|
<div
|
||||||
|
ref={ref}
|
||||||
|
className={cn(
|
||||||
|
"rounded-lg border bg-card text-card-foreground shadow-sm",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
))
|
||||||
|
Card.displayName = "Card"
|
||||||
|
|
||||||
|
const CardHeader = React.forwardRef<
|
||||||
|
HTMLDivElement,
|
||||||
|
React.HTMLAttributes<HTMLDivElement>
|
||||||
|
>(({ className, ...props }, ref) => (
|
||||||
|
<div
|
||||||
|
ref={ref}
|
||||||
|
className={cn("flex flex-col space-y-1.5 p-6", className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
))
|
||||||
|
CardHeader.displayName = "CardHeader"
|
||||||
|
|
||||||
|
const CardTitle = React.forwardRef<
|
||||||
|
HTMLDivElement,
|
||||||
|
React.HTMLAttributes<HTMLDivElement>
|
||||||
|
>(({ className, ...props }, ref) => (
|
||||||
|
<div
|
||||||
|
ref={ref}
|
||||||
|
className={cn(
|
||||||
|
"text-2xl font-semibold leading-none tracking-tight",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
))
|
||||||
|
CardTitle.displayName = "CardTitle"
|
||||||
|
|
||||||
|
const CardDescription = React.forwardRef<
|
||||||
|
HTMLDivElement,
|
||||||
|
React.HTMLAttributes<HTMLDivElement>
|
||||||
|
>(({ className, ...props }, ref) => (
|
||||||
|
<div
|
||||||
|
ref={ref}
|
||||||
|
className={cn("text-sm text-muted-foreground", className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
))
|
||||||
|
CardDescription.displayName = "CardDescription"
|
||||||
|
|
||||||
|
const CardContent = React.forwardRef<
|
||||||
|
HTMLDivElement,
|
||||||
|
React.HTMLAttributes<HTMLDivElement>
|
||||||
|
>(({ className, ...props }, ref) => (
|
||||||
|
<div ref={ref} className={cn("p-6 pt-0", className)} {...props} />
|
||||||
|
))
|
||||||
|
CardContent.displayName = "CardContent"
|
||||||
|
|
||||||
|
const CardFooter = React.forwardRef<
|
||||||
|
HTMLDivElement,
|
||||||
|
React.HTMLAttributes<HTMLDivElement>
|
||||||
|
>(({ className, ...props }, ref) => (
|
||||||
|
<div
|
||||||
|
ref={ref}
|
||||||
|
className={cn("flex items-center p-6 pt-0", className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
))
|
||||||
|
CardFooter.displayName = "CardFooter"
|
||||||
|
|
||||||
|
export { Card, CardHeader, CardFooter, CardTitle, CardDescription, CardContent }
|
||||||
30
components/ui/checkbox.tsx
Normal file
30
components/ui/checkbox.tsx
Normal file
@@ -0,0 +1,30 @@
|
|||||||
|
"use client"
|
||||||
|
|
||||||
|
import * as React from "react"
|
||||||
|
import * as CheckboxPrimitive from "@radix-ui/react-checkbox"
|
||||||
|
import { Check } from "lucide-react"
|
||||||
|
|
||||||
|
import { cn } from "@/lib/utils"
|
||||||
|
|
||||||
|
const Checkbox = React.forwardRef<
|
||||||
|
React.ElementRef<typeof CheckboxPrimitive.Root>,
|
||||||
|
React.ComponentPropsWithoutRef<typeof CheckboxPrimitive.Root>
|
||||||
|
>(({ className, ...props }, ref) => (
|
||||||
|
<CheckboxPrimitive.Root
|
||||||
|
ref={ref}
|
||||||
|
className={cn(
|
||||||
|
"peer h-4 w-4 shrink-0 rounded-sm border border-primary ring-offset-background focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50 data-[state=checked]:bg-primary data-[state=checked]:text-primary-foreground",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
<CheckboxPrimitive.Indicator
|
||||||
|
className={cn("flex items-center justify-center text-current")}
|
||||||
|
>
|
||||||
|
<Check className="h-4 w-4" />
|
||||||
|
</CheckboxPrimitive.Indicator>
|
||||||
|
</CheckboxPrimitive.Root>
|
||||||
|
))
|
||||||
|
Checkbox.displayName = CheckboxPrimitive.Root.displayName
|
||||||
|
|
||||||
|
export { Checkbox }
|
||||||
69
components/ui/command.tsx
Normal file
69
components/ui/command.tsx
Normal file
@@ -0,0 +1,69 @@
|
|||||||
|
"use client"
|
||||||
|
|
||||||
|
import * as React from "react"
|
||||||
|
import { Command as CommandPrimitive } from "cmdk"
|
||||||
|
import { cn } from "@/lib/utils"
|
||||||
|
|
||||||
|
const Command = React.forwardRef<
|
||||||
|
React.ElementRef<typeof CommandPrimitive>,
|
||||||
|
React.ComponentPropsWithoutRef<typeof CommandPrimitive>
|
||||||
|
>(({ className, ...props }, ref) => (
|
||||||
|
<CommandPrimitive
|
||||||
|
ref={ref}
|
||||||
|
className={cn("flex h-full w-full flex-col overflow-hidden rounded-md bg-popover text-popover-foreground", className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
))
|
||||||
|
Command.displayName = CommandPrimitive.displayName
|
||||||
|
|
||||||
|
const CommandInput = React.forwardRef<
|
||||||
|
React.ElementRef<typeof CommandPrimitive.Input>,
|
||||||
|
React.ComponentPropsWithoutRef<typeof CommandPrimitive.Input>
|
||||||
|
>(({ className, ...props }, ref) => (
|
||||||
|
<CommandPrimitive.Input
|
||||||
|
ref={ref}
|
||||||
|
className={cn("flex h-10 w-full rounded-md bg-transparent py-3 text-sm outline-none placeholder:text-muted-foreground disabled:cursor-not-allowed disabled:opacity-50", className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
))
|
||||||
|
CommandInput.displayName = CommandPrimitive.Input.displayName
|
||||||
|
|
||||||
|
const CommandList = React.forwardRef<
|
||||||
|
React.ElementRef<typeof CommandPrimitive.List>,
|
||||||
|
React.ComponentPropsWithoutRef<typeof CommandPrimitive.List>
|
||||||
|
>(({ className, ...props }, ref) => (
|
||||||
|
<CommandPrimitive.List ref={ref} className={cn("max-h-[300px] overflow-y-auto overflow-x-hidden", className)} {...props} />
|
||||||
|
))
|
||||||
|
CommandList.displayName = CommandPrimitive.List.displayName
|
||||||
|
|
||||||
|
const CommandEmpty = React.forwardRef<
|
||||||
|
React.ElementRef<typeof CommandPrimitive.Empty>,
|
||||||
|
React.ComponentPropsWithoutRef<typeof CommandPrimitive.Empty>
|
||||||
|
>((props, ref) => <CommandPrimitive.Empty ref={ref} className="py-6 text-center text-sm" {...props} />)
|
||||||
|
CommandEmpty.displayName = CommandPrimitive.Empty.displayName
|
||||||
|
|
||||||
|
const CommandGroup = React.forwardRef<
|
||||||
|
React.ElementRef<typeof CommandPrimitive.Group>,
|
||||||
|
React.ComponentPropsWithoutRef<typeof CommandPrimitive.Group>
|
||||||
|
>(({ className, ...props }, ref) => (
|
||||||
|
<CommandPrimitive.Group ref={ref} className={cn("overflow-hidden p-1 text-foreground [&_[cmdk-group-heading]]:px-2 [&_[cmdk-group-heading]]:py-1.5 [&_[cmdk-group-heading]]:text-xs [&_[cmdk-group-heading]]:font-medium [&_[cmdk-group-heading]]:text-muted-foreground", className)} {...props} />
|
||||||
|
))
|
||||||
|
CommandGroup.displayName = CommandPrimitive.Group.displayName
|
||||||
|
|
||||||
|
const CommandItem = React.forwardRef<
|
||||||
|
React.ElementRef<typeof CommandPrimitive.Item>,
|
||||||
|
React.ComponentPropsWithoutRef<typeof CommandPrimitive.Item>
|
||||||
|
>(({ className, ...props }, ref) => (
|
||||||
|
<CommandPrimitive.Item ref={ref} className={cn("relative flex cursor-default select-none items-center rounded-sm px-2 py-1.5 text-sm outline-none aria-selected:bg-accent aria-selected:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50", className)} {...props} />
|
||||||
|
))
|
||||||
|
CommandItem.displayName = CommandPrimitive.Item.displayName
|
||||||
|
|
||||||
|
const CommandSeparator = React.forwardRef<
|
||||||
|
React.ElementRef<typeof CommandPrimitive.Separator>,
|
||||||
|
React.ComponentPropsWithoutRef<typeof CommandPrimitive.Separator>
|
||||||
|
>(({ className, ...props }, ref) => (
|
||||||
|
<CommandPrimitive.Separator ref={ref} className={cn("-mx-1 h-px bg-border", className)} {...props} />
|
||||||
|
))
|
||||||
|
CommandSeparator.displayName = CommandPrimitive.Separator.displayName
|
||||||
|
|
||||||
|
export { Command, CommandInput, CommandList, CommandEmpty, CommandGroup, CommandItem, CommandSeparator }
|
||||||
97
components/ui/dialog.tsx
Normal file
97
components/ui/dialog.tsx
Normal file
@@ -0,0 +1,97 @@
|
|||||||
|
"use client"
|
||||||
|
|
||||||
|
import * as React from "react"
|
||||||
|
import * as DialogPrimitive from "@radix-ui/react-dialog"
|
||||||
|
import { X } from "lucide-react"
|
||||||
|
|
||||||
|
import { cn } from "@/lib/utils"
|
||||||
|
|
||||||
|
const Dialog = DialogPrimitive.Root
|
||||||
|
|
||||||
|
const DialogTrigger = DialogPrimitive.Trigger
|
||||||
|
|
||||||
|
const DialogPortal = DialogPrimitive.Portal
|
||||||
|
|
||||||
|
const DialogClose = DialogPrimitive.Close
|
||||||
|
|
||||||
|
const DialogOverlay = React.forwardRef<
|
||||||
|
React.ElementRef<typeof DialogPrimitive.Overlay>,
|
||||||
|
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Overlay>
|
||||||
|
>(({ className, ...props }, ref) => (
|
||||||
|
<DialogPrimitive.Overlay
|
||||||
|
ref={ref}
|
||||||
|
className={cn(
|
||||||
|
"fixed inset-0 z-50 bg-background/80 backdrop-blur-sm data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0",
|
||||||
|
className,
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
))
|
||||||
|
DialogOverlay.displayName = DialogPrimitive.Overlay.displayName
|
||||||
|
|
||||||
|
const DialogContent = React.forwardRef<
|
||||||
|
React.ElementRef<typeof DialogPrimitive.Content>,
|
||||||
|
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Content>
|
||||||
|
>(({ className, children, ...props }, ref) => (
|
||||||
|
<DialogPortal>
|
||||||
|
<DialogOverlay />
|
||||||
|
<DialogPrimitive.Content
|
||||||
|
ref={ref}
|
||||||
|
className={cn(
|
||||||
|
"fixed left-[50%] top-[50%] z-50 grid w-full max-w-lg translate-x-[-50%] translate-y-[-50%] gap-4 border bg-background p-6 shadow-lg duration-200 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[state=closed]:slide-out-to-left-1/2 data-[state=closed]:slide-out-to-top-[48%] data-[state=open]:slide-in-from-left-1/2 data-[state=open]:slide-in-from-top-[48%] sm:rounded-[20px]",
|
||||||
|
className,
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
<DialogPrimitive.Close className="absolute right-4 top-4 rounded-sm opacity-70 ring-offset-background transition-opacity hover:opacity-100 focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2 disabled:pointer-events-none data-[state=open]:bg-accent data-[state=open]:text-muted-foreground">
|
||||||
|
<X className="h-4 w-4" />
|
||||||
|
<span className="sr-only">Close</span>
|
||||||
|
</DialogPrimitive.Close>
|
||||||
|
</DialogPrimitive.Content>
|
||||||
|
</DialogPortal>
|
||||||
|
))
|
||||||
|
DialogContent.displayName = DialogPrimitive.Content.displayName
|
||||||
|
|
||||||
|
const DialogHeader = ({ className, ...props }: React.HTMLAttributes<HTMLDivElement>) => (
|
||||||
|
<div className={cn("flex flex-col space-y-1.5 text-center sm:text-left", className)} {...props} />
|
||||||
|
)
|
||||||
|
DialogHeader.displayName = "DialogHeader"
|
||||||
|
|
||||||
|
const DialogFooter = ({ className, ...props }: React.HTMLAttributes<HTMLDivElement>) => (
|
||||||
|
<div className={cn("flex flex-col-reverse sm:flex-row sm:justify-end sm:space-x-2", className)} {...props} />
|
||||||
|
)
|
||||||
|
DialogFooter.displayName = "DialogFooter"
|
||||||
|
|
||||||
|
const DialogTitle = React.forwardRef<
|
||||||
|
React.ElementRef<typeof DialogPrimitive.Title>,
|
||||||
|
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Title>
|
||||||
|
>(({ className, ...props }, ref) => (
|
||||||
|
<DialogPrimitive.Title
|
||||||
|
ref={ref}
|
||||||
|
className={cn("text-lg font-semibold leading-none tracking-tight", className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
))
|
||||||
|
DialogTitle.displayName = DialogPrimitive.Title.displayName
|
||||||
|
|
||||||
|
const DialogDescription = React.forwardRef<
|
||||||
|
React.ElementRef<typeof DialogPrimitive.Description>,
|
||||||
|
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Description>
|
||||||
|
>(({ className, ...props }, ref) => (
|
||||||
|
<DialogPrimitive.Description ref={ref} className={cn("text-sm text-muted-foreground", className)} {...props} />
|
||||||
|
))
|
||||||
|
DialogDescription.displayName = DialogPrimitive.Description.displayName
|
||||||
|
|
||||||
|
export {
|
||||||
|
Dialog,
|
||||||
|
DialogPortal,
|
||||||
|
DialogOverlay,
|
||||||
|
DialogClose,
|
||||||
|
DialogTrigger,
|
||||||
|
DialogContent,
|
||||||
|
DialogHeader,
|
||||||
|
DialogFooter,
|
||||||
|
DialogTitle,
|
||||||
|
DialogDescription,
|
||||||
|
}
|
||||||
100
components/ui/dropdown-menu.tsx
Normal file
100
components/ui/dropdown-menu.tsx
Normal file
@@ -0,0 +1,100 @@
|
|||||||
|
"use client"
|
||||||
|
|
||||||
|
import * as React from "react"
|
||||||
|
import * as DropdownMenuPrimitive from "@radix-ui/react-dropdown-menu"
|
||||||
|
import { cn } from "@/lib/utils"
|
||||||
|
|
||||||
|
const DropdownMenu = DropdownMenuPrimitive.Root
|
||||||
|
const DropdownMenuTrigger = DropdownMenuPrimitive.Trigger
|
||||||
|
const DropdownMenuGroup = DropdownMenuPrimitive.Group
|
||||||
|
const DropdownMenuSub = DropdownMenuPrimitive.Sub
|
||||||
|
const DropdownMenuRadioGroup = DropdownMenuPrimitive.RadioGroup
|
||||||
|
|
||||||
|
const DropdownMenuContent = React.forwardRef<
|
||||||
|
React.ElementRef<typeof DropdownMenuPrimitive.Content>,
|
||||||
|
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.Content>
|
||||||
|
>(({ className, sideOffset = 4, ...props }, ref) => (
|
||||||
|
<DropdownMenuPrimitive.Portal>
|
||||||
|
<DropdownMenuPrimitive.Content
|
||||||
|
ref={ref}
|
||||||
|
sideOffset={sideOffset}
|
||||||
|
className={cn(
|
||||||
|
"z-50 min-w-[8rem] overflow-hidden rounded-md border bg-popover p-1 text-popover-foreground shadow-md data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
</DropdownMenuPrimitive.Portal>
|
||||||
|
))
|
||||||
|
DropdownMenuContent.displayName = DropdownMenuPrimitive.Content.displayName
|
||||||
|
|
||||||
|
const DropdownMenuItem = React.forwardRef<
|
||||||
|
React.ElementRef<typeof DropdownMenuPrimitive.Item>,
|
||||||
|
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.Item> & { inset?: boolean }
|
||||||
|
>(({ className, inset, ...props }, ref) => (
|
||||||
|
<DropdownMenuPrimitive.Item
|
||||||
|
ref={ref}
|
||||||
|
className={cn(
|
||||||
|
"relative flex cursor-default select-none items-center rounded-sm px-2 py-1.5 text-sm outline-none transition-colors focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50",
|
||||||
|
inset && "pl-8",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
))
|
||||||
|
DropdownMenuItem.displayName = DropdownMenuPrimitive.Item.displayName
|
||||||
|
|
||||||
|
const DropdownMenuCheckboxItem = React.forwardRef<
|
||||||
|
React.ElementRef<typeof DropdownMenuPrimitive.CheckboxItem>,
|
||||||
|
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.CheckboxItem>
|
||||||
|
>(({ className, children, checked, ...props }, ref) => (
|
||||||
|
<DropdownMenuPrimitive.CheckboxItem
|
||||||
|
ref={ref}
|
||||||
|
className={cn(
|
||||||
|
"relative flex cursor-default select-none items-center rounded-sm py-1.5 pl-8 pr-2 text-sm outline-none transition-colors focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
checked={checked}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
</DropdownMenuPrimitive.CheckboxItem>
|
||||||
|
))
|
||||||
|
DropdownMenuCheckboxItem.displayName = DropdownMenuPrimitive.CheckboxItem.displayName
|
||||||
|
|
||||||
|
const DropdownMenuLabel = React.forwardRef<
|
||||||
|
React.ElementRef<typeof DropdownMenuPrimitive.Label>,
|
||||||
|
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.Label> & { inset?: boolean }
|
||||||
|
>(({ className, inset, ...props }, ref) => (
|
||||||
|
<DropdownMenuPrimitive.Label
|
||||||
|
ref={ref}
|
||||||
|
className={cn("px-2 py-1.5 text-sm font-semibold", inset && "pl-8", className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
))
|
||||||
|
DropdownMenuLabel.displayName = DropdownMenuPrimitive.Label.displayName
|
||||||
|
|
||||||
|
const DropdownMenuSeparator = React.forwardRef<
|
||||||
|
React.ElementRef<typeof DropdownMenuPrimitive.Separator>,
|
||||||
|
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.Separator>
|
||||||
|
>(({ className, ...props }, ref) => (
|
||||||
|
<DropdownMenuPrimitive.Separator
|
||||||
|
ref={ref}
|
||||||
|
className={cn("-mx-1 my-1 h-px bg-muted", className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
))
|
||||||
|
DropdownMenuSeparator.displayName = DropdownMenuPrimitive.Separator.displayName
|
||||||
|
|
||||||
|
export {
|
||||||
|
DropdownMenu,
|
||||||
|
DropdownMenuTrigger,
|
||||||
|
DropdownMenuContent,
|
||||||
|
DropdownMenuItem,
|
||||||
|
DropdownMenuCheckboxItem,
|
||||||
|
DropdownMenuLabel,
|
||||||
|
DropdownMenuSeparator,
|
||||||
|
DropdownMenuGroup,
|
||||||
|
DropdownMenuSub,
|
||||||
|
DropdownMenuRadioGroup,
|
||||||
|
}
|
||||||
22
components/ui/input.tsx
Normal file
22
components/ui/input.tsx
Normal file
@@ -0,0 +1,22 @@
|
|||||||
|
import * as React from "react"
|
||||||
|
|
||||||
|
import { cn } from "@/lib/utils"
|
||||||
|
|
||||||
|
const Input = React.forwardRef<HTMLInputElement, React.ComponentProps<"input">>(
|
||||||
|
({ className, type, ...props }, ref) => {
|
||||||
|
return (
|
||||||
|
<input
|
||||||
|
type={type}
|
||||||
|
className={cn(
|
||||||
|
"flex h-10 w-full rounded-md border border-input bg-background px-3 py-2 text-base ring-offset-background file:border-0 file:bg-transparent file:text-sm file:font-medium file:text-foreground placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50 md:text-sm",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
ref={ref}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
)
|
||||||
|
Input.displayName = "Input"
|
||||||
|
|
||||||
|
export { Input }
|
||||||
26
components/ui/label.tsx
Normal file
26
components/ui/label.tsx
Normal file
@@ -0,0 +1,26 @@
|
|||||||
|
"use client"
|
||||||
|
|
||||||
|
import * as React from "react"
|
||||||
|
import * as LabelPrimitive from "@radix-ui/react-label"
|
||||||
|
import { cva, type VariantProps } from "class-variance-authority"
|
||||||
|
|
||||||
|
import { cn } from "@/lib/utils"
|
||||||
|
|
||||||
|
const labelVariants = cva(
|
||||||
|
"text-sm font-medium leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70"
|
||||||
|
)
|
||||||
|
|
||||||
|
const Label = React.forwardRef<
|
||||||
|
React.ElementRef<typeof LabelPrimitive.Root>,
|
||||||
|
React.ComponentPropsWithoutRef<typeof LabelPrimitive.Root> &
|
||||||
|
VariantProps<typeof labelVariants>
|
||||||
|
>(({ className, ...props }, ref) => (
|
||||||
|
<LabelPrimitive.Root
|
||||||
|
ref={ref}
|
||||||
|
className={cn(labelVariants(), className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
))
|
||||||
|
Label.displayName = LabelPrimitive.Root.displayName
|
||||||
|
|
||||||
|
export { Label }
|
||||||
29
components/ui/popover.tsx
Normal file
29
components/ui/popover.tsx
Normal file
@@ -0,0 +1,29 @@
|
|||||||
|
"use client"
|
||||||
|
|
||||||
|
import * as React from "react"
|
||||||
|
import * as PopoverPrimitive from "@radix-ui/react-popover"
|
||||||
|
import { cn } from "@/lib/utils"
|
||||||
|
|
||||||
|
const Popover = PopoverPrimitive.Root
|
||||||
|
const PopoverTrigger = PopoverPrimitive.Trigger
|
||||||
|
|
||||||
|
const PopoverContent = React.forwardRef<
|
||||||
|
React.ElementRef<typeof PopoverPrimitive.Content>,
|
||||||
|
React.ComponentPropsWithoutRef<typeof PopoverPrimitive.Content>
|
||||||
|
>(({ className, align = "center", sideOffset = 4, ...props }, ref) => (
|
||||||
|
<PopoverPrimitive.Portal>
|
||||||
|
<PopoverPrimitive.Content
|
||||||
|
ref={ref}
|
||||||
|
align={align}
|
||||||
|
sideOffset={sideOffset}
|
||||||
|
className={cn(
|
||||||
|
"z-50 w-72 rounded-md border bg-popover p-4 text-popover-foreground shadow-md outline-none data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
</PopoverPrimitive.Portal>
|
||||||
|
))
|
||||||
|
PopoverContent.displayName = PopoverPrimitive.Content.displayName
|
||||||
|
|
||||||
|
export { Popover, PopoverTrigger, PopoverContent }
|
||||||
160
components/ui/select.tsx
Normal file
160
components/ui/select.tsx
Normal file
@@ -0,0 +1,160 @@
|
|||||||
|
"use client"
|
||||||
|
|
||||||
|
import * as React from "react"
|
||||||
|
import * as SelectPrimitive from "@radix-ui/react-select"
|
||||||
|
import { Check, ChevronDown, ChevronUp } from "lucide-react"
|
||||||
|
|
||||||
|
import { cn } from "@/lib/utils"
|
||||||
|
|
||||||
|
const Select = SelectPrimitive.Root
|
||||||
|
|
||||||
|
const SelectGroup = SelectPrimitive.Group
|
||||||
|
|
||||||
|
const SelectValue = SelectPrimitive.Value
|
||||||
|
|
||||||
|
const SelectTrigger = React.forwardRef<
|
||||||
|
React.ElementRef<typeof SelectPrimitive.Trigger>,
|
||||||
|
React.ComponentPropsWithoutRef<typeof SelectPrimitive.Trigger>
|
||||||
|
>(({ className, children, ...props }, ref) => (
|
||||||
|
<SelectPrimitive.Trigger
|
||||||
|
ref={ref}
|
||||||
|
className={cn(
|
||||||
|
"flex h-10 w-full items-center justify-between rounded-md border border-input bg-background px-3 py-2 text-sm ring-offset-background placeholder:text-muted-foreground focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50 [&>span]:line-clamp-1",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
<SelectPrimitive.Icon asChild>
|
||||||
|
<ChevronDown className="h-4 w-4 opacity-50" />
|
||||||
|
</SelectPrimitive.Icon>
|
||||||
|
</SelectPrimitive.Trigger>
|
||||||
|
))
|
||||||
|
SelectTrigger.displayName = SelectPrimitive.Trigger.displayName
|
||||||
|
|
||||||
|
const SelectScrollUpButton = React.forwardRef<
|
||||||
|
React.ElementRef<typeof SelectPrimitive.ScrollUpButton>,
|
||||||
|
React.ComponentPropsWithoutRef<typeof SelectPrimitive.ScrollUpButton>
|
||||||
|
>(({ className, ...props }, ref) => (
|
||||||
|
<SelectPrimitive.ScrollUpButton
|
||||||
|
ref={ref}
|
||||||
|
className={cn(
|
||||||
|
"flex cursor-default items-center justify-center py-1",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
<ChevronUp className="h-4 w-4" />
|
||||||
|
</SelectPrimitive.ScrollUpButton>
|
||||||
|
))
|
||||||
|
SelectScrollUpButton.displayName = SelectPrimitive.ScrollUpButton.displayName
|
||||||
|
|
||||||
|
const SelectScrollDownButton = React.forwardRef<
|
||||||
|
React.ElementRef<typeof SelectPrimitive.ScrollDownButton>,
|
||||||
|
React.ComponentPropsWithoutRef<typeof SelectPrimitive.ScrollDownButton>
|
||||||
|
>(({ className, ...props }, ref) => (
|
||||||
|
<SelectPrimitive.ScrollDownButton
|
||||||
|
ref={ref}
|
||||||
|
className={cn(
|
||||||
|
"flex cursor-default items-center justify-center py-1",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
<ChevronDown className="h-4 w-4" />
|
||||||
|
</SelectPrimitive.ScrollDownButton>
|
||||||
|
))
|
||||||
|
SelectScrollDownButton.displayName =
|
||||||
|
SelectPrimitive.ScrollDownButton.displayName
|
||||||
|
|
||||||
|
const SelectContent = React.forwardRef<
|
||||||
|
React.ElementRef<typeof SelectPrimitive.Content>,
|
||||||
|
React.ComponentPropsWithoutRef<typeof SelectPrimitive.Content>
|
||||||
|
>(({ className, children, position = "popper", ...props }, ref) => (
|
||||||
|
<SelectPrimitive.Portal>
|
||||||
|
<SelectPrimitive.Content
|
||||||
|
ref={ref}
|
||||||
|
className={cn(
|
||||||
|
"relative z-50 max-h-96 min-w-[8rem] overflow-hidden rounded-md border bg-popover text-popover-foreground shadow-md data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2",
|
||||||
|
position === "popper" &&
|
||||||
|
"data-[side=bottom]:translate-y-1 data-[side=left]:-translate-x-1 data-[side=right]:translate-x-1 data-[side=top]:-translate-y-1",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
position={position}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
<SelectScrollUpButton />
|
||||||
|
<SelectPrimitive.Viewport
|
||||||
|
className={cn(
|
||||||
|
"p-1",
|
||||||
|
position === "popper" &&
|
||||||
|
"h-[var(--radix-select-trigger-height)] w-full min-w-[var(--radix-select-trigger-width)]"
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
</SelectPrimitive.Viewport>
|
||||||
|
<SelectScrollDownButton />
|
||||||
|
</SelectPrimitive.Content>
|
||||||
|
</SelectPrimitive.Portal>
|
||||||
|
))
|
||||||
|
SelectContent.displayName = SelectPrimitive.Content.displayName
|
||||||
|
|
||||||
|
const SelectLabel = React.forwardRef<
|
||||||
|
React.ElementRef<typeof SelectPrimitive.Label>,
|
||||||
|
React.ComponentPropsWithoutRef<typeof SelectPrimitive.Label>
|
||||||
|
>(({ className, ...props }, ref) => (
|
||||||
|
<SelectPrimitive.Label
|
||||||
|
ref={ref}
|
||||||
|
className={cn("py-1.5 pl-8 pr-2 text-sm font-semibold", className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
))
|
||||||
|
SelectLabel.displayName = SelectPrimitive.Label.displayName
|
||||||
|
|
||||||
|
const SelectItem = React.forwardRef<
|
||||||
|
React.ElementRef<typeof SelectPrimitive.Item>,
|
||||||
|
React.ComponentPropsWithoutRef<typeof SelectPrimitive.Item>
|
||||||
|
>(({ className, children, ...props }, ref) => (
|
||||||
|
<SelectPrimitive.Item
|
||||||
|
ref={ref}
|
||||||
|
className={cn(
|
||||||
|
"relative flex w-full cursor-default select-none items-center rounded-sm py-1.5 pl-8 pr-2 text-sm outline-none focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
<span className="absolute left-2 flex h-3.5 w-3.5 items-center justify-center">
|
||||||
|
<SelectPrimitive.ItemIndicator>
|
||||||
|
<Check className="h-4 w-4" />
|
||||||
|
</SelectPrimitive.ItemIndicator>
|
||||||
|
</span>
|
||||||
|
|
||||||
|
<SelectPrimitive.ItemText>{children}</SelectPrimitive.ItemText>
|
||||||
|
</SelectPrimitive.Item>
|
||||||
|
))
|
||||||
|
SelectItem.displayName = SelectPrimitive.Item.displayName
|
||||||
|
|
||||||
|
const SelectSeparator = React.forwardRef<
|
||||||
|
React.ElementRef<typeof SelectPrimitive.Separator>,
|
||||||
|
React.ComponentPropsWithoutRef<typeof SelectPrimitive.Separator>
|
||||||
|
>(({ className, ...props }, ref) => (
|
||||||
|
<SelectPrimitive.Separator
|
||||||
|
ref={ref}
|
||||||
|
className={cn("-mx-1 my-1 h-px bg-muted", className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
))
|
||||||
|
SelectSeparator.displayName = SelectPrimitive.Separator.displayName
|
||||||
|
|
||||||
|
export {
|
||||||
|
Select,
|
||||||
|
SelectGroup,
|
||||||
|
SelectValue,
|
||||||
|
SelectTrigger,
|
||||||
|
SelectContent,
|
||||||
|
SelectLabel,
|
||||||
|
SelectItem,
|
||||||
|
SelectSeparator,
|
||||||
|
SelectScrollUpButton,
|
||||||
|
SelectScrollDownButton,
|
||||||
|
}
|
||||||
14
components/ui/separator.tsx
Normal file
14
components/ui/separator.tsx
Normal file
@@ -0,0 +1,14 @@
|
|||||||
|
"use client"
|
||||||
|
import * as React from "react"
|
||||||
|
import * as SeparatorPrimitive from "@radix-ui/react-separator"
|
||||||
|
import { cn } from "@/lib/utils"
|
||||||
|
const Separator = React.forwardRef<
|
||||||
|
React.ElementRef<typeof SeparatorPrimitive.Root>,
|
||||||
|
React.ComponentPropsWithoutRef<typeof SeparatorPrimitive.Root>
|
||||||
|
>(({ className, orientation = "horizontal", decorative = true, ...props }, ref) => (
|
||||||
|
<SeparatorPrimitive.Root ref={ref} decorative={decorative} orientation={orientation}
|
||||||
|
className={cn("shrink-0 bg-border", orientation === "horizontal" ? "h-[1px] w-full" : "h-full w-[1px]", className)}
|
||||||
|
{...props} />
|
||||||
|
))
|
||||||
|
Separator.displayName = SeparatorPrimitive.Root.displayName
|
||||||
|
export { Separator }
|
||||||
109
components/ui/sheet.tsx
Normal file
109
components/ui/sheet.tsx
Normal file
@@ -0,0 +1,109 @@
|
|||||||
|
"use client"
|
||||||
|
|
||||||
|
import * as React from "react"
|
||||||
|
import * as SheetPrimitive from "@radix-ui/react-dialog"
|
||||||
|
import { cva, type VariantProps } from "class-variance-authority"
|
||||||
|
import { X } from "lucide-react"
|
||||||
|
|
||||||
|
import { cn } from "@/lib/utils"
|
||||||
|
|
||||||
|
const Sheet = SheetPrimitive.Root
|
||||||
|
|
||||||
|
const SheetTrigger = SheetPrimitive.Trigger
|
||||||
|
|
||||||
|
const SheetClose = SheetPrimitive.Close
|
||||||
|
|
||||||
|
const SheetPortal = SheetPrimitive.Portal
|
||||||
|
|
||||||
|
const SheetOverlay = React.forwardRef<
|
||||||
|
React.ElementRef<typeof SheetPrimitive.Overlay>,
|
||||||
|
React.ComponentPropsWithoutRef<typeof SheetPrimitive.Overlay>
|
||||||
|
>(({ className, ...props }, ref) => (
|
||||||
|
<SheetPrimitive.Overlay
|
||||||
|
className={cn(
|
||||||
|
"fixed inset-0 z-50 bg-black/80 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0",
|
||||||
|
className,
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
ref={ref}
|
||||||
|
/>
|
||||||
|
))
|
||||||
|
SheetOverlay.displayName = SheetPrimitive.Overlay.displayName
|
||||||
|
|
||||||
|
const sheetVariants = cva(
|
||||||
|
"fixed z-50 gap-4 bg-background p-6 shadow-lg transition ease-in-out data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:duration-300 data-[state=open]:duration-500",
|
||||||
|
{
|
||||||
|
variants: {
|
||||||
|
side: {
|
||||||
|
top: "inset-x-0 top-0 border-b data-[state=closed]:slide-out-to-top data-[state=open]:slide-in-from-top",
|
||||||
|
bottom:
|
||||||
|
"inset-x-0 bottom-0 border-t data-[state=closed]:slide-out-to-bottom data-[state=open]:slide-in-from-bottom",
|
||||||
|
left: "inset-y-0 left-0 h-full w-3/4 border-r data-[state=closed]:slide-out-to-left data-[state=open]:slide-in-from-left sm:max-w-sm",
|
||||||
|
right:
|
||||||
|
"inset-y-0 right-0 h-full w-3/4 border-l data-[state=closed]:slide-out-to-right data-[state=open]:slide-in-from-right sm:max-w-sm",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
defaultVariants: {
|
||||||
|
side: "right",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
interface SheetContentProps
|
||||||
|
extends React.ComponentPropsWithoutRef<typeof SheetPrimitive.Content>,
|
||||||
|
VariantProps<typeof sheetVariants> {}
|
||||||
|
|
||||||
|
const SheetContent = React.forwardRef<React.ElementRef<typeof SheetPrimitive.Content>, SheetContentProps>(
|
||||||
|
({ side = "right", className, children, ...props }, ref) => (
|
||||||
|
<SheetPortal>
|
||||||
|
<SheetOverlay />
|
||||||
|
<SheetPrimitive.Content ref={ref} className={cn(sheetVariants({ side }), className)} {...props}>
|
||||||
|
{children}
|
||||||
|
<SheetPrimitive.Close className="absolute right-4 top-4 rounded-sm opacity-70 ring-offset-background transition-opacity hover:opacity-100 focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2 disabled:pointer-events-none data-[state=open]:bg-secondary">
|
||||||
|
<X className="h-4 w-4" />
|
||||||
|
<span className="sr-only">Close</span>
|
||||||
|
</SheetPrimitive.Close>
|
||||||
|
</SheetPrimitive.Content>
|
||||||
|
</SheetPortal>
|
||||||
|
),
|
||||||
|
)
|
||||||
|
SheetContent.displayName = SheetPrimitive.Content.displayName
|
||||||
|
|
||||||
|
const SheetHeader = ({ className, ...props }: React.HTMLAttributes<HTMLDivElement>) => (
|
||||||
|
<div className={cn("flex flex-col space-y-2 text-center sm:text-left", className)} {...props} />
|
||||||
|
)
|
||||||
|
SheetHeader.displayName = "SheetHeader"
|
||||||
|
|
||||||
|
const SheetFooter = ({ className, ...props }: React.HTMLAttributes<HTMLDivElement>) => (
|
||||||
|
<div className={cn("flex flex-col-reverse sm:flex-row sm:justify-end sm:space-x-2", className)} {...props} />
|
||||||
|
)
|
||||||
|
SheetFooter.displayName = "SheetFooter"
|
||||||
|
|
||||||
|
const SheetTitle = React.forwardRef<
|
||||||
|
React.ElementRef<typeof SheetPrimitive.Title>,
|
||||||
|
React.ComponentPropsWithoutRef<typeof SheetPrimitive.Title>
|
||||||
|
>(({ className, ...props }, ref) => (
|
||||||
|
<SheetPrimitive.Title ref={ref} className={cn("text-lg font-semibold text-foreground", className)} {...props} />
|
||||||
|
))
|
||||||
|
SheetTitle.displayName = SheetPrimitive.Title.displayName
|
||||||
|
|
||||||
|
const SheetDescription = React.forwardRef<
|
||||||
|
React.ElementRef<typeof SheetPrimitive.Description>,
|
||||||
|
React.ComponentPropsWithoutRef<typeof SheetPrimitive.Description>
|
||||||
|
>(({ className, ...props }, ref) => (
|
||||||
|
<SheetPrimitive.Description ref={ref} className={cn("text-sm text-muted-foreground", className)} {...props} />
|
||||||
|
))
|
||||||
|
SheetDescription.displayName = SheetPrimitive.Description.displayName
|
||||||
|
|
||||||
|
export {
|
||||||
|
Sheet,
|
||||||
|
SheetPortal,
|
||||||
|
SheetOverlay,
|
||||||
|
SheetTrigger,
|
||||||
|
SheetClose,
|
||||||
|
SheetContent,
|
||||||
|
SheetHeader,
|
||||||
|
SheetFooter,
|
||||||
|
SheetTitle,
|
||||||
|
SheetDescription,
|
||||||
|
}
|
||||||
7
components/ui/skeleton.tsx
Normal file
7
components/ui/skeleton.tsx
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
import { cn } from "@/lib/utils"
|
||||||
|
|
||||||
|
function Skeleton({ className, ...props }: React.HTMLAttributes<HTMLDivElement>) {
|
||||||
|
return <div className={cn("animate-pulse rounded-md bg-muted", className)} {...props} />
|
||||||
|
}
|
||||||
|
|
||||||
|
export { Skeleton }
|
||||||
28
components/ui/switch.tsx
Normal file
28
components/ui/switch.tsx
Normal file
@@ -0,0 +1,28 @@
|
|||||||
|
"use client"
|
||||||
|
|
||||||
|
import * as React from "react"
|
||||||
|
import * as SwitchPrimitives from "@radix-ui/react-switch"
|
||||||
|
import { cn } from "@/lib/utils"
|
||||||
|
|
||||||
|
const Switch = React.forwardRef<
|
||||||
|
React.ElementRef<typeof SwitchPrimitives.Root>,
|
||||||
|
React.ComponentPropsWithoutRef<typeof SwitchPrimitives.Root>
|
||||||
|
>(({ className, ...props }, ref) => (
|
||||||
|
<SwitchPrimitives.Root
|
||||||
|
className={cn(
|
||||||
|
"peer inline-flex h-6 w-11 shrink-0 cursor-pointer items-center rounded-full border-2 border-transparent transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:ring-offset-background disabled:cursor-not-allowed disabled:opacity-50 data-[state=checked]:bg-primary data-[state=unchecked]:bg-input",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
ref={ref}
|
||||||
|
>
|
||||||
|
<SwitchPrimitives.Thumb
|
||||||
|
className={cn(
|
||||||
|
"pointer-events-none block h-5 w-5 rounded-full bg-background shadow-lg ring-0 transition-transform data-[state=checked]:translate-x-5 data-[state=unchecked]:translate-x-0"
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
</SwitchPrimitives.Root>
|
||||||
|
))
|
||||||
|
Switch.displayName = SwitchPrimitives.Root.displayName
|
||||||
|
|
||||||
|
export { Switch }
|
||||||
117
components/ui/table.tsx
Normal file
117
components/ui/table.tsx
Normal file
@@ -0,0 +1,117 @@
|
|||||||
|
import * as React from "react"
|
||||||
|
|
||||||
|
import { cn } from "@/lib/utils"
|
||||||
|
|
||||||
|
const Table = React.forwardRef<
|
||||||
|
HTMLTableElement,
|
||||||
|
React.HTMLAttributes<HTMLTableElement>
|
||||||
|
>(({ className, ...props }, ref) => (
|
||||||
|
<div className="relative w-full overflow-auto">
|
||||||
|
<table
|
||||||
|
ref={ref}
|
||||||
|
className={cn("w-full caption-bottom text-sm", className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
))
|
||||||
|
Table.displayName = "Table"
|
||||||
|
|
||||||
|
const TableHeader = React.forwardRef<
|
||||||
|
HTMLTableSectionElement,
|
||||||
|
React.HTMLAttributes<HTMLTableSectionElement>
|
||||||
|
>(({ className, ...props }, ref) => (
|
||||||
|
<thead ref={ref} className={cn("[&_tr]:border-b", className)} {...props} />
|
||||||
|
))
|
||||||
|
TableHeader.displayName = "TableHeader"
|
||||||
|
|
||||||
|
const TableBody = React.forwardRef<
|
||||||
|
HTMLTableSectionElement,
|
||||||
|
React.HTMLAttributes<HTMLTableSectionElement>
|
||||||
|
>(({ className, ...props }, ref) => (
|
||||||
|
<tbody
|
||||||
|
ref={ref}
|
||||||
|
className={cn("[&_tr:last-child]:border-0", className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
))
|
||||||
|
TableBody.displayName = "TableBody"
|
||||||
|
|
||||||
|
const TableFooter = React.forwardRef<
|
||||||
|
HTMLTableSectionElement,
|
||||||
|
React.HTMLAttributes<HTMLTableSectionElement>
|
||||||
|
>(({ className, ...props }, ref) => (
|
||||||
|
<tfoot
|
||||||
|
ref={ref}
|
||||||
|
className={cn(
|
||||||
|
"border-t bg-muted/50 font-medium [&>tr]:last:border-b-0",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
))
|
||||||
|
TableFooter.displayName = "TableFooter"
|
||||||
|
|
||||||
|
const TableRow = React.forwardRef<
|
||||||
|
HTMLTableRowElement,
|
||||||
|
React.HTMLAttributes<HTMLTableRowElement>
|
||||||
|
>(({ className, ...props }, ref) => (
|
||||||
|
<tr
|
||||||
|
ref={ref}
|
||||||
|
className={cn(
|
||||||
|
"border-b transition-colors hover:bg-muted/50 data-[state=selected]:bg-muted",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
))
|
||||||
|
TableRow.displayName = "TableRow"
|
||||||
|
|
||||||
|
const TableHead = React.forwardRef<
|
||||||
|
HTMLTableCellElement,
|
||||||
|
React.ThHTMLAttributes<HTMLTableCellElement>
|
||||||
|
>(({ className, ...props }, ref) => (
|
||||||
|
<th
|
||||||
|
ref={ref}
|
||||||
|
className={cn(
|
||||||
|
"h-12 px-4 text-left align-middle font-medium text-muted-foreground [&:has([role=checkbox])]:pr-0",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
))
|
||||||
|
TableHead.displayName = "TableHead"
|
||||||
|
|
||||||
|
const TableCell = React.forwardRef<
|
||||||
|
HTMLTableCellElement,
|
||||||
|
React.TdHTMLAttributes<HTMLTableCellElement>
|
||||||
|
>(({ className, ...props }, ref) => (
|
||||||
|
<td
|
||||||
|
ref={ref}
|
||||||
|
className={cn("p-4 align-middle [&:has([role=checkbox])]:pr-0", className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
))
|
||||||
|
TableCell.displayName = "TableCell"
|
||||||
|
|
||||||
|
const TableCaption = React.forwardRef<
|
||||||
|
HTMLTableCaptionElement,
|
||||||
|
React.HTMLAttributes<HTMLTableCaptionElement>
|
||||||
|
>(({ className, ...props }, ref) => (
|
||||||
|
<caption
|
||||||
|
ref={ref}
|
||||||
|
className={cn("mt-4 text-sm text-muted-foreground", className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
))
|
||||||
|
TableCaption.displayName = "TableCaption"
|
||||||
|
|
||||||
|
export {
|
||||||
|
Table,
|
||||||
|
TableHeader,
|
||||||
|
TableBody,
|
||||||
|
TableFooter,
|
||||||
|
TableHead,
|
||||||
|
TableRow,
|
||||||
|
TableCell,
|
||||||
|
TableCaption,
|
||||||
|
}
|
||||||
55
components/ui/tabs.tsx
Normal file
55
components/ui/tabs.tsx
Normal file
@@ -0,0 +1,55 @@
|
|||||||
|
"use client"
|
||||||
|
|
||||||
|
import * as React from "react"
|
||||||
|
import * as TabsPrimitive from "@radix-ui/react-tabs"
|
||||||
|
|
||||||
|
import { cn } from "@/lib/utils"
|
||||||
|
|
||||||
|
const Tabs = TabsPrimitive.Root
|
||||||
|
|
||||||
|
const TabsList = React.forwardRef<
|
||||||
|
React.ElementRef<typeof TabsPrimitive.List>,
|
||||||
|
React.ComponentPropsWithoutRef<typeof TabsPrimitive.List>
|
||||||
|
>(({ className, ...props }, ref) => (
|
||||||
|
<TabsPrimitive.List
|
||||||
|
ref={ref}
|
||||||
|
className={cn(
|
||||||
|
"inline-flex h-10 items-center justify-center rounded-md bg-muted p-1 text-muted-foreground",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
))
|
||||||
|
TabsList.displayName = TabsPrimitive.List.displayName
|
||||||
|
|
||||||
|
const TabsTrigger = React.forwardRef<
|
||||||
|
React.ElementRef<typeof TabsPrimitive.Trigger>,
|
||||||
|
React.ComponentPropsWithoutRef<typeof TabsPrimitive.Trigger>
|
||||||
|
>(({ className, ...props }, ref) => (
|
||||||
|
<TabsPrimitive.Trigger
|
||||||
|
ref={ref}
|
||||||
|
className={cn(
|
||||||
|
"inline-flex items-center justify-center whitespace-nowrap rounded-sm px-3 py-1.5 text-sm font-medium ring-offset-background transition-all focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50 data-[state=active]:bg-background data-[state=active]:text-foreground data-[state=active]:shadow-sm",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
))
|
||||||
|
TabsTrigger.displayName = TabsPrimitive.Trigger.displayName
|
||||||
|
|
||||||
|
const TabsContent = React.forwardRef<
|
||||||
|
React.ElementRef<typeof TabsPrimitive.Content>,
|
||||||
|
React.ComponentPropsWithoutRef<typeof TabsPrimitive.Content>
|
||||||
|
>(({ className, ...props }, ref) => (
|
||||||
|
<TabsPrimitive.Content
|
||||||
|
ref={ref}
|
||||||
|
className={cn(
|
||||||
|
"mt-2 ring-offset-background focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
))
|
||||||
|
TabsContent.displayName = TabsPrimitive.Content.displayName
|
||||||
|
|
||||||
|
export { Tabs, TabsList, TabsTrigger, TabsContent }
|
||||||
22
components/ui/textarea.tsx
Normal file
22
components/ui/textarea.tsx
Normal file
@@ -0,0 +1,22 @@
|
|||||||
|
import * as React from "react"
|
||||||
|
|
||||||
|
import { cn } from "@/lib/utils"
|
||||||
|
|
||||||
|
const Textarea = React.forwardRef<
|
||||||
|
HTMLTextAreaElement,
|
||||||
|
React.ComponentProps<"textarea">
|
||||||
|
>(({ className, ...props }, ref) => {
|
||||||
|
return (
|
||||||
|
<textarea
|
||||||
|
className={cn(
|
||||||
|
"flex min-h-[80px] w-full rounded-md border border-input bg-background px-3 py-2 text-base ring-offset-background placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50 md:text-sm",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
ref={ref}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
})
|
||||||
|
Textarea.displayName = "Textarea"
|
||||||
|
|
||||||
|
export { Textarea }
|
||||||
113
components/ui/toast.tsx
Normal file
113
components/ui/toast.tsx
Normal file
@@ -0,0 +1,113 @@
|
|||||||
|
"use client"
|
||||||
|
|
||||||
|
import * as React from "react"
|
||||||
|
import * as ToastPrimitives from "@radix-ui/react-toast"
|
||||||
|
import { cva, type VariantProps } from "class-variance-authority"
|
||||||
|
import { X } from "lucide-react"
|
||||||
|
|
||||||
|
import { cn } from "@/lib/utils"
|
||||||
|
|
||||||
|
const ToastProvider = ToastPrimitives.Provider
|
||||||
|
|
||||||
|
const ToastViewport = React.forwardRef<
|
||||||
|
React.ElementRef<typeof ToastPrimitives.Viewport>,
|
||||||
|
React.ComponentPropsWithoutRef<typeof ToastPrimitives.Viewport>
|
||||||
|
>(({ className, ...props }, ref) => (
|
||||||
|
<ToastPrimitives.Viewport
|
||||||
|
ref={ref}
|
||||||
|
className={cn(
|
||||||
|
"fixed top-0 z-[100] flex max-h-screen w-full flex-col-reverse p-4 sm:bottom-0 sm:right-0 sm:top-auto sm:flex-col md:max-w-[420px]",
|
||||||
|
className,
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
))
|
||||||
|
ToastViewport.displayName = ToastPrimitives.Viewport.displayName
|
||||||
|
|
||||||
|
const toastVariants = cva(
|
||||||
|
"group pointer-events-auto relative flex w-full items-center justify-between space-x-4 overflow-hidden rounded-md border p-6 pr-8 shadow-lg transition-all data-[swipe=cancel]:translate-x-0 data-[swipe=end]:translate-x-[var(--radix-toast-swipe-end-x)] data-[swipe=move]:translate-x-[var(--radix-toast-swipe-move-x)] data-[swipe=move]:transition-none data-[state=open]:animate-in data-[state=closed]:animate-out data-[swipe=end]:animate-out data-[state=closed]:fade-out-80 data-[state=closed]:slide-out-to-right-full data-[state=open]:slide-in-from-top-full data-[state=open]:sm:slide-in-from-bottom-full",
|
||||||
|
{
|
||||||
|
variants: {
|
||||||
|
variant: {
|
||||||
|
default: "border bg-background text-foreground",
|
||||||
|
destructive: "destructive group border-destructive bg-destructive text-destructive-foreground",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
defaultVariants: {
|
||||||
|
variant: "default",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
const Toast = React.forwardRef<
|
||||||
|
React.ElementRef<typeof ToastPrimitives.Root>,
|
||||||
|
React.ComponentPropsWithoutRef<typeof ToastPrimitives.Root> & VariantProps<typeof toastVariants>
|
||||||
|
>(({ className, variant, ...props }, ref) => {
|
||||||
|
return <ToastPrimitives.Root ref={ref} className={cn(toastVariants({ variant }), className)} {...props} />
|
||||||
|
})
|
||||||
|
Toast.displayName = ToastPrimitives.Root.displayName
|
||||||
|
|
||||||
|
const ToastAction = React.forwardRef<
|
||||||
|
React.ElementRef<typeof ToastPrimitives.Action>,
|
||||||
|
React.ComponentPropsWithoutRef<typeof ToastPrimitives.Action>
|
||||||
|
>(({ className, ...props }, ref) => (
|
||||||
|
<ToastPrimitives.Action
|
||||||
|
ref={ref}
|
||||||
|
className={cn(
|
||||||
|
"inline-flex h-8 shrink-0 items-center justify-center rounded-md border bg-transparent px-3 text-sm font-medium ring-offset-background transition-colors hover:bg-secondary focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2 disabled:pointer-events-none disabled:opacity-50 group-[.destructive]:border-muted/40 group-[.destructive]:hover:border-destructive/30 group-[.destructive]:hover:bg-destructive group-[.destructive]:hover:text-destructive-foreground group-[.destructive]:focus:ring-destructive",
|
||||||
|
className,
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
))
|
||||||
|
ToastAction.displayName = ToastPrimitives.Action.displayName
|
||||||
|
|
||||||
|
const ToastClose = React.forwardRef<
|
||||||
|
React.ElementRef<typeof ToastPrimitives.Close>,
|
||||||
|
React.ComponentPropsWithoutRef<typeof ToastPrimitives.Close>
|
||||||
|
>(({ className, ...props }, ref) => (
|
||||||
|
<ToastPrimitives.Close
|
||||||
|
ref={ref}
|
||||||
|
className={cn(
|
||||||
|
"absolute right-2 top-2 rounded-md p-1 text-foreground/50 opacity-0 transition-opacity hover:text-foreground focus:opacity-100 focus:outline-none focus:ring-2 group-hover:opacity-100 group-[.destructive]:text-red-300 group-[.destructive]:hover:text-red-50 group-[.destructive]:focus:ring-red-400 group-[.destructive]:focus:ring-offset-red-600",
|
||||||
|
className,
|
||||||
|
)}
|
||||||
|
toast-close=""
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
<X className="h-4 w-4" />
|
||||||
|
</ToastPrimitives.Close>
|
||||||
|
))
|
||||||
|
ToastClose.displayName = ToastPrimitives.Close.displayName
|
||||||
|
|
||||||
|
const ToastTitle = React.forwardRef<
|
||||||
|
React.ElementRef<typeof ToastPrimitives.Title>,
|
||||||
|
React.ComponentPropsWithoutRef<typeof ToastPrimitives.Title>
|
||||||
|
>(({ className, ...props }, ref) => (
|
||||||
|
<ToastPrimitives.Title ref={ref} className={cn("text-sm font-semibold", className)} {...props} />
|
||||||
|
))
|
||||||
|
ToastTitle.displayName = ToastPrimitives.Title.displayName
|
||||||
|
|
||||||
|
const ToastDescription = React.forwardRef<
|
||||||
|
React.ElementRef<typeof ToastPrimitives.Description>,
|
||||||
|
React.ComponentPropsWithoutRef<typeof ToastPrimitives.Description>
|
||||||
|
>(({ className, ...props }, ref) => (
|
||||||
|
<ToastPrimitives.Description ref={ref} className={cn("text-sm opacity-90", className)} {...props} />
|
||||||
|
))
|
||||||
|
ToastDescription.displayName = ToastPrimitives.Description.displayName
|
||||||
|
|
||||||
|
type ToastProps = React.ComponentPropsWithoutRef<typeof Toast>
|
||||||
|
|
||||||
|
type ToastActionElement = React.ReactElement<typeof ToastAction>
|
||||||
|
|
||||||
|
export {
|
||||||
|
type ToastProps,
|
||||||
|
type ToastActionElement,
|
||||||
|
ToastProvider,
|
||||||
|
ToastViewport,
|
||||||
|
Toast,
|
||||||
|
ToastTitle,
|
||||||
|
ToastDescription,
|
||||||
|
ToastClose,
|
||||||
|
ToastAction,
|
||||||
|
}
|
||||||
24
components/ui/toaster.tsx
Normal file
24
components/ui/toaster.tsx
Normal file
@@ -0,0 +1,24 @@
|
|||||||
|
"use client"
|
||||||
|
|
||||||
|
import { Toast, ToastClose, ToastDescription, ToastProvider, ToastTitle, ToastViewport } from "@/components/ui/toast"
|
||||||
|
import { useToast } from "@/hooks/use-toast"
|
||||||
|
|
||||||
|
export function Toaster() {
|
||||||
|
const { toasts } = useToast()
|
||||||
|
|
||||||
|
return (
|
||||||
|
<ToastProvider>
|
||||||
|
{toasts.map(({ id, title, description, action, ...props }) => (
|
||||||
|
<Toast key={id} {...props}>
|
||||||
|
<div className="grid gap-1">
|
||||||
|
{title && <ToastTitle>{title}</ToastTitle>}
|
||||||
|
{description && <ToastDescription>{description}</ToastDescription>}
|
||||||
|
</div>
|
||||||
|
{action}
|
||||||
|
<ToastClose />
|
||||||
|
</Toast>
|
||||||
|
))}
|
||||||
|
<ToastViewport />
|
||||||
|
</ToastProvider>
|
||||||
|
)
|
||||||
|
}
|
||||||
194
components/ui/use-toast.ts
Normal file
194
components/ui/use-toast.ts
Normal file
@@ -0,0 +1,194 @@
|
|||||||
|
"use client"
|
||||||
|
|
||||||
|
// Inspired by react-hot-toast library
|
||||||
|
import * as React from "react"
|
||||||
|
|
||||||
|
import type {
|
||||||
|
ToastActionElement,
|
||||||
|
ToastProps,
|
||||||
|
} from "@/components/ui/toast"
|
||||||
|
|
||||||
|
const TOAST_LIMIT = 1
|
||||||
|
const TOAST_REMOVE_DELAY = 1000000
|
||||||
|
|
||||||
|
type ToasterToast = ToastProps & {
|
||||||
|
id: string
|
||||||
|
title?: React.ReactNode
|
||||||
|
description?: React.ReactNode
|
||||||
|
action?: ToastActionElement
|
||||||
|
}
|
||||||
|
|
||||||
|
const actionTypes = {
|
||||||
|
ADD_TOAST: "ADD_TOAST",
|
||||||
|
UPDATE_TOAST: "UPDATE_TOAST",
|
||||||
|
DISMISS_TOAST: "DISMISS_TOAST",
|
||||||
|
REMOVE_TOAST: "REMOVE_TOAST",
|
||||||
|
} as const
|
||||||
|
|
||||||
|
let count = 0
|
||||||
|
|
||||||
|
function genId() {
|
||||||
|
count = (count + 1) % Number.MAX_SAFE_INTEGER
|
||||||
|
return count.toString()
|
||||||
|
}
|
||||||
|
|
||||||
|
type ActionType = typeof actionTypes
|
||||||
|
|
||||||
|
type Action =
|
||||||
|
| {
|
||||||
|
type: ActionType["ADD_TOAST"]
|
||||||
|
toast: ToasterToast
|
||||||
|
}
|
||||||
|
| {
|
||||||
|
type: ActionType["UPDATE_TOAST"]
|
||||||
|
toast: Partial<ToasterToast>
|
||||||
|
}
|
||||||
|
| {
|
||||||
|
type: ActionType["DISMISS_TOAST"]
|
||||||
|
toastId?: ToasterToast["id"]
|
||||||
|
}
|
||||||
|
| {
|
||||||
|
type: ActionType["REMOVE_TOAST"]
|
||||||
|
toastId?: ToasterToast["id"]
|
||||||
|
}
|
||||||
|
|
||||||
|
interface State {
|
||||||
|
toasts: ToasterToast[]
|
||||||
|
}
|
||||||
|
|
||||||
|
const toastTimeouts = new Map<string, ReturnType<typeof setTimeout>>()
|
||||||
|
|
||||||
|
const addToRemoveQueue = (toastId: string) => {
|
||||||
|
if (toastTimeouts.has(toastId)) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const timeout = setTimeout(() => {
|
||||||
|
toastTimeouts.delete(toastId)
|
||||||
|
dispatch({
|
||||||
|
type: "REMOVE_TOAST",
|
||||||
|
toastId: toastId,
|
||||||
|
})
|
||||||
|
}, TOAST_REMOVE_DELAY)
|
||||||
|
|
||||||
|
toastTimeouts.set(toastId, timeout)
|
||||||
|
}
|
||||||
|
|
||||||
|
export const reducer = (state: State, action: Action): State => {
|
||||||
|
switch (action.type) {
|
||||||
|
case "ADD_TOAST":
|
||||||
|
return {
|
||||||
|
...state,
|
||||||
|
toasts: [action.toast, ...state.toasts].slice(0, TOAST_LIMIT),
|
||||||
|
}
|
||||||
|
|
||||||
|
case "UPDATE_TOAST":
|
||||||
|
return {
|
||||||
|
...state,
|
||||||
|
toasts: state.toasts.map((t) =>
|
||||||
|
t.id === action.toast.id ? { ...t, ...action.toast } : t
|
||||||
|
),
|
||||||
|
}
|
||||||
|
|
||||||
|
case "DISMISS_TOAST": {
|
||||||
|
const { toastId } = action
|
||||||
|
|
||||||
|
// ! Side effects ! - This could be extracted into a dismissToast() action,
|
||||||
|
// but I'll keep it here for simplicity
|
||||||
|
if (toastId) {
|
||||||
|
addToRemoveQueue(toastId)
|
||||||
|
} else {
|
||||||
|
state.toasts.forEach((toast) => {
|
||||||
|
addToRemoveQueue(toast.id)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
...state,
|
||||||
|
toasts: state.toasts.map((t) =>
|
||||||
|
t.id === toastId || toastId === undefined
|
||||||
|
? {
|
||||||
|
...t,
|
||||||
|
open: false,
|
||||||
|
}
|
||||||
|
: t
|
||||||
|
),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
case "REMOVE_TOAST":
|
||||||
|
if (action.toastId === undefined) {
|
||||||
|
return {
|
||||||
|
...state,
|
||||||
|
toasts: [],
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
...state,
|
||||||
|
toasts: state.toasts.filter((t) => t.id !== action.toastId),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const listeners: Array<(state: State) => void> = []
|
||||||
|
|
||||||
|
let memoryState: State = { toasts: [] }
|
||||||
|
|
||||||
|
function dispatch(action: Action) {
|
||||||
|
memoryState = reducer(memoryState, action)
|
||||||
|
listeners.forEach((listener) => {
|
||||||
|
listener(memoryState)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
type Toast = Omit<ToasterToast, "id">
|
||||||
|
|
||||||
|
function toast({ ...props }: Toast) {
|
||||||
|
const id = genId()
|
||||||
|
|
||||||
|
const update = (props: ToasterToast) =>
|
||||||
|
dispatch({
|
||||||
|
type: "UPDATE_TOAST",
|
||||||
|
toast: { ...props, id },
|
||||||
|
})
|
||||||
|
const dismiss = () => dispatch({ type: "DISMISS_TOAST", toastId: id })
|
||||||
|
|
||||||
|
dispatch({
|
||||||
|
type: "ADD_TOAST",
|
||||||
|
toast: {
|
||||||
|
...props,
|
||||||
|
id,
|
||||||
|
open: true,
|
||||||
|
onOpenChange: (open) => {
|
||||||
|
if (!open) dismiss()
|
||||||
|
},
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
return {
|
||||||
|
id: id,
|
||||||
|
dismiss,
|
||||||
|
update,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function useToast() {
|
||||||
|
const [state, setState] = React.useState<State>(memoryState)
|
||||||
|
|
||||||
|
React.useEffect(() => {
|
||||||
|
listeners.push(setState)
|
||||||
|
return () => {
|
||||||
|
const index = listeners.indexOf(setState)
|
||||||
|
if (index > -1) {
|
||||||
|
listeners.splice(index, 1)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}, [state])
|
||||||
|
|
||||||
|
return {
|
||||||
|
...state,
|
||||||
|
toast,
|
||||||
|
dismiss: (toastId?: string) => dispatch({ type: "DISMISS_TOAST", toastId }),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export { useToast, toast }
|
||||||
18
data/data.tsx
Normal file
18
data/data.tsx
Normal file
@@ -0,0 +1,18 @@
|
|||||||
|
export const labels = {
|
||||||
|
name: "Nome",
|
||||||
|
price: "Preço",
|
||||||
|
type: "Tipo",
|
||||||
|
validity: "Validade",
|
||||||
|
}
|
||||||
|
|
||||||
|
export const priorities = [
|
||||||
|
{ label: "Alta", value: "high" },
|
||||||
|
{ label: "Média", value: "medium" },
|
||||||
|
{ label: "Baixa", value: "low" },
|
||||||
|
]
|
||||||
|
|
||||||
|
export const statuses = [
|
||||||
|
{ label: "Aberto", value: "open" },
|
||||||
|
{ label: "Em andamento", value: "in progress" },
|
||||||
|
{ label: "Concluído", value: "completed" },
|
||||||
|
]
|
||||||
189
hooks/use-toast.ts
Normal file
189
hooks/use-toast.ts
Normal file
@@ -0,0 +1,189 @@
|
|||||||
|
"use client"
|
||||||
|
|
||||||
|
// Inspired by react-hot-toast library
|
||||||
|
import * as React from "react"
|
||||||
|
|
||||||
|
import type { ToastActionElement, ToastProps } from "@/components/ui/toast"
|
||||||
|
|
||||||
|
const TOAST_LIMIT = 1
|
||||||
|
const TOAST_REMOVE_DELAY = 1000000
|
||||||
|
|
||||||
|
type ToasterToast = ToastProps & {
|
||||||
|
id: string
|
||||||
|
title?: React.ReactNode
|
||||||
|
description?: React.ReactNode
|
||||||
|
action?: ToastActionElement
|
||||||
|
}
|
||||||
|
|
||||||
|
const actionTypes = {
|
||||||
|
ADD_TOAST: "ADD_TOAST",
|
||||||
|
UPDATE_TOAST: "UPDATE_TOAST",
|
||||||
|
DISMISS_TOAST: "DISMISS_TOAST",
|
||||||
|
REMOVE_TOAST: "REMOVE_TOAST",
|
||||||
|
} as const
|
||||||
|
|
||||||
|
let count = 0
|
||||||
|
|
||||||
|
function genId() {
|
||||||
|
count = (count + 1) % Number.MAX_VALUE
|
||||||
|
return count.toString()
|
||||||
|
}
|
||||||
|
|
||||||
|
type ActionType = typeof actionTypes
|
||||||
|
|
||||||
|
type Action =
|
||||||
|
| {
|
||||||
|
type: ActionType["ADD_TOAST"]
|
||||||
|
toast: ToasterToast
|
||||||
|
}
|
||||||
|
| {
|
||||||
|
type: ActionType["UPDATE_TOAST"]
|
||||||
|
toast: Partial<ToasterToast>
|
||||||
|
}
|
||||||
|
| {
|
||||||
|
type: ActionType["DISMISS_TOAST"]
|
||||||
|
toastId?: string
|
||||||
|
}
|
||||||
|
| {
|
||||||
|
type: ActionType["REMOVE_TOAST"]
|
||||||
|
toastId?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
interface State {
|
||||||
|
toasts: ToasterToast[]
|
||||||
|
}
|
||||||
|
|
||||||
|
const toastTimeouts = new Map<string, ReturnType<typeof setTimeout>>()
|
||||||
|
|
||||||
|
const reducer = (state: State, action: Action): State => {
|
||||||
|
switch (action.type) {
|
||||||
|
case "ADD_TOAST":
|
||||||
|
return {
|
||||||
|
...state,
|
||||||
|
toasts: [action.toast, ...state.toasts].slice(0, TOAST_LIMIT),
|
||||||
|
}
|
||||||
|
|
||||||
|
case "UPDATE_TOAST":
|
||||||
|
return {
|
||||||
|
...state,
|
||||||
|
toasts: state.toasts.map((t) => (t.id === action.toast.id ? { ...t, ...action.toast } : t)),
|
||||||
|
}
|
||||||
|
|
||||||
|
case "DISMISS_TOAST": {
|
||||||
|
const { toastId } = action
|
||||||
|
|
||||||
|
// ! Side effects ! - This could be extracted into a dismissToast() action,
|
||||||
|
// but I'll keep it here for simplicity
|
||||||
|
if (toastId) {
|
||||||
|
addToRemoveQueue(toastId)
|
||||||
|
} else {
|
||||||
|
state.toasts.forEach((toast) => {
|
||||||
|
addToRemoveQueue(toast.id)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
...state,
|
||||||
|
toasts: state.toasts.map((t) =>
|
||||||
|
t.id === toastId || toastId === undefined
|
||||||
|
? {
|
||||||
|
...t,
|
||||||
|
open: false,
|
||||||
|
}
|
||||||
|
: t,
|
||||||
|
),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
case "REMOVE_TOAST":
|
||||||
|
if (action.toastId === undefined) {
|
||||||
|
return {
|
||||||
|
...state,
|
||||||
|
toasts: [],
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
...state,
|
||||||
|
toasts: state.toasts.filter((t) => t.id !== action.toastId),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const listeners: Array<(state: State) => void> = []
|
||||||
|
|
||||||
|
let memoryState: State = { toasts: [] }
|
||||||
|
|
||||||
|
function dispatch(action: Action) {
|
||||||
|
memoryState = reducer(memoryState, action)
|
||||||
|
listeners.forEach((listener) => {
|
||||||
|
listener(memoryState)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
type Toast = Omit<ToasterToast, "id">
|
||||||
|
|
||||||
|
function toast({ ...props }: Toast) {
|
||||||
|
const id = genId()
|
||||||
|
|
||||||
|
const update = (props: ToasterToast) =>
|
||||||
|
dispatch({
|
||||||
|
type: "UPDATE_TOAST",
|
||||||
|
toast: { ...props, id },
|
||||||
|
})
|
||||||
|
const dismiss = () => dispatch({ type: "DISMISS_TOAST", toastId: id })
|
||||||
|
|
||||||
|
dispatch({
|
||||||
|
type: "ADD_TOAST",
|
||||||
|
toast: {
|
||||||
|
...props,
|
||||||
|
id,
|
||||||
|
open: true,
|
||||||
|
onOpenChange: (open) => {
|
||||||
|
if (!open) dismiss()
|
||||||
|
},
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
return {
|
||||||
|
id: id,
|
||||||
|
dismiss,
|
||||||
|
update,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function useToast() {
|
||||||
|
const [state, setState] = React.useState<State>(memoryState)
|
||||||
|
|
||||||
|
React.useEffect(() => {
|
||||||
|
listeners.push(setState)
|
||||||
|
return () => {
|
||||||
|
const index = listeners.indexOf(setState)
|
||||||
|
if (index > -1) {
|
||||||
|
listeners.splice(index, 1)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}, [state])
|
||||||
|
|
||||||
|
return {
|
||||||
|
...state,
|
||||||
|
toast,
|
||||||
|
dismiss: (toastId?: string) => dispatch({ type: "DISMISS_TOAST", toastId }),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function addToRemoveQueue(toastId: string) {
|
||||||
|
if (toastTimeouts.has(toastId)) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const timeout = setTimeout(() => {
|
||||||
|
toastTimeouts.delete(toastId)
|
||||||
|
dispatch({
|
||||||
|
type: "REMOVE_TOAST",
|
||||||
|
toastId,
|
||||||
|
})
|
||||||
|
}, TOAST_REMOVE_DELAY)
|
||||||
|
|
||||||
|
toastTimeouts.set(toastId, timeout)
|
||||||
|
}
|
||||||
|
|
||||||
|
export { useToast, toast }
|
||||||
424
lib/actions.ts
Normal file
424
lib/actions.ts
Normal file
@@ -0,0 +1,424 @@
|
|||||||
|
"use server"
|
||||||
|
|
||||||
|
import { createServiceClient } from "@/lib/supabase"
|
||||||
|
import { buscarOuCriarClienteAsaas, criarPagamentoAsaas, type MetodoPagamento } from "@/lib/asaas"
|
||||||
|
import { revalidatePath } from "next/cache"
|
||||||
|
|
||||||
|
// =============================================
|
||||||
|
// PRODUTOS
|
||||||
|
// =============================================
|
||||||
|
|
||||||
|
export async function getProdutos() {
|
||||||
|
const supabase = createServiceClient()
|
||||||
|
const { data, error } = await supabase
|
||||||
|
.from("produtos")
|
||||||
|
.select("*")
|
||||||
|
.eq("ativo", true)
|
||||||
|
.order("preco_centavos", { ascending: true })
|
||||||
|
|
||||||
|
if (error) throw new Error(error.message)
|
||||||
|
return data
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getProdutoById(id: string) {
|
||||||
|
const supabase = createServiceClient()
|
||||||
|
const { data, error } = await supabase
|
||||||
|
.from("produtos")
|
||||||
|
.select("*")
|
||||||
|
.eq("id", id)
|
||||||
|
.single()
|
||||||
|
|
||||||
|
if (error) throw new Error(error.message)
|
||||||
|
return data
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function createProduto(data: {
|
||||||
|
nome: string
|
||||||
|
descricao?: string
|
||||||
|
tipo: "PF" | "PJ" | "SSL" | "NFe"
|
||||||
|
validade: string
|
||||||
|
midia?: string
|
||||||
|
preco_centavos: number
|
||||||
|
imagem_url?: string
|
||||||
|
}) {
|
||||||
|
const supabase = createServiceClient()
|
||||||
|
const { error } = await supabase.from("produtos").insert(data)
|
||||||
|
if (error) throw new Error(error.message)
|
||||||
|
revalidatePath("/admin")
|
||||||
|
revalidatePath("/produtos")
|
||||||
|
return { success: true }
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function updateProduto(
|
||||||
|
id: string,
|
||||||
|
data: Partial<{
|
||||||
|
nome: string
|
||||||
|
descricao: string
|
||||||
|
tipo: "PF" | "PJ" | "SSL" | "NFe"
|
||||||
|
validade: string
|
||||||
|
midia: string
|
||||||
|
preco_centavos: number
|
||||||
|
ativo: boolean
|
||||||
|
imagem_url: string
|
||||||
|
}>
|
||||||
|
) {
|
||||||
|
const supabase = createServiceClient()
|
||||||
|
const { error } = await supabase.from("produtos").update(data).eq("id", id)
|
||||||
|
if (error) throw new Error(error.message)
|
||||||
|
revalidatePath("/admin")
|
||||||
|
revalidatePath("/produtos")
|
||||||
|
return { success: true }
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function deleteProduto(id: string) {
|
||||||
|
const supabase = createServiceClient()
|
||||||
|
const { error } = await supabase.from("produtos").update({ ativo: false }).eq("id", id)
|
||||||
|
if (error) throw new Error(error.message)
|
||||||
|
revalidatePath("/admin")
|
||||||
|
return { success: true }
|
||||||
|
}
|
||||||
|
|
||||||
|
// =============================================
|
||||||
|
// CLIENTES
|
||||||
|
// =============================================
|
||||||
|
|
||||||
|
export async function getClientes(options: { limit?: number; search?: string } = {}) {
|
||||||
|
const supabase = createServiceClient()
|
||||||
|
let query = supabase.from("clientes").select("*").order("created_at", { ascending: false })
|
||||||
|
|
||||||
|
if (options.search) {
|
||||||
|
query = query.or(
|
||||||
|
`nome.ilike.%${options.search}%,email.ilike.%${options.search}%,cpf_cnpj.ilike.%${options.search}%`
|
||||||
|
)
|
||||||
|
}
|
||||||
|
if (options.limit) query = query.limit(options.limit)
|
||||||
|
|
||||||
|
const { data, error } = await query
|
||||||
|
if (error) throw new Error(error.message)
|
||||||
|
return data
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function createCliente(data: {
|
||||||
|
nome: string
|
||||||
|
email: string
|
||||||
|
telefone?: string
|
||||||
|
cpf_cnpj?: string
|
||||||
|
}) {
|
||||||
|
const supabase = createServiceClient()
|
||||||
|
const { data: cliente, error } = await supabase
|
||||||
|
.from("clientes")
|
||||||
|
.insert(data)
|
||||||
|
.select()
|
||||||
|
.single()
|
||||||
|
|
||||||
|
if (error) throw new Error(error.message)
|
||||||
|
revalidatePath("/admin")
|
||||||
|
return cliente
|
||||||
|
}
|
||||||
|
|
||||||
|
// =============================================
|
||||||
|
// CHECKOUT — cria cliente no ASAAS + pedido
|
||||||
|
// =============================================
|
||||||
|
|
||||||
|
export async function criarCheckout(input: {
|
||||||
|
produtoId: string
|
||||||
|
cliente: {
|
||||||
|
nome: string
|
||||||
|
email: string
|
||||||
|
cpfCnpj: string
|
||||||
|
telefone?: string
|
||||||
|
}
|
||||||
|
metodo: MetodoPagamento
|
||||||
|
cartao?: {
|
||||||
|
holderName: string
|
||||||
|
number: string
|
||||||
|
expiryMonth: string
|
||||||
|
expiryYear: string
|
||||||
|
ccv: string
|
||||||
|
postalCode: string
|
||||||
|
addressNumber: string
|
||||||
|
}
|
||||||
|
}) {
|
||||||
|
const supabase = createServiceClient()
|
||||||
|
|
||||||
|
// 1. Busca produto
|
||||||
|
const { data: produto, error: produtoError } = await supabase
|
||||||
|
.from("produtos")
|
||||||
|
.select("*")
|
||||||
|
.eq("id", input.produtoId)
|
||||||
|
.eq("ativo", true)
|
||||||
|
.single()
|
||||||
|
|
||||||
|
if (produtoError || !produto) throw new Error("Produto não encontrado")
|
||||||
|
|
||||||
|
// 2. Busca ou cria cliente no banco (por email ou CPF/CNPJ)
|
||||||
|
let cliente = await supabase
|
||||||
|
.from("clientes")
|
||||||
|
.select("*")
|
||||||
|
.or(`email.eq.${input.cliente.email},cpf_cnpj.eq.${input.cliente.cpfCnpj}`)
|
||||||
|
.limit(1)
|
||||||
|
.single()
|
||||||
|
.then(({ data }) => data)
|
||||||
|
|
||||||
|
if (!cliente) {
|
||||||
|
const { data: novoCliente, error } = await supabase
|
||||||
|
.from("clientes")
|
||||||
|
.upsert({
|
||||||
|
nome: input.cliente.nome,
|
||||||
|
email: input.cliente.email,
|
||||||
|
telefone: input.cliente.telefone,
|
||||||
|
cpf_cnpj: input.cliente.cpfCnpj,
|
||||||
|
}, { onConflict: "cpf_cnpj" })
|
||||||
|
.select()
|
||||||
|
.single()
|
||||||
|
|
||||||
|
if (error) throw new Error(error.message)
|
||||||
|
cliente = novoCliente
|
||||||
|
}
|
||||||
|
|
||||||
|
// 3. Busca ou cria cliente no ASAAS
|
||||||
|
const asaasCliente = await buscarOuCriarClienteAsaas({
|
||||||
|
nome: input.cliente.nome,
|
||||||
|
email: input.cliente.email,
|
||||||
|
cpfCnpj: input.cliente.cpfCnpj,
|
||||||
|
telefone: input.cliente.telefone,
|
||||||
|
})
|
||||||
|
|
||||||
|
// Atualiza asaas_id no banco se necessário
|
||||||
|
if (!cliente.asaas_id) {
|
||||||
|
await supabase.from("clientes").update({ asaas_id: asaasCliente.id }).eq("id", cliente.id)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 4. Cria pagamento no ASAAS
|
||||||
|
const valorReais = produto.preco_centavos / 100
|
||||||
|
|
||||||
|
const asaasPayment = await criarPagamentoAsaas({
|
||||||
|
asaasClienteId: asaasCliente.id,
|
||||||
|
valor: valorReais,
|
||||||
|
descricao: `${produto.nome}`,
|
||||||
|
metodo: input.metodo,
|
||||||
|
cartao: input.cartao
|
||||||
|
? {
|
||||||
|
holderName: input.cartao.holderName,
|
||||||
|
number: input.cartao.number,
|
||||||
|
expiryMonth: input.cartao.expiryMonth,
|
||||||
|
expiryYear: input.cartao.expiryYear,
|
||||||
|
ccv: input.cartao.ccv,
|
||||||
|
}
|
||||||
|
: undefined,
|
||||||
|
cartaoHolder: input.cartao
|
||||||
|
? {
|
||||||
|
name: input.cliente.nome,
|
||||||
|
email: input.cliente.email,
|
||||||
|
cpfCnpj: input.cliente.cpfCnpj,
|
||||||
|
postalCode: input.cartao.postalCode,
|
||||||
|
addressNumber: input.cartao.addressNumber,
|
||||||
|
phone: input.cliente.telefone ?? "",
|
||||||
|
}
|
||||||
|
: undefined,
|
||||||
|
})
|
||||||
|
|
||||||
|
// 5. Salva pedido no Supabase
|
||||||
|
const { data: pedido, error: pedidoError } = await supabase
|
||||||
|
.from("pedidos")
|
||||||
|
.insert({
|
||||||
|
cliente_id: cliente.id,
|
||||||
|
produto_id: produto.id,
|
||||||
|
valor_centavos: produto.preco_centavos,
|
||||||
|
metodo_pagamento: input.metodo,
|
||||||
|
status: "PENDING",
|
||||||
|
asaas_payment_id: asaasPayment.id,
|
||||||
|
asaas_invoice_url: asaasPayment.invoiceUrl ?? asaasPayment.bankSlipUrl ?? null,
|
||||||
|
pix_copia_cola: asaasPayment.pixQrCode?.payload ?? null,
|
||||||
|
pix_qrcode_url: asaasPayment.pixQrCode?.encodedImage ?? null,
|
||||||
|
})
|
||||||
|
.select()
|
||||||
|
.single()
|
||||||
|
|
||||||
|
if (pedidoError) throw new Error(pedidoError.message)
|
||||||
|
|
||||||
|
// Dispara notificação PIX via n8n (fire-and-forget)
|
||||||
|
if (input.metodo === "PIX" && asaasPayment.pixQrCode?.payload) {
|
||||||
|
const n8nPixUrl = process.env.N8N_PIX_WEBHOOK_URL
|
||||||
|
if (n8nPixUrl) {
|
||||||
|
fetch(n8nPixUrl, {
|
||||||
|
method: "POST",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify({
|
||||||
|
pedido_id: pedido.id,
|
||||||
|
cliente: {
|
||||||
|
nome: input.cliente.nome,
|
||||||
|
email: input.cliente.email,
|
||||||
|
telefone: input.cliente.telefone ?? "",
|
||||||
|
},
|
||||||
|
produto: {
|
||||||
|
nome: produto.nome,
|
||||||
|
validade: produto.validade,
|
||||||
|
},
|
||||||
|
valor: (produto.preco_centavos / 100).toLocaleString("pt-BR", {
|
||||||
|
style: "currency",
|
||||||
|
currency: "BRL",
|
||||||
|
minimumFractionDigits: 2,
|
||||||
|
}),
|
||||||
|
pix_copia_cola: asaasPayment.pixQrCode.payload,
|
||||||
|
pix_qrcode_base64: asaasPayment.pixQrCode.encodedImage,
|
||||||
|
}),
|
||||||
|
}).catch((err) => console.error("n8n pix webhook error:", err))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
pedidoId: pedido.id,
|
||||||
|
metodo: input.metodo,
|
||||||
|
invoiceUrl: asaasPayment.invoiceUrl ?? asaasPayment.bankSlipUrl,
|
||||||
|
pixCopiaECola: asaasPayment.pixQrCode?.payload,
|
||||||
|
pixQrCodeBase64: asaasPayment.pixQrCode?.encodedImage,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// =============================================
|
||||||
|
// PEDIDOS
|
||||||
|
// =============================================
|
||||||
|
|
||||||
|
export async function getPedidos(options: { limit?: number; status?: string } = {}) {
|
||||||
|
const supabase = createServiceClient()
|
||||||
|
let query = supabase
|
||||||
|
.from("pedidos")
|
||||||
|
.select("*, clientes(nome, email, telefone, cpf_cnpj), produtos(nome, tipo, validade, midia)")
|
||||||
|
.order("created_at", { ascending: false })
|
||||||
|
|
||||||
|
if (options.status) query = query.eq("status", options.status)
|
||||||
|
if (options.limit) query = query.limit(options.limit)
|
||||||
|
|
||||||
|
const { data, error } = await query
|
||||||
|
if (error) throw new Error(error.message)
|
||||||
|
return data
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getPedidoById(id: string) {
|
||||||
|
const supabase = createServiceClient()
|
||||||
|
const { data, error } = await supabase
|
||||||
|
.from("pedidos")
|
||||||
|
.select("*, clientes(*), produtos(*)")
|
||||||
|
.eq("id", id)
|
||||||
|
.single()
|
||||||
|
|
||||||
|
if (error) throw new Error(error.message)
|
||||||
|
return data
|
||||||
|
}
|
||||||
|
|
||||||
|
// =============================================
|
||||||
|
// AGENDAMENTOS
|
||||||
|
// =============================================
|
||||||
|
|
||||||
|
export async function getAgendamentos(options: { limit?: number } = {}) {
|
||||||
|
const supabase = createServiceClient()
|
||||||
|
let query = supabase
|
||||||
|
.from("agendamentos")
|
||||||
|
.select("*, clientes(nome, email), produtos(nome)")
|
||||||
|
.order("data_hora", { ascending: true })
|
||||||
|
|
||||||
|
if (options.limit) query = query.limit(options.limit)
|
||||||
|
|
||||||
|
const { data, error } = await query
|
||||||
|
if (error) throw new Error(error.message)
|
||||||
|
return data
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function criarAgendamento(data: {
|
||||||
|
clienteId: string
|
||||||
|
produtoId: string
|
||||||
|
pedidoId?: string
|
||||||
|
dataHora: string
|
||||||
|
observacoes?: string
|
||||||
|
}) {
|
||||||
|
const supabase = createServiceClient()
|
||||||
|
const { data: agendamento, error } = await supabase
|
||||||
|
.from("agendamentos")
|
||||||
|
.insert({
|
||||||
|
cliente_id: data.clienteId,
|
||||||
|
produto_id: data.produtoId,
|
||||||
|
pedido_id: data.pedidoId,
|
||||||
|
data_hora: data.dataHora,
|
||||||
|
observacoes: data.observacoes,
|
||||||
|
})
|
||||||
|
.select()
|
||||||
|
.single()
|
||||||
|
|
||||||
|
if (error) throw new Error(error.message)
|
||||||
|
revalidatePath("/admin")
|
||||||
|
return agendamento
|
||||||
|
}
|
||||||
|
|
||||||
|
// =============================================
|
||||||
|
// CUPONS
|
||||||
|
// =============================================
|
||||||
|
|
||||||
|
export async function getCupomDestaque() {
|
||||||
|
const supabase = createServiceClient()
|
||||||
|
const hoje = new Date().toISOString().slice(0, 10)
|
||||||
|
const { data } = await supabase
|
||||||
|
.from("cupons")
|
||||||
|
.select("*")
|
||||||
|
.eq("ativo", true)
|
||||||
|
.eq("destaque", true)
|
||||||
|
.gte("validade", hoje)
|
||||||
|
.order("created_at", { ascending: false })
|
||||||
|
.limit(1)
|
||||||
|
.single()
|
||||||
|
return data ?? null
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getCupons() {
|
||||||
|
const supabase = createServiceClient()
|
||||||
|
const { data, error } = await supabase
|
||||||
|
.from("cupons")
|
||||||
|
.select("*")
|
||||||
|
.order("created_at", { ascending: false })
|
||||||
|
if (error) throw new Error(error.message)
|
||||||
|
return data
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function criarCupom(input: {
|
||||||
|
codigo: string
|
||||||
|
descricao: string
|
||||||
|
percentual: number
|
||||||
|
validade: string
|
||||||
|
destaque: boolean
|
||||||
|
}) {
|
||||||
|
const supabase = createServiceClient()
|
||||||
|
const { error } = await supabase.from("cupons").insert({
|
||||||
|
codigo: input.codigo.toUpperCase(),
|
||||||
|
descricao: input.descricao,
|
||||||
|
percentual: input.percentual,
|
||||||
|
validade: input.validade,
|
||||||
|
ativo: true,
|
||||||
|
destaque: input.destaque,
|
||||||
|
})
|
||||||
|
if (error) throw new Error(error.message)
|
||||||
|
revalidatePath("/admin")
|
||||||
|
revalidatePath("/")
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function atualizarCupom(id: string, input: Partial<{
|
||||||
|
codigo: string
|
||||||
|
descricao: string
|
||||||
|
percentual: number
|
||||||
|
validade: string
|
||||||
|
ativo: boolean
|
||||||
|
destaque: boolean
|
||||||
|
}>) {
|
||||||
|
const supabase = createServiceClient()
|
||||||
|
const { error } = await supabase.from("cupons").update(input).eq("id", id)
|
||||||
|
if (error) throw new Error(error.message)
|
||||||
|
revalidatePath("/admin")
|
||||||
|
revalidatePath("/")
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function deletarCupom(id: string) {
|
||||||
|
const supabase = createServiceClient()
|
||||||
|
const { error } = await supabase.from("cupons").delete().eq("id", id)
|
||||||
|
if (error) throw new Error(error.message)
|
||||||
|
revalidatePath("/admin")
|
||||||
|
revalidatePath("/")
|
||||||
|
}
|
||||||
207
lib/asaas.ts
Normal file
207
lib/asaas.ts
Normal file
@@ -0,0 +1,207 @@
|
|||||||
|
// =============================================
|
||||||
|
// ASAAS — Integração com gateway de pagamentos
|
||||||
|
// =============================================
|
||||||
|
|
||||||
|
const ASAAS_API_URL =
|
||||||
|
process.env.ASAAS_ENV === "production"
|
||||||
|
? "https://www.asaas.com/api/v3"
|
||||||
|
: "https://sandbox.asaas.com/api/v3"
|
||||||
|
|
||||||
|
const ASAAS_API_KEY = process.env.ASAAS_API_KEY!
|
||||||
|
|
||||||
|
async function asaasFetch<T>(path: string, options: RequestInit = {}): Promise<T> {
|
||||||
|
const res = await fetch(`${ASAAS_API_URL}${path}`, {
|
||||||
|
...options,
|
||||||
|
headers: {
|
||||||
|
"Content-Type": "application/json",
|
||||||
|
access_token: ASAAS_API_KEY,
|
||||||
|
...options.headers,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
if (!res.ok) {
|
||||||
|
const error = await res.json().catch(() => ({}))
|
||||||
|
throw new Error(`ASAAS API error ${res.status}: ${JSON.stringify(error)}`)
|
||||||
|
}
|
||||||
|
|
||||||
|
return res.json()
|
||||||
|
}
|
||||||
|
|
||||||
|
// =============================================
|
||||||
|
// CLIENTES
|
||||||
|
// =============================================
|
||||||
|
|
||||||
|
export type AsaasCliente = {
|
||||||
|
id: string
|
||||||
|
name: string
|
||||||
|
email: string
|
||||||
|
phone?: string
|
||||||
|
cpfCnpj?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function criarClienteAsaas(data: {
|
||||||
|
nome: string
|
||||||
|
email: string
|
||||||
|
cpfCnpj: string
|
||||||
|
telefone?: string
|
||||||
|
}): Promise<AsaasCliente> {
|
||||||
|
return asaasFetch<AsaasCliente>("/customers", {
|
||||||
|
method: "POST",
|
||||||
|
body: JSON.stringify({
|
||||||
|
name: data.nome,
|
||||||
|
email: data.email,
|
||||||
|
cpfCnpj: data.cpfCnpj,
|
||||||
|
phone: data.telefone,
|
||||||
|
}),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function buscarOuCriarClienteAsaas(data: {
|
||||||
|
nome: string
|
||||||
|
email: string
|
||||||
|
cpfCnpj: string
|
||||||
|
telefone?: string
|
||||||
|
}): Promise<AsaasCliente> {
|
||||||
|
// Tenta encontrar cliente existente por CPF/CNPJ
|
||||||
|
const lista = await asaasFetch<{ data: AsaasCliente[] }>(
|
||||||
|
`/customers?cpfCnpj=${data.cpfCnpj}&limit=1`
|
||||||
|
)
|
||||||
|
|
||||||
|
if (lista.data.length > 0) return lista.data[0]
|
||||||
|
|
||||||
|
return criarClienteAsaas(data)
|
||||||
|
}
|
||||||
|
|
||||||
|
// =============================================
|
||||||
|
// PAGAMENTOS
|
||||||
|
// =============================================
|
||||||
|
|
||||||
|
export type MetodoPagamento = "PIX" | "BOLETO" | "CREDIT_CARD"
|
||||||
|
|
||||||
|
export type AsaasPaymentInput = {
|
||||||
|
asaasClienteId: string
|
||||||
|
valor: number // em reais (ex: 199.00)
|
||||||
|
descricao: string
|
||||||
|
metodo: MetodoPagamento
|
||||||
|
vencimento?: string // YYYY-MM-DD, obrigatório para boleto
|
||||||
|
cartao?: {
|
||||||
|
holderName: string
|
||||||
|
number: string
|
||||||
|
expiryMonth: string
|
||||||
|
expiryYear: string
|
||||||
|
ccv: string
|
||||||
|
}
|
||||||
|
cartaoHolder?: {
|
||||||
|
name: string
|
||||||
|
email: string
|
||||||
|
cpfCnpj: string
|
||||||
|
postalCode: string
|
||||||
|
addressNumber: string
|
||||||
|
phone: string
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export type AsaasPayment = {
|
||||||
|
id: string
|
||||||
|
status: string
|
||||||
|
value: number
|
||||||
|
netValue: number
|
||||||
|
billingType: string
|
||||||
|
invoiceUrl?: string
|
||||||
|
bankSlipUrl?: string
|
||||||
|
pixQrCode?: {
|
||||||
|
encodedImage: string
|
||||||
|
payload: string
|
||||||
|
expirationDate: string
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function criarPagamentoAsaas(input: AsaasPaymentInput): Promise<AsaasPayment> {
|
||||||
|
const vencimento =
|
||||||
|
input.vencimento ?? new Date(Date.now() + 3 * 24 * 60 * 60 * 1000).toISOString().slice(0, 10)
|
||||||
|
|
||||||
|
const body: Record<string, unknown> = {
|
||||||
|
customer: input.asaasClienteId,
|
||||||
|
billingType: input.metodo,
|
||||||
|
value: input.valor,
|
||||||
|
dueDate: vencimento,
|
||||||
|
description: input.descricao,
|
||||||
|
}
|
||||||
|
|
||||||
|
if (input.metodo === "CREDIT_CARD" && input.cartao && input.cartaoHolder) {
|
||||||
|
body.creditCard = {
|
||||||
|
holderName: input.cartao.holderName,
|
||||||
|
number: input.cartao.number,
|
||||||
|
expiryMonth: input.cartao.expiryMonth,
|
||||||
|
expiryYear: input.cartao.expiryYear,
|
||||||
|
ccv: input.cartao.ccv,
|
||||||
|
}
|
||||||
|
body.creditCardHolderInfo = {
|
||||||
|
name: input.cartaoHolder.name,
|
||||||
|
email: input.cartaoHolder.email,
|
||||||
|
cpfCnpj: input.cartaoHolder.cpfCnpj,
|
||||||
|
postalCode: input.cartaoHolder.postalCode,
|
||||||
|
addressNumber: input.cartaoHolder.addressNumber,
|
||||||
|
phone: input.cartaoHolder.phone,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const payment = await asaasFetch<AsaasPayment>("/payments", {
|
||||||
|
method: "POST",
|
||||||
|
body: JSON.stringify(body),
|
||||||
|
})
|
||||||
|
|
||||||
|
// Para PIX, busca o QR Code
|
||||||
|
if (input.metodo === "PIX") {
|
||||||
|
const pix = await asaasFetch<{ encodedImage: string; payload: string; expirationDate: string }>(
|
||||||
|
`/payments/${payment.id}/pixQrCode`
|
||||||
|
)
|
||||||
|
payment.pixQrCode = pix
|
||||||
|
}
|
||||||
|
|
||||||
|
return payment
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function buscarPagamentoAsaas(paymentId: string): Promise<AsaasPayment> {
|
||||||
|
return asaasFetch<AsaasPayment>(`/payments/${paymentId}`)
|
||||||
|
}
|
||||||
|
|
||||||
|
// =============================================
|
||||||
|
// WEBHOOK — valida e processa evento
|
||||||
|
// =============================================
|
||||||
|
|
||||||
|
export type AsaasWebhookEvent = {
|
||||||
|
event: string
|
||||||
|
payment: {
|
||||||
|
id: string
|
||||||
|
status: string
|
||||||
|
value: number
|
||||||
|
billingType: string
|
||||||
|
paymentDate?: string
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function parseWebhookPayload(body: unknown): AsaasWebhookEvent {
|
||||||
|
return body as AsaasWebhookEvent
|
||||||
|
}
|
||||||
|
|
||||||
|
// Mapeia status ASAAS → status interno
|
||||||
|
export function mapearStatus(
|
||||||
|
asaasStatus: string
|
||||||
|
): "PENDING" | "RECEIVED" | "CONFIRMED" | "OVERDUE" | "REFUNDED" | "CANCELLED" {
|
||||||
|
const map: Record<string, "PENDING" | "RECEIVED" | "CONFIRMED" | "OVERDUE" | "REFUNDED" | "CANCELLED"> = {
|
||||||
|
PENDING: "PENDING",
|
||||||
|
RECEIVED: "RECEIVED",
|
||||||
|
CONFIRMED: "CONFIRMED",
|
||||||
|
OVERDUE: "OVERDUE",
|
||||||
|
REFUND_REQUESTED: "REFUNDED",
|
||||||
|
REFUNDED: "REFUNDED",
|
||||||
|
CHARGEBACK_REQUESTED: "CANCELLED",
|
||||||
|
CHARGEBACK_DISPUTE: "CANCELLED",
|
||||||
|
AWAITING_CHARGEBACK_REVERSAL: "CANCELLED",
|
||||||
|
DUNNING_REQUESTED: "OVERDUE",
|
||||||
|
DUNNING_RECEIVED: "RECEIVED",
|
||||||
|
AWAITING_RISK_ANALYSIS: "PENDING",
|
||||||
|
}
|
||||||
|
return map[asaasStatus] ?? "PENDING"
|
||||||
|
}
|
||||||
48
lib/auth-context.tsx
Normal file
48
lib/auth-context.tsx
Normal file
@@ -0,0 +1,48 @@
|
|||||||
|
"use client"
|
||||||
|
|
||||||
|
import type React from "react"
|
||||||
|
|
||||||
|
import { createContext, useContext, useState, useEffect } from "react"
|
||||||
|
|
||||||
|
interface AuthContextType {
|
||||||
|
isLoggedIn: boolean
|
||||||
|
login: (username: string, password: string) => Promise<boolean>
|
||||||
|
logout: () => void
|
||||||
|
}
|
||||||
|
|
||||||
|
const AuthContext = createContext<AuthContextType>({
|
||||||
|
isLoggedIn: false,
|
||||||
|
login: async () => false,
|
||||||
|
logout: () => {},
|
||||||
|
})
|
||||||
|
|
||||||
|
export const useAuth = () => useContext(AuthContext)
|
||||||
|
|
||||||
|
export const AuthProvider = ({ children }: { children: React.ReactNode }) => {
|
||||||
|
const [isLoggedIn, setIsLoggedIn] = useState(false)
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const token = localStorage.getItem("isLoggedIn")
|
||||||
|
if (token === "true") {
|
||||||
|
setIsLoggedIn(true)
|
||||||
|
}
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
const login = async (username: string, password: string) => {
|
||||||
|
const adminUser = process.env.NEXT_PUBLIC_ADMIN_USER ?? "admin"
|
||||||
|
const adminPass = process.env.NEXT_PUBLIC_ADMIN_PASSWORD ?? "admin"
|
||||||
|
if (username === adminUser && password === adminPass) {
|
||||||
|
localStorage.setItem("isLoggedIn", "true")
|
||||||
|
setIsLoggedIn(true)
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
const logout = () => {
|
||||||
|
localStorage.removeItem("isLoggedIn")
|
||||||
|
setIsLoggedIn(false)
|
||||||
|
}
|
||||||
|
|
||||||
|
return <AuthContext.Provider value={{ isLoggedIn, login, logout }}>{children}</AuthContext.Provider>
|
||||||
|
}
|
||||||
11
lib/config.ts
Normal file
11
lib/config.ts
Normal file
@@ -0,0 +1,11 @@
|
|||||||
|
// Configurações da instância — todas via variáveis de ambiente
|
||||||
|
// Copie .env.example para .env.local e preencha
|
||||||
|
|
||||||
|
export const appConfig = {
|
||||||
|
name: process.env.NEXT_PUBLIC_APP_NAME ?? "Checkout",
|
||||||
|
logoUrl: process.env.NEXT_PUBLIC_APP_LOGO_URL ?? null,
|
||||||
|
primaryColor: process.env.NEXT_PUBLIC_APP_PRIMARY_COLOR ?? "#1d4ed8",
|
||||||
|
supportEmail: process.env.NEXT_PUBLIC_SUPPORT_EMAIL ?? null,
|
||||||
|
supportWhatsapp: process.env.NEXT_PUBLIC_SUPPORT_WHATSAPP ?? null,
|
||||||
|
afterPaymentRedirect: process.env.NEXT_PUBLIC_AFTER_PAYMENT_REDIRECT ?? null,
|
||||||
|
}
|
||||||
29
lib/schema.ts
Normal file
29
lib/schema.ts
Normal file
@@ -0,0 +1,29 @@
|
|||||||
|
import { z } from "zod"
|
||||||
|
|
||||||
|
export const productSchema = z.object({
|
||||||
|
id: z.string(),
|
||||||
|
name: z.string(),
|
||||||
|
description: z.string().nullable(),
|
||||||
|
images: z.array(z.string()).optional(),
|
||||||
|
price: z.object({
|
||||||
|
id: z.string().nullable(),
|
||||||
|
amount: z.number().nullable(),
|
||||||
|
display_amount: z.string().nullable(),
|
||||||
|
}),
|
||||||
|
metadata: z
|
||||||
|
.object({
|
||||||
|
tipo: z.string().optional(),
|
||||||
|
validade: z.string().optional(),
|
||||||
|
midia: z.string().optional(),
|
||||||
|
categoria: z.string().optional(),
|
||||||
|
cor: z.string().optional(),
|
||||||
|
excerpt: z.string().optional(),
|
||||||
|
beneficios: z.string().optional(),
|
||||||
|
})
|
||||||
|
.optional(),
|
||||||
|
})
|
||||||
|
|
||||||
|
export const productListSchema = z.object({
|
||||||
|
data: z.array(productSchema),
|
||||||
|
has_more: z.boolean(),
|
||||||
|
})
|
||||||
81
lib/supabase.ts
Normal file
81
lib/supabase.ts
Normal file
@@ -0,0 +1,81 @@
|
|||||||
|
import { createClient } from "@supabase/supabase-js"
|
||||||
|
|
||||||
|
const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL!
|
||||||
|
const supabaseAnonKey = process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!
|
||||||
|
|
||||||
|
// Client-side (browser)
|
||||||
|
export const supabase = createClient(supabaseUrl, supabaseAnonKey)
|
||||||
|
|
||||||
|
// Server-side com service role (para Server Actions e API routes)
|
||||||
|
export function createServiceClient() {
|
||||||
|
return createClient(supabaseUrl, process.env.SUPABASE_SERVICE_ROLE_KEY!, {
|
||||||
|
auth: { autoRefreshToken: false, persistSession: false },
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// Tipos do banco
|
||||||
|
export type Produto = {
|
||||||
|
id: string
|
||||||
|
nome: string
|
||||||
|
descricao: string | null
|
||||||
|
tipo: "PF" | "PJ" | "SSL" | "NFe"
|
||||||
|
validade: string
|
||||||
|
midia: "Token" | "Cartão" | "Nuvem" | "Sem mídia" | null
|
||||||
|
preco_centavos: number
|
||||||
|
ativo: boolean
|
||||||
|
imagem_url: string | null
|
||||||
|
created_at: string
|
||||||
|
updated_at: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export type Cliente = {
|
||||||
|
id: string
|
||||||
|
nome: string
|
||||||
|
email: string
|
||||||
|
telefone: string | null
|
||||||
|
cpf_cnpj: string | null
|
||||||
|
asaas_id: string | null
|
||||||
|
created_at: string
|
||||||
|
updated_at: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export type Pedido = {
|
||||||
|
id: string
|
||||||
|
cliente_id: string | null
|
||||||
|
produto_id: string | null
|
||||||
|
valor_centavos: number
|
||||||
|
metodo_pagamento: "PIX" | "BOLETO" | "CREDIT_CARD" | null
|
||||||
|
status: "PENDING" | "RECEIVED" | "CONFIRMED" | "OVERDUE" | "REFUNDED" | "CANCELLED"
|
||||||
|
asaas_payment_id: string | null
|
||||||
|
asaas_invoice_url: string | null
|
||||||
|
pix_copia_cola: string | null
|
||||||
|
pix_qrcode_url: string | null
|
||||||
|
due_date: string | null
|
||||||
|
paid_at: string | null
|
||||||
|
created_at: string
|
||||||
|
updated_at: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export type Cupom = {
|
||||||
|
id: string
|
||||||
|
codigo: string
|
||||||
|
descricao: string
|
||||||
|
percentual: number
|
||||||
|
validade: string
|
||||||
|
ativo: boolean
|
||||||
|
destaque: boolean
|
||||||
|
created_at: string
|
||||||
|
updated_at: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export type Agendamento = {
|
||||||
|
id: string
|
||||||
|
cliente_id: string | null
|
||||||
|
produto_id: string | null
|
||||||
|
pedido_id: string | null
|
||||||
|
data_hora: string
|
||||||
|
status: "AGUARDANDO" | "CONFIRMADO" | "CANCELADO" | "CONCLUIDO"
|
||||||
|
observacoes: string | null
|
||||||
|
created_at: string
|
||||||
|
updated_at: string
|
||||||
|
}
|
||||||
6
lib/utils.ts
Normal file
6
lib/utils.ts
Normal file
@@ -0,0 +1,6 @@
|
|||||||
|
import { type ClassValue, clsx } from "clsx"
|
||||||
|
import { twMerge } from "tailwind-merge"
|
||||||
|
|
||||||
|
export function cn(...inputs: ClassValue[]) {
|
||||||
|
return twMerge(clsx(inputs))
|
||||||
|
}
|
||||||
5
next-env.d.ts
vendored
Normal file
5
next-env.d.ts
vendored
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
/// <reference types="next" />
|
||||||
|
/// <reference types="next/image-types/global" />
|
||||||
|
|
||||||
|
// NOTE: This file should not be edited
|
||||||
|
// see https://nextjs.org/docs/app/api-reference/config/typescript for more information.
|
||||||
13
next.config.js
Normal file
13
next.config.js
Normal file
@@ -0,0 +1,13 @@
|
|||||||
|
/** @type {import('next').NextConfig} */
|
||||||
|
const nextConfig = {
|
||||||
|
output: "standalone",
|
||||||
|
reactStrictMode: true,
|
||||||
|
eslint: { ignoreDuringBuilds: true },
|
||||||
|
typescript: { ignoreBuildErrors: true },
|
||||||
|
images: {
|
||||||
|
unoptimized: true,
|
||||||
|
domains: ["images.pexels.com", "images.unsplash.com", "v0.blob.com", "hebbkx1anhila5yf.public.blob.vercel-storage.com"],
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = nextConfig
|
||||||
15
next.config.mjs
Normal file
15
next.config.mjs
Normal file
@@ -0,0 +1,15 @@
|
|||||||
|
/** @type {import('next').NextConfig} */
|
||||||
|
const nextConfig = {
|
||||||
|
output: "standalone",
|
||||||
|
eslint: {
|
||||||
|
ignoreDuringBuilds: true,
|
||||||
|
},
|
||||||
|
typescript: {
|
||||||
|
ignoreBuildErrors: true,
|
||||||
|
},
|
||||||
|
images: {
|
||||||
|
unoptimized: true,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
export default nextConfig
|
||||||
8419
package-lock.json
generated
Normal file
8419
package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
65
package.json
Normal file
65
package.json
Normal file
@@ -0,0 +1,65 @@
|
|||||||
|
{
|
||||||
|
"name": "vixcert",
|
||||||
|
"version": "0.1.0",
|
||||||
|
"private": true,
|
||||||
|
"scripts": {
|
||||||
|
"build": "next build",
|
||||||
|
"dev": "next dev",
|
||||||
|
"lint": "next lint",
|
||||||
|
"start": "next start"
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"@calcom/embed-react": "^1.5.3",
|
||||||
|
"@emotion/is-prop-valid": "latest",
|
||||||
|
"@radix-ui/react-accordion": "^1.1.2",
|
||||||
|
"@radix-ui/react-avatar": "^1.1.10",
|
||||||
|
"@radix-ui/react-checkbox": "^1.3.3",
|
||||||
|
"@radix-ui/react-dialog": "latest",
|
||||||
|
"@radix-ui/react-dropdown-menu": "^2.1.16",
|
||||||
|
"@radix-ui/react-icons": "latest",
|
||||||
|
"@radix-ui/react-label": "^2.0.2",
|
||||||
|
"@radix-ui/react-popover": "^1.1.15",
|
||||||
|
"@radix-ui/react-select": "^2.2.6",
|
||||||
|
"@radix-ui/react-separator": "^1.1.8",
|
||||||
|
"@radix-ui/react-slot": "latest",
|
||||||
|
"@radix-ui/react-switch": "^1.2.6",
|
||||||
|
"@radix-ui/react-tabs": "^1.1.13",
|
||||||
|
"@radix-ui/react-toast": "latest",
|
||||||
|
"@supabase/ssr": "^0.10.0",
|
||||||
|
"@supabase/supabase-js": "^2.102.1",
|
||||||
|
"@tanstack/react-table": "latest",
|
||||||
|
"autoprefixer": "10.4.14",
|
||||||
|
"class-variance-authority": "^0.7.1",
|
||||||
|
"clsx": "^2.1.1",
|
||||||
|
"cmdk": "^1.1.1",
|
||||||
|
"date-fns": "latest",
|
||||||
|
"dotenv": "^17.2.1",
|
||||||
|
"embla-carousel": "latest",
|
||||||
|
"embla-carousel-autoplay": "latest",
|
||||||
|
"embla-carousel-react": "latest",
|
||||||
|
"eslint": "8.42.0",
|
||||||
|
"eslint-config-next": "13.4.5",
|
||||||
|
"framer-motion": "latest",
|
||||||
|
"lucide-react": "^0.454.0",
|
||||||
|
"next": "15.2.4",
|
||||||
|
"next-themes": "latest",
|
||||||
|
"postcss": "8.4.24",
|
||||||
|
"react": "^19",
|
||||||
|
"react-day-picker": "^9.14.0",
|
||||||
|
"react-dom": "^19",
|
||||||
|
"react-is": "^19.2.5",
|
||||||
|
"recharts": "latest",
|
||||||
|
"stripe": "latest",
|
||||||
|
"tailwind-merge": "^2.5.5",
|
||||||
|
"tailwindcss": "3.3.2",
|
||||||
|
"tailwindcss-animate": "^1.0.7",
|
||||||
|
"zod": "^3.21.4"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@types/node": "^22",
|
||||||
|
"@types/react": "^19",
|
||||||
|
"@types/react-dom": "^19",
|
||||||
|
"postcss": "^8.5",
|
||||||
|
"typescript": "^5"
|
||||||
|
}
|
||||||
|
}
|
||||||
5537
pnpm-lock.yaml
generated
Normal file
5537
pnpm-lock.yaml
generated
Normal file
File diff suppressed because it is too large
Load Diff
6
postcss.config.js
Normal file
6
postcss.config.js
Normal file
@@ -0,0 +1,6 @@
|
|||||||
|
module.exports = {
|
||||||
|
plugins: {
|
||||||
|
tailwindcss: {},
|
||||||
|
autoprefixer: {},
|
||||||
|
},
|
||||||
|
}
|
||||||
8
postcss.config.mjs
Normal file
8
postcss.config.mjs
Normal file
@@ -0,0 +1,8 @@
|
|||||||
|
/** @type {import('postcss-load-config').Config} */
|
||||||
|
const config = {
|
||||||
|
plugins: {
|
||||||
|
tailwindcss: {},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
export default config;
|
||||||
BIN
public/placeholder-logo.png
Normal file
BIN
public/placeholder-logo.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 568 B |
1
public/placeholder-logo.svg
Normal file
1
public/placeholder-logo.svg
Normal file
@@ -0,0 +1 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" width="215" height="48" fill="none"><path fill="#000" d="M57.588 9.6h6L73.828 38h-5.2l-2.36-6.88h-11.36L52.548 38h-5.2l10.24-28.4Zm7.16 17.16-4.16-12.16-4.16 12.16h8.32Zm23.694-2.24c-.186-1.307-.706-2.32-1.56-3.04-.853-.72-1.866-1.08-3.04-1.08-1.68 0-2.986.613-3.92 1.84-.906 1.227-1.36 2.947-1.36 5.16s.454 3.933 1.36 5.16c.934 1.227 2.24 1.84 3.92 1.84 1.254 0 2.307-.373 3.16-1.12.854-.773 1.387-1.867 1.6-3.28l5.12.24c-.186 1.68-.733 3.147-1.64 4.4-.906 1.227-2.08 2.173-3.52 2.84-1.413.667-2.986 1-4.72 1-2.08 0-3.906-.453-5.48-1.36-1.546-.907-2.76-2.2-3.64-3.88-.853-1.68-1.28-3.627-1.28-5.84 0-2.24.427-4.187 1.28-5.84.88-1.68 2.094-2.973 3.64-3.88 1.574-.907 3.4-1.36 5.48-1.36 1.68 0 3.227.32 4.64.96 1.414.64 2.56 1.56 3.44 2.76.907 1.2 1.454 2.6 1.64 4.2l-5.12.28Zm11.486-7.72.12 3.4c.534-1.227 1.307-2.173 2.32-2.84 1.04-.693 2.267-1.04 3.68-1.04 1.494 0 2.76.387 3.8 1.16 1.067.747 1.827 1.813 2.28 3.2.507-1.44 1.294-2.52 2.36-3.24 1.094-.747 2.414-1.12 3.96-1.12 1.414 0 2.64.307 3.68.92s1.84 1.52 2.4 2.72c.56 1.2.84 2.667.84 4.4V38h-4.96V25.92c0-1.813-.293-3.187-.88-4.12-.56-.96-1.413-1.44-2.56-1.44-.906 0-1.68.213-2.32.64-.64.427-1.133 1.053-1.48 1.88-.32.827-.48 1.84-.48 3.04V38h-4.56V25.92c0-1.2-.133-2.213-.4-3.04-.24-.827-.626-1.453-1.16-1.88-.506-.427-1.133-.64-1.88-.64-.906 0-1.68.227-2.32.68-.64.427-1.133 1.053-1.48 1.88-.32.827-.48 1.827-.48 3V38h-4.96V16.8h4.48Zm26.723 10.6c0-2.24.427-4.187 1.28-5.84.854-1.68 2.067-2.973 3.64-3.88 1.574-.907 3.4-1.36 5.48-1.36 1.84 0 3.494.413 4.96 1.24 1.467.827 2.64 2.08 3.52 3.76.88 1.653 1.347 3.693 1.4 6.12v1.32h-15.08c.107 1.813.614 3.227 1.52 4.24.907.987 2.134 1.48 3.68 1.48.987 0 1.88-.253 2.68-.76a4.803 4.803 0 0 0 1.84-2.2l5.08.36c-.64 2.027-1.84 3.64-3.6 4.84-1.733 1.173-3.733 1.76-6 1.76-2.08 0-3.906-.453-5.48-1.36-1.573-.907-2.786-2.2-3.64-3.88-.853-1.68-1.28-3.627-1.28-5.84Zm15.16-2.04c-.213-1.733-.76-3.013-1.64-3.84-.853-.827-1.893-1.24-3.12-1.24-1.44 0-2.6.453-3.48 1.36-.88.88-1.44 2.12-1.68 3.72h9.92ZM163.139 9.6V38h-5.04V9.6h5.04Zm8.322 7.2.24 5.88-.64-.36c.32-2.053 1.094-3.56 2.32-4.52 1.254-.987 2.787-1.48 4.6-1.48 2.32 0 4.107.733 5.36 2.2 1.254 1.44 1.88 3.387 1.88 5.84V38h-4.96V25.92c0-1.253-.12-2.28-.36-3.08-.24-.8-.64-1.413-1.2-1.84-.533-.427-1.253-.64-2.16-.64-1.44 0-2.573.48-3.4 1.44-.8.933-1.2 2.307-1.2 4.12V38h-4.96V16.8h4.48Zm30.003 7.72c-.186-1.307-.706-2.32-1.56-3.04-.853-.72-1.866-1.08-3.04-1.08-1.68 0-2.986.613-3.92 1.84-.906 1.227-1.36 2.947-1.36 5.16s.454 3.933 1.36 5.16c.934 1.227 2.24 1.84 3.92 1.84 1.254 0 2.307-.373 3.16-1.12.854-.773 1.387-1.867 1.6-3.28l5.12.24c-.186 1.68-.733 3.147-1.64 4.4-.906 1.227-2.08 2.173-3.52 2.84-1.413.667-2.986 1-4.72 1-2.08 0-3.906-.453-5.48-1.36-1.546-.907-2.76-2.2-3.64-3.88-.853-1.68-1.28-3.627-1.28-5.84 0-2.24.427-4.187 1.28-5.84.88-1.68 2.094-2.973 3.64-3.88 1.574-.907 3.4-1.36 5.48-1.36 1.68 0 3.227.32 4.64.96 1.414.64 2.56 1.56 3.44 2.76.907 1.2 1.454 2.6 1.64 4.2l-5.12.28Zm11.443 8.16V38h-5.6v-5.32h5.6Z"/><path fill="#171717" fill-rule="evenodd" d="m7.839 40.783 16.03-28.054L20 6 0 40.783h7.839Zm8.214 0H40L27.99 19.894l-4.02 7.032 3.976 6.914H20.02l-3.967 6.943Z" clip-rule="evenodd"/></svg>
|
||||||
|
After Width: | Height: | Size: 3.1 KiB |
BIN
public/placeholder-user.jpg
Normal file
BIN
public/placeholder-user.jpg
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 1.6 KiB |
BIN
public/placeholder.jpg
Normal file
BIN
public/placeholder.jpg
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 1.0 KiB |
1
public/placeholder.svg
Normal file
1
public/placeholder.svg
Normal file
@@ -0,0 +1 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" width="1200" height="1200" fill="none"><rect width="1200" height="1200" fill="#EAEAEA" rx="3"/><g opacity=".5"><g opacity=".5"><path fill="#FAFAFA" d="M600.709 736.5c-75.454 0-136.621-61.167-136.621-136.62 0-75.454 61.167-136.621 136.621-136.621 75.453 0 136.62 61.167 136.62 136.621 0 75.453-61.167 136.62-136.62 136.62Z"/><path stroke="#C9C9C9" stroke-width="2.418" d="M600.709 736.5c-75.454 0-136.621-61.167-136.621-136.62 0-75.454 61.167-136.621 136.621-136.621 75.453 0 136.62 61.167 136.62 136.621 0 75.453-61.167 136.62-136.62 136.62Z"/></g><path stroke="url(#a)" stroke-width="2.418" d="M0-1.209h553.581" transform="scale(1 -1) rotate(45 1163.11 91.165)"/><path stroke="url(#b)" stroke-width="2.418" d="M404.846 598.671h391.726"/><path stroke="url(#c)" stroke-width="2.418" d="M599.5 795.742V404.017"/><path stroke="url(#d)" stroke-width="2.418" d="m795.717 796.597-391.441-391.44"/><path fill="#fff" d="M600.709 656.704c-31.384 0-56.825-25.441-56.825-56.824 0-31.384 25.441-56.825 56.825-56.825 31.383 0 56.824 25.441 56.824 56.825 0 31.383-25.441 56.824-56.824 56.824Z"/><g clip-path="url(#e)"><path fill="#666" fill-rule="evenodd" d="M616.426 586.58h-31.434v16.176l3.553-3.554.531-.531h9.068l.074-.074 8.463-8.463h2.565l7.18 7.181V586.58Zm-15.715 14.654 3.698 3.699 1.283 1.282-2.565 2.565-1.282-1.283-5.2-5.199h-6.066l-5.514 5.514-.073.073v2.876a2.418 2.418 0 0 0 2.418 2.418h26.598a2.418 2.418 0 0 0 2.418-2.418v-8.317l-8.463-8.463-7.181 7.181-.071.072Zm-19.347 5.442v4.085a6.045 6.045 0 0 0 6.046 6.045h26.598a6.044 6.044 0 0 0 6.045-6.045v-7.108l1.356-1.355-1.282-1.283-.074-.073v-17.989h-38.689v23.43l-.146.146.146.147Z" clip-rule="evenodd"/></g><path stroke="#C9C9C9" stroke-width="2.418" d="M600.709 656.704c-31.384 0-56.825-25.441-56.825-56.824 0-31.384 25.441-56.825 56.825-56.825 31.383 0 56.824 25.441 56.824 56.825 0 31.383-25.441 56.824-56.824 56.824Z"/></g><defs><linearGradient id="a" x1="554.061" x2="-.48" y1=".083" y2=".087" gradientUnits="userSpaceOnUse"><stop stop-color="#C9C9C9" stop-opacity="0"/><stop offset=".208" stop-color="#C9C9C9"/><stop offset=".792" stop-color="#C9C9C9"/><stop offset="1" stop-color="#C9C9C9" stop-opacity="0"/></linearGradient><linearGradient id="b" x1="796.912" x2="404.507" y1="599.963" y2="599.965" gradientUnits="userSpaceOnUse"><stop stop-color="#C9C9C9" stop-opacity="0"/><stop offset=".208" stop-color="#C9C9C9"/><stop offset=".792" stop-color="#C9C9C9"/><stop offset="1" stop-color="#C9C9C9" stop-opacity="0"/></linearGradient><linearGradient id="c" x1="600.792" x2="600.794" y1="403.677" y2="796.082" gradientUnits="userSpaceOnUse"><stop stop-color="#C9C9C9" stop-opacity="0"/><stop offset=".208" stop-color="#C9C9C9"/><stop offset=".792" stop-color="#C9C9C9"/><stop offset="1" stop-color="#C9C9C9" stop-opacity="0"/></linearGradient><linearGradient id="d" x1="404.85" x2="796.972" y1="403.903" y2="796.02" gradientUnits="userSpaceOnUse"><stop stop-color="#C9C9C9" stop-opacity="0"/><stop offset=".208" stop-color="#C9C9C9"/><stop offset=".792" stop-color="#C9C9C9"/><stop offset="1" stop-color="#C9C9C9" stop-opacity="0"/></linearGradient><clipPath id="e"><path fill="#fff" d="M581.364 580.535h38.689v38.689h-38.689z"/></clipPath></defs></svg>
|
||||||
|
After Width: | Height: | Size: 3.2 KiB |
99
setup.sh
Executable file
99
setup.sh
Executable file
@@ -0,0 +1,99 @@
|
|||||||
|
#!/bin/bash
|
||||||
|
# setup.sh — Bootstrap de nova instância do asaas-checkout
|
||||||
|
# Uso: bash setup.sh
|
||||||
|
|
||||||
|
set -e
|
||||||
|
|
||||||
|
echo ""
|
||||||
|
echo "=== asaas-checkout — Setup de nova instância ==="
|
||||||
|
echo ""
|
||||||
|
|
||||||
|
# Coletar configurações
|
||||||
|
read -p "Nome do projeto (slug, sem espaços): " PROJECT_SLUG
|
||||||
|
read -p "Nome exibido na interface: " APP_NAME
|
||||||
|
read -p "Domínio (ex: checkout.seudominio.com.br): " DOMAIN
|
||||||
|
read -p "ASAAS API Key: " ASAAS_KEY
|
||||||
|
read -p "ASAAS Env [sandbox/production] (default: sandbox): " ASAAS_ENV
|
||||||
|
ASAAS_ENV=${ASAAS_ENV:-sandbox}
|
||||||
|
read -p "Supabase URL: " SUPABASE_URL
|
||||||
|
read -p "Supabase Anon Key: " SUPABASE_ANON
|
||||||
|
read -p "Supabase Service Role Key: " SUPABASE_SERVICE
|
||||||
|
read -p "Senha do admin (deixar vazio para gerar): " ADMIN_PASS
|
||||||
|
if [ -z "$ADMIN_PASS" ]; then
|
||||||
|
ADMIN_PASS=$(openssl rand -base64 12)
|
||||||
|
echo " → Senha gerada: $ADMIN_PASS"
|
||||||
|
fi
|
||||||
|
read -p "Redirect após pagamento (URL, pode ser / ): " AFTER_PAYMENT
|
||||||
|
AFTER_PAYMENT=${AFTER_PAYMENT:-/}
|
||||||
|
read -p "n8n Webhook URL pagamento confirmado (opcional): " N8N_WEBHOOK
|
||||||
|
read -p "n8n Webhook URL PIX gerado (opcional): " N8N_PIX
|
||||||
|
|
||||||
|
# Criar .env.local
|
||||||
|
cat > .env.local <<EOF
|
||||||
|
NEXT_PUBLIC_APP_NAME="${APP_NAME}"
|
||||||
|
NEXT_PUBLIC_APP_LOGO_URL=""
|
||||||
|
NEXT_PUBLIC_APP_PRIMARY_COLOR="#1d4ed8"
|
||||||
|
NEXT_PUBLIC_AFTER_PAYMENT_REDIRECT="${AFTER_PAYMENT}"
|
||||||
|
NEXT_PUBLIC_SUPPORT_EMAIL=""
|
||||||
|
NEXT_PUBLIC_SUPPORT_WHATSAPP=""
|
||||||
|
NEXT_PUBLIC_ADMIN_USER="admin"
|
||||||
|
NEXT_PUBLIC_ADMIN_PASSWORD="${ADMIN_PASS}"
|
||||||
|
ASAAS_API_KEY="${ASAAS_KEY}"
|
||||||
|
ASAAS_ENV="${ASAAS_ENV}"
|
||||||
|
NEXT_PUBLIC_SUPABASE_URL="${SUPABASE_URL}"
|
||||||
|
NEXT_PUBLIC_SUPABASE_ANON_KEY="${SUPABASE_ANON}"
|
||||||
|
SUPABASE_SERVICE_ROLE_KEY="${SUPABASE_SERVICE}"
|
||||||
|
N8N_WEBHOOK_URL="${N8N_WEBHOOK}"
|
||||||
|
N8N_PIX_WEBHOOK_URL="${N8N_PIX}"
|
||||||
|
EOF
|
||||||
|
|
||||||
|
echo ""
|
||||||
|
echo "✓ .env.local criado"
|
||||||
|
|
||||||
|
# Criar stack file para Docker Swarm
|
||||||
|
cat > stack.${PROJECT_SLUG}.yaml <<EOF
|
||||||
|
version: "3.8"
|
||||||
|
|
||||||
|
services:
|
||||||
|
app:
|
||||||
|
image: ${PROJECT_SLUG}:latest
|
||||||
|
networks:
|
||||||
|
- network_public
|
||||||
|
environment:
|
||||||
|
NEXT_PUBLIC_APP_NAME: "${APP_NAME}"
|
||||||
|
NEXT_PUBLIC_APP_LOGO_URL: ""
|
||||||
|
NEXT_PUBLIC_APP_PRIMARY_COLOR: "#1d4ed8"
|
||||||
|
NEXT_PUBLIC_AFTER_PAYMENT_REDIRECT: "${AFTER_PAYMENT}"
|
||||||
|
NEXT_PUBLIC_ADMIN_USER: "admin"
|
||||||
|
NEXT_PUBLIC_ADMIN_PASSWORD: "${ADMIN_PASS}"
|
||||||
|
ASAAS_API_KEY: "${ASAAS_KEY}"
|
||||||
|
ASAAS_ENV: "${ASAAS_ENV}"
|
||||||
|
NEXT_PUBLIC_SUPABASE_URL: "${SUPABASE_URL}"
|
||||||
|
NEXT_PUBLIC_SUPABASE_ANON_KEY: "${SUPABASE_ANON}"
|
||||||
|
SUPABASE_SERVICE_ROLE_KEY: "${SUPABASE_SERVICE}"
|
||||||
|
N8N_WEBHOOK_URL: "${N8N_WEBHOOK}"
|
||||||
|
N8N_PIX_WEBHOOK_URL: "${N8N_PIX}"
|
||||||
|
deploy:
|
||||||
|
replicas: 1
|
||||||
|
restart_policy:
|
||||||
|
condition: on-failure
|
||||||
|
labels:
|
||||||
|
- traefik.enable=true
|
||||||
|
- traefik.http.routers.${PROJECT_SLUG}.rule=Host(\`${DOMAIN}\`)
|
||||||
|
- traefik.http.routers.${PROJECT_SLUG}.entrypoints=websecure
|
||||||
|
- traefik.http.routers.${PROJECT_SLUG}.tls.certresolver=letsencrypt
|
||||||
|
- traefik.http.services.${PROJECT_SLUG}.loadbalancer.server.port=3000
|
||||||
|
|
||||||
|
networks:
|
||||||
|
network_public:
|
||||||
|
external: true
|
||||||
|
EOF
|
||||||
|
|
||||||
|
echo "✓ stack.${PROJECT_SLUG}.yaml criado"
|
||||||
|
echo ""
|
||||||
|
echo "Próximos passos:"
|
||||||
|
echo " 1. docker build -t ${PROJECT_SLUG}:latest ."
|
||||||
|
echo " 2. docker stack deploy -c stack.${PROJECT_SLUG}.yaml ${PROJECT_SLUG}"
|
||||||
|
echo " 3. docker service update --image ${PROJECT_SLUG}:latest --force ${PROJECT_SLUG}_app"
|
||||||
|
echo ""
|
||||||
|
echo "Admin: https://${DOMAIN}/admin | usuário: admin | senha: ${ADMIN_PASS}"
|
||||||
90
styles/globals.css
Normal file
90
styles/globals.css
Normal file
@@ -0,0 +1,90 @@
|
|||||||
|
@tailwind base;
|
||||||
|
@tailwind components;
|
||||||
|
@tailwind utilities;
|
||||||
|
|
||||||
|
@layer utilities {
|
||||||
|
.text-balance {
|
||||||
|
text-wrap: balance;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@layer base {
|
||||||
|
:root {
|
||||||
|
--background: 0 0% 100%;
|
||||||
|
--foreground: 0 0% 3.9%;
|
||||||
|
--card: 0 0% 100%;
|
||||||
|
--card-foreground: 0 0% 3.9%;
|
||||||
|
--popover: 0 0% 100%;
|
||||||
|
--popover-foreground: 0 0% 3.9%;
|
||||||
|
--primary: 0 0% 9%;
|
||||||
|
--primary-foreground: 0 0% 98%;
|
||||||
|
--secondary: 0 0% 96.1%;
|
||||||
|
--secondary-foreground: 0 0% 9%;
|
||||||
|
--muted: 0 0% 96.1%;
|
||||||
|
--muted-foreground: 0 0% 45.1%;
|
||||||
|
--accent: 0 0% 96.1%;
|
||||||
|
--accent-foreground: 0 0% 9%;
|
||||||
|
--destructive: 0 84.2% 60.2%;
|
||||||
|
--destructive-foreground: 0 0% 98%;
|
||||||
|
--border: 0 0% 89.8%;
|
||||||
|
--input: 0 0% 89.8%;
|
||||||
|
--ring: 0 0% 3.9%;
|
||||||
|
--chart-1: 12 76% 61%;
|
||||||
|
--chart-2: 173 58% 39%;
|
||||||
|
--chart-3: 197 37% 24%;
|
||||||
|
--chart-4: 43 74% 66%;
|
||||||
|
--chart-5: 27 87% 67%;
|
||||||
|
--radius: 0.5rem;
|
||||||
|
--sidebar-background: 0 0% 98%;
|
||||||
|
--sidebar-foreground: 240 5.3% 26.1%;
|
||||||
|
--sidebar-primary: 240 5.9% 10%;
|
||||||
|
--sidebar-primary-foreground: 0 0% 98%;
|
||||||
|
--sidebar-accent: 240 4.8% 95.9%;
|
||||||
|
--sidebar-accent-foreground: 240 5.9% 10%;
|
||||||
|
--sidebar-border: 220 13% 91%;
|
||||||
|
--sidebar-ring: 217.2 91.2% 59.8%;
|
||||||
|
}
|
||||||
|
.dark {
|
||||||
|
--background: 0 0% 3.9%;
|
||||||
|
--foreground: 0 0% 98%;
|
||||||
|
--card: 0 0% 3.9%;
|
||||||
|
--card-foreground: 0 0% 98%;
|
||||||
|
--popover: 0 0% 3.9%;
|
||||||
|
--popover-foreground: 0 0% 98%;
|
||||||
|
--primary: 0 0% 98%;
|
||||||
|
--primary-foreground: 0 0% 9%;
|
||||||
|
--secondary: 0 0% 14.9%;
|
||||||
|
--secondary-foreground: 0 0% 98%;
|
||||||
|
--muted: 0 0% 14.9%;
|
||||||
|
--muted-foreground: 0 0% 63.9%;
|
||||||
|
--accent: 0 0% 14.9%;
|
||||||
|
--accent-foreground: 0 0% 98%;
|
||||||
|
--destructive: 0 62.8% 30.6%;
|
||||||
|
--destructive-foreground: 0 0% 98%;
|
||||||
|
--border: 0 0% 14.9%;
|
||||||
|
--input: 0 0% 14.9%;
|
||||||
|
--ring: 0 0% 83.1%;
|
||||||
|
--chart-1: 220 70% 50%;
|
||||||
|
--chart-2: 160 60% 45%;
|
||||||
|
--chart-3: 30 80% 55%;
|
||||||
|
--chart-4: 280 65% 60%;
|
||||||
|
--chart-5: 340 75% 55%;
|
||||||
|
--sidebar-background: 240 5.9% 10%;
|
||||||
|
--sidebar-foreground: 240 4.8% 95.9%;
|
||||||
|
--sidebar-primary: 224.3 76.3% 48%;
|
||||||
|
--sidebar-primary-foreground: 0 0% 100%;
|
||||||
|
--sidebar-accent: 240 3.7% 15.9%;
|
||||||
|
--sidebar-accent-foreground: 240 4.8% 95.9%;
|
||||||
|
--sidebar-border: 240 3.7% 15.9%;
|
||||||
|
--sidebar-ring: 217.2 91.2% 59.8%;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@layer base {
|
||||||
|
* {
|
||||||
|
@apply border-border;
|
||||||
|
}
|
||||||
|
body {
|
||||||
|
@apply bg-background text-foreground;
|
||||||
|
}
|
||||||
|
}
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user