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