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:
2026-04-16 06:40:41 +02:00
commit 038ce3f556
103 changed files with 20709 additions and 0 deletions

44
.env.example Normal file
View 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
View File

@@ -0,0 +1,6 @@
.env.local
.env
node_modules/
.next/
stack.*.yaml
*.local

57
.specs/PROJECT.md Normal file
View 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
View 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
View 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
View 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
View 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
View 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
View 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>
)
}

View 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 })
}

View 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 })
}
}

View 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 })
}

View 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)
}

View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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>
)
}

View 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
View File

@@ -0,0 +1,5 @@
import { redirect } from "next/navigation"
export default function ProdutosPage() {
redirect("/comprar")
}

21
components.json Normal file
View 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"
}

View 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
View 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
View 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>
)
}

View 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>
)
}

View 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>
)
}

View 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 lugar.
</p>
</div>
<div className="flex items-center gap-2">{children}</div>
</div>
)
}

View 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>
)
}

View 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>
))}
</>
)
}

View 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>
)
}

View 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>
)
}

View 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>
)
}

View 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>
)
}

View 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>
)
}

View 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
View 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
View 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
View 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>
)
}

View 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>
)
}

View 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>
)
}

View 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>
)
}

View 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>
)
}

View 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 &quot;{selectedProduct?.nome}&quot;? 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>
</>
)
}

View 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>
)
}

View 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>
)
}

View 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>
)
}

View 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>
}

View 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>
)
}

View 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
View 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
View 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
View 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 }

View 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
View 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 }

View 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
View 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
View 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,
}

View 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
View 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
View 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
View 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
View 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,
}

View 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
View 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,
}

View 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
View 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
View 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
View 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 }

View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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

File diff suppressed because it is too large Load Diff

65
package.json Normal file
View 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

File diff suppressed because it is too large Load Diff

6
postcss.config.js Normal file
View File

@@ -0,0 +1,6 @@
module.exports = {
plugins: {
tailwindcss: {},
autoprefixer: {},
},
}

8
postcss.config.mjs Normal file
View 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

Binary file not shown.

After

Width:  |  Height:  |  Size: 568 B

View 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

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.6 KiB

BIN
public/placeholder.jpg Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.0 KiB

1
public/placeholder.svg Normal file
View 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
View 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
View 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