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:
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")
|
||||
}
|
||||
Reference in New Issue
Block a user