feat: initial commit — asaas-checkout template white-label

Template genérico de checkout com ASAAS, parametrizado via env vars.
Inclui fluxo completo: checkout → pedido → polling → webhook → admin.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-04-16 06:40:41 +02:00
commit 038ce3f556
103 changed files with 20709 additions and 0 deletions

73
app/admin/columns.tsx Normal file
View File

@@ -0,0 +1,73 @@
"use client"
import type { ColumnDef } from "@tanstack/react-table"
import { Checkbox } from "@/components/ui/checkbox"
import type { productSchema } from "@/lib/schema"
import { DataTableColumnHeader } from "@/components/data-table-column-header"
import { DataTableRowActions } from "@/components/data-table-row-actions"
import type { z } from "zod"
export type Product = z.infer<typeof productSchema>
export const columns: ColumnDef<Product>[] = [
{
id: "select",
header: ({ table }) => (
<Checkbox
checked={table.getIsAllPageRowsSelected()}
onCheckedChange={(value) => table.toggleAllPageRowsSelected(!!value)}
aria-label="Selecionar tudo"
className="translate-y-[2px]"
/>
),
cell: ({ row }) => (
<Checkbox
checked={row.getIsSelected()}
onCheckedChange={(value) => row.toggleSelected(!!value)}
aria-label="Selecionar linha"
className="translate-y-[2px]"
/>
),
enableSorting: false,
enableHiding: false,
},
{
accessorKey: "name",
header: ({ column }) => <DataTableColumnHeader column={column} title="Nome" />,
cell: ({ row }) => <div className="w-[180px]">{row.getValue("name")}</div>,
enableSorting: true,
enableHiding: true,
},
{
accessorKey: "price.display_amount",
header: ({ column }) => <DataTableColumnHeader column={column} title="Preço" />,
cell: ({ row }) => {
const amount = Number.parseFloat(row.getValue("price.display_amount"))
const formatted = new Intl.NumberFormat("pt-BR", {
style: "currency",
currency: "BRL",
}).format(amount)
return <div className="font-medium">{formatted}</div>
},
enableSorting: true,
enableHiding: true,
},
{
accessorKey: "tipo",
header: ({ column }) => <DataTableColumnHeader column={column} title="Tipo" />,
cell: ({ row }) => <div>{row.getValue("tipo")}</div>,
enableSorting: true,
enableHiding: true,
},
{
accessorKey: "validade",
header: ({ column }) => <DataTableColumnHeader column={column} title="Validade" />,
cell: ({ row }) => <div>{row.getValue("validade")}</div>,
enableSorting: true,
enableHiding: true,
},
{
id: "actions",
cell: ({ row }) => <DataTableRowActions row={row} />,
},
]

14
app/admin/layout.tsx Normal file
View File

@@ -0,0 +1,14 @@
import type React from "react"
export default function AdminLayout({
children,
}: {
children: React.ReactNode
}) {
return (
<div className="flex min-h-screen flex-col">
{/* Remover o SiteHeader e SiteFooter daqui, pois já estão no layout principal */}
<main className="flex-1">{children}</main>
</div>
)
}

144
app/admin/page.tsx Normal file
View File

@@ -0,0 +1,144 @@
import { getProdutos, getPedidos, getClientes, getCupons, getAgendamentos } from "@/lib/actions"
import { ProductTable } from "@/components/product-table"
import { TransactionTable } from "@/components/transaction-table"
import { CustomerTable } from "@/components/customer-table"
import { DashboardHeader } from "@/components/dashboard-header"
import { DashboardShell } from "@/components/dashboard-shell"
import { DashboardStats } from "@/components/dashboard-stats"
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"
import { ProductFormDialog } from "@/components/product-form-dialog"
import { CustomerFormDialog } from "@/components/customer-form-dialog"
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card"
import { Overview } from "@/components/overview"
import { RecentSales } from "@/components/recent-sales"
import { AuthGuard } from "@/components/auth-guard"
import { CupomTable } from "@/components/cupom-table"
import { AgendamentosTable } from "@/components/agendamentos-table"
export const dynamic = "force-dynamic"
export const metadata = {
title: "Dashboard — Admin",
}
export default async function AdminDashboard() {
const [produtos, pedidos, clientes, cupons, agendamentos] = await Promise.all([
getProdutos(),
getPedidos({ limit: 200 }),
getClientes({ limit: 200 }),
getCupons(),
getAgendamentos({ limit: 100 }),
])
const pedidosConfirmados = pedidos.filter(
(p) => p.status === "RECEIVED" || p.status === "CONFIRMED"
)
const pedidosPendentes = pedidos.filter((p) => p.status === "PENDING").length
const receitaTotal = pedidosConfirmados.reduce((acc, p) => acc + p.valor_centavos / 100, 0)
const ticketMedio = pedidosConfirmados.length > 0 ? receitaTotal / pedidosConfirmados.length : 0
// Receita mensal (12 meses) para o gráfico
const receitaMensal = Array(12).fill(0)
pedidosConfirmados.forEach((p) => {
const mes = new Date(p.created_at).getMonth()
receitaMensal[mes] += p.valor_centavos / 100
})
// Últimas 5 vendas para o card de resumo
const ultimasVendas = pedidosConfirmados.slice(0, 5) as Parameters<typeof RecentSales>[0]["vendas"]
// Contagem de agendamentos pendentes para badge
const agendamentosPendentes = agendamentos.filter(
(a) => a.status === "pendente" || a.status === "confirmado"
).length
return (
<AuthGuard>
<DashboardShell>
<DashboardHeader
heading="Dashboard de Administração"
text="Gerencie seus produtos, clientes e visualize estatísticas de vendas."
>
<ProductFormDialog />
</DashboardHeader>
{/* Cards de métricas */}
<div className="grid gap-4 md:grid-cols-2 lg:grid-cols-4">
<DashboardStats
receitaTotal={receitaTotal}
totalPedidosConfirmados={pedidosConfirmados.length}
ticketMedio={ticketMedio}
pedidosPendentes={pedidosPendentes}
/>
</div>
{/* Gráfico + últimas vendas */}
<div className="grid gap-4 md:grid-cols-2 lg:grid-cols-7">
<Card className="col-span-4">
<CardHeader>
<CardTitle className="font-heading">Receita Mensal</CardTitle>
</CardHeader>
<CardContent className="pl-2">
<Overview data={receitaMensal} />
</CardContent>
</Card>
<Card className="col-span-3">
<CardHeader>
<CardTitle className="font-heading">Últimas Vendas</CardTitle>
<CardDescription className="font-sans">
{pedidosConfirmados.length} venda{pedidosConfirmados.length !== 1 ? "s" : ""} confirmada{pedidosConfirmados.length !== 1 ? "s" : ""} no total.
</CardDescription>
</CardHeader>
<CardContent>
<RecentSales vendas={ultimasVendas} />
</CardContent>
</Card>
</div>
{/* Abas */}
<Tabs defaultValue="pedidos" className="space-y-4">
<TabsList>
<TabsTrigger value="pedidos">Pedidos</TabsTrigger>
<TabsTrigger value="agendamentos">
Agendamentos
{agendamentosPendentes > 0 && (
<span className="ml-1.5 inline-flex items-center justify-center rounded-full bg-primary text-primary-foreground text-[10px] font-bold w-4 h-4">
{agendamentosPendentes}
</span>
)}
</TabsTrigger>
<TabsTrigger value="products">Produtos</TabsTrigger>
<TabsTrigger value="customers">Clientes</TabsTrigger>
<TabsTrigger value="cupons">Cupons</TabsTrigger>
</TabsList>
<TabsContent value="pedidos" className="space-y-4">
<TransactionTable transactions={pedidos} />
</TabsContent>
<TabsContent value="agendamentos" className="space-y-4">
<AgendamentosTable agendamentos={agendamentos as Parameters<typeof AgendamentosTable>[0]["agendamentos"]} />
</TabsContent>
<TabsContent value="products" className="space-y-4">
<div className="flex justify-end mb-4">
<ProductFormDialog />
</div>
<ProductTable products={produtos} />
</TabsContent>
<TabsContent value="customers" className="space-y-4">
<div className="flex justify-end mb-4">
<CustomerFormDialog />
</div>
<CustomerTable customers={clientes} />
</TabsContent>
<TabsContent value="cupons" className="space-y-4">
<CupomTable cupons={cupons} />
</TabsContent>
</Tabs>
</DashboardShell>
</AuthGuard>
)
}

View File

@@ -0,0 +1,28 @@
import { NextResponse } from "next/server"
import { createServiceClient } from "@/lib/supabase"
export async function PATCH(
request: Request,
{ params }: { params: Promise<{ id: string }> }
) {
const { id } = await params
const body = await request.json().catch(() => ({}))
const { status } = body as { status?: string }
const STATUSES_VALIDOS = ["pendente", "confirmado", "realizado", "cancelado"]
if (!status || !STATUSES_VALIDOS.includes(status)) {
return NextResponse.json({ error: "Status inválido" }, { status: 400 })
}
const supabase = createServiceClient()
const { error } = await supabase
.from("agendamentos")
.update({ status, updated_at: new Date().toISOString() })
.eq("id", id)
if (error) {
return NextResponse.json({ error: error.message }, { status: 500 })
}
return NextResponse.json({ ok: true, status })
}

View File

@@ -0,0 +1,102 @@
import { NextResponse } from "next/server"
import { createServiceClient } from "@/lib/supabase"
import { parseWebhookPayload, mapearStatus } from "@/lib/asaas"
const N8N_WEBHOOK_URL = process.env.N8N_WEBHOOK_URL ?? ""
export async function POST(request: Request) {
let body: unknown
try {
body = await request.json()
} catch {
return NextResponse.json({ error: "Invalid JSON" }, { status: 400 })
}
const supabase = createServiceClient()
// Salva log do webhook para auditoria
await supabase.from("webhook_logs").insert({
evento: (body as { event?: string }).event ?? "unknown",
payload: body,
processado: false,
})
try {
const event = parseWebhookPayload(body)
// Processa apenas eventos de pagamento
if (!event.event.startsWith("PAYMENT_")) {
return NextResponse.json({ ok: true })
}
const novoStatus = mapearStatus(event.payment.status)
const isPago =
event.payment.status === "RECEIVED" || event.payment.status === "CONFIRMED"
const paidAt = isPago ? new Date().toISOString() : null
const updateData: Record<string, unknown> = { status: novoStatus }
if (paidAt) updateData.paid_at = paidAt
// Atualiza pedido e busca dados completos para notificação
const { data: pedido } = await supabase
.from("pedidos")
.update(updateData)
.eq("asaas_payment_id", event.payment.id)
.select("id, valor_centavos, metodo_pagamento, cliente_id, produto_id")
.single()
if (pedido) {
// Marca webhook como processado
await supabase
.from("webhook_logs")
.update({ processado: true })
.eq("evento", event.event)
.order("created_at", { ascending: false })
.limit(1)
// Dispara automação n8n apenas em pagamentos confirmados
if (isPago && N8N_WEBHOOK_URL) {
// Busca cliente e produto para enriquecer o payload
const [{ data: cliente }, { data: produto }] = await Promise.all([
supabase.from("clientes").select("nome, email, telefone, cpf_cnpj").eq("id", pedido.cliente_id).single(),
supabase.from("produtos").select("nome, tipo, validade, midia").eq("id", pedido.produto_id).single(),
])
const notificacao = {
evento: event.event,
pedido_id: pedido.id,
asaas_payment_id: event.payment.id,
valor: (pedido.valor_centavos / 100).toLocaleString("pt-BR", { minimumFractionDigits: 2, style: "currency", currency: "BRL" }),
metodo: pedido.metodo_pagamento,
cliente: {
nome: cliente?.nome ?? "—",
email: cliente?.email ?? "—",
telefone: cliente?.telefone ?? "—",
cpf_cnpj: cliente?.cpf_cnpj ?? "—",
},
produto: {
nome: produto?.nome ?? "—",
tipo: produto?.tipo ?? "—",
validade: produto?.validade ?? "—",
midia: produto?.midia ?? "—",
},
pago_em: paidAt,
link_agendamento: process.env.NEXT_PUBLIC_AFTER_PAYMENT_REDIRECT ?? "/",
}
// Fire-and-forget — não bloqueia a resposta ao ASAAS
fetch(N8N_WEBHOOK_URL, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(notificacao),
}).catch((err) => console.error("n8n webhook error:", err))
}
}
return NextResponse.json({ ok: true })
} catch (error) {
console.error("Webhook processing error:", error)
return NextResponse.json({ error: "Processing failed" }, { status: 500 })
}
}

View File

@@ -0,0 +1,6 @@
// Rota legada do Stripe — substituída por /api/asaas-webhook
import { NextResponse } from "next/server"
export async function GET() {
return NextResponse.json({ message: "Use ASAAS checkout via Server Actions" }, { status: 410 })
}

View File

@@ -0,0 +1,48 @@
import { NextResponse } from "next/server"
import { createServiceClient } from "@/lib/supabase"
import { buscarPagamentoAsaas, mapearStatus } from "@/lib/asaas"
export async function GET(
_request: Request,
{ params }: { params: Promise<{ id: string }> }
) {
const { id } = await params
const supabase = createServiceClient()
const { data, error } = await supabase
.from("pedidos")
.select(
"id, status, valor_centavos, metodo_pagamento, pix_copia_cola, pix_qrcode_url, asaas_invoice_url, asaas_payment_id, paid_at, clientes(nome, email), produtos(nome, validade, midia)"
)
.eq("id", id)
.single()
if (error || !data) {
return NextResponse.json({ error: "Pedido não encontrado" }, { status: 404 })
}
// Se ainda está pendente, consulta o ASAAS diretamente para pegar status atualizado
if (data.status === "PENDING" && data.asaas_payment_id) {
try {
const asaasPayment = await buscarPagamentoAsaas(data.asaas_payment_id)
const novoStatus = mapearStatus(asaasPayment.status)
if (novoStatus !== "PENDING") {
const isPago = novoStatus === "RECEIVED" || novoStatus === "CONFIRMED"
const updateData: Record<string, unknown> = { status: novoStatus }
if (isPago) updateData.paid_at = new Date().toISOString()
await supabase
.from("pedidos")
.update(updateData)
.eq("id", id)
return NextResponse.json({ ...data, status: novoStatus, paid_at: isPago ? updateData.paid_at : null })
}
} catch {
// Falha silenciosa — retorna o que tem no banco
}
}
return NextResponse.json(data)
}

View File

@@ -0,0 +1,45 @@
import { NextResponse } from "next/server"
import { createServiceClient } from "@/lib/supabase"
import { buscarPagamentoAsaas, mapearStatus } from "@/lib/asaas"
export async function POST(
_request: Request,
{ params }: { params: Promise<{ id: string }> }
) {
const { id } = await params
const supabase = createServiceClient()
const { data: pedido } = await supabase
.from("pedidos")
.select("id, status, asaas_payment_id")
.eq("id", id)
.single()
if (!pedido) {
return NextResponse.json({ error: "Pedido não encontrado" }, { status: 404 })
}
if (!pedido.asaas_payment_id) {
return NextResponse.json({ error: "Pedido sem ID ASAAS" }, { status: 400 })
}
try {
const asaasPayment = await buscarPagamentoAsaas(pedido.asaas_payment_id)
const novoStatus = mapearStatus(asaasPayment.status)
const isPago = novoStatus === "RECEIVED" || novoStatus === "CONFIRMED"
const updateData: Record<string, unknown> = { status: novoStatus }
if (isPago && !pedido.status.includes("RECEIVED") && !pedido.status.includes("CONFIRMED")) {
updateData.paid_at = new Date().toISOString()
}
await supabase.from("pedidos").update(updateData).eq("id", id)
return NextResponse.json({ status: novoStatus, asaas_status: asaasPayment.status })
} catch (err) {
return NextResponse.json(
{ error: err instanceof Error ? err.message : "Erro ao consultar ASAAS" },
{ status: 500 }
)
}
}

6
app/api/webhook/route.ts Normal file
View File

@@ -0,0 +1,6 @@
// Rota legada do Stripe — substituída por /api/asaas-webhook
import { NextResponse } from "next/server"
export async function POST() {
return NextResponse.json({ message: "Use /api/asaas-webhook" }, { status: 410 })
}

75
app/comprar/page.tsx Normal file
View File

@@ -0,0 +1,75 @@
export const dynamic = "force-dynamic"
import Link from "next/link"
import { getProdutos } from "@/lib/actions"
import { Button } from "@/components/ui/button"
import { Card, CardContent, CardFooter, CardHeader, CardTitle } from "@/components/ui/card"
import { appConfig } from "@/lib/config"
export const metadata = {
title: "Produtos",
}
export default async function ComprarPage() {
const produtos = await getProdutos()
return (
<div className="container mx-auto py-12 px-4 max-w-5xl">
<div className="text-center mb-10">
<h1 className="text-4xl font-bold mb-3">Produtos</h1>
<p className="text-lg text-muted-foreground">
Escolha o produto e conclua sua compra em poucos minutos.
</p>
</div>
{produtos.length === 0 ? (
<div className="text-center py-20 text-muted-foreground">
Nenhum produto disponível no momento.
</div>
) : (
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-6">
{produtos.map((p) => (
<Card key={p.id} className="flex flex-col hover:shadow-lg transition-shadow">
<CardHeader>
<CardTitle className="text-lg">{p.nome}</CardTitle>
{p.descricao && (
<p className="text-sm text-muted-foreground">{p.descricao}</p>
)}
</CardHeader>
<CardContent className="flex-1 space-y-1">
<p className="text-3xl font-bold text-primary">
{(p.preco_centavos / 100).toLocaleString("pt-BR", {
style: "currency",
currency: "BRL",
})}
</p>
{p.validade && (
<p className="text-sm text-muted-foreground">Validade: {p.validade}</p>
)}
</CardContent>
<CardFooter>
<Button asChild className="w-full">
<Link href={`/produtos/${p.id}`}>Comprar agora</Link>
</Button>
</CardFooter>
</Card>
))}
</div>
)}
{appConfig.supportWhatsapp && (
<p className="text-center mt-12 text-sm text-muted-foreground">
Dúvidas?{" "}
<a
href={`https://wa.me/${appConfig.supportWhatsapp}`}
target="_blank"
rel="noopener noreferrer"
className="text-primary underline"
>
Fale conosco no WhatsApp
</a>
</p>
)}
</div>
)
}

84
app/globals.css Normal file
View File

@@ -0,0 +1,84 @@
@tailwind base;
@tailwind components;
@tailwind utilities;
@layer base {
:root {
--background: 0 0% 100%;
--foreground: 222.2 84% 4.9%;
--card: 0 0% 100%;
--card-foreground: 222.2 84% 4.9%;
--popover: 0 0% 100%;
--popover-foreground: 222.2 84% 4.9%;
--primary: 212 100% 18%;
--primary-foreground: 210 40% 98%;
--secondary: 25 100% 50%;
--secondary-foreground: 210 40% 98%;
--muted: 210 40% 96.1%;
--muted-foreground: 215.4 16.3% 46.9%;
--accent: 210 40% 96.1%;
--accent-foreground: 222.2 47.4% 11.2%;
--destructive: 0 84.2% 60.2%;
--destructive-foreground: 210 40% 98%;
--border: 214.3 31.8% 91.4%;
--input: 214.3 31.8% 91.4%;
--ring: 222.2 84% 4.9%;
--radius: 0.5rem;
}
.dark {
--background: 222.2 84% 4.9%;
--foreground: 210 40% 98%;
--card: 222.2 84% 4.9%;
--card-foreground: 210 40% 98%;
--popover: 222.2 84% 4.9%;
--popover-foreground: 210 40% 98%;
--primary: 210 40% 98%;
--primary-foreground: 222.2 47.4% 11.2%;
--secondary: 25 100% 50%;
--secondary-foreground: 210 40% 98%;
--muted: 217.2 32.6% 17.5%;
--muted-foreground: 215 20.2% 65.1%;
--accent: 217.2 32.6% 17.5%;
--accent-foreground: 210 40% 98%;
--destructive: 0 62.8% 30.6%;
--destructive-foreground: 210 40% 98%;
--border: 217.2 32.6% 17.5%;
--input: 217.2 32.6% 17.5%;
--ring: 212.7 26.8% 83.9%;
}
}
@layer base {
* {
@apply border-border;
}
body {
@apply bg-background text-foreground;
}
h1,
h2,
h3,
h4,
h5,
h6 {
@apply font-sans;
}
}

27
app/layout-client.tsx Normal file
View File

@@ -0,0 +1,27 @@
"use client"
import { ThemeProvider } from "@/components/theme-provider"
import { SiteHeader } from "@/components/site-header"
import { SiteFooter } from "@/components/site-footer"
import { Toaster } from "@/components/ui/toaster"
import type React from "react"
import { AuthProvider } from "@/lib/auth-context"
import { usePathname } from "next/navigation"
export default function ClientRootLayoutContent({ children }: { children: React.ReactNode }) {
const pathname = usePathname()
const isLoginPage = pathname === "/login"
return (
<ThemeProvider attribute="class" defaultTheme="light" enableSystem>
<AuthProvider>
<div className="relative flex min-h-screen flex-col">
{!isLoginPage && <SiteHeader />}
<main className="flex-1">{children}</main>
{!isLoginPage && <SiteFooter />}
</div>
<Toaster />
</AuthProvider>
</ThemeProvider>
)
}

40
app/layout.tsx Normal file
View File

@@ -0,0 +1,40 @@
import type React from "react"
import { cn } from "@/lib/utils"
import { Cairo, Inter } from "next/font/google"
import "./globals.css"
import ClientRootLayoutContent from "./layout-client"
const kiro = Cairo({
subsets: ["latin"],
variable: "--font-kiro",
weight: ["300", "400", "500", "700"],
})
const inter = Inter({
subsets: ["latin"],
variable: "--font-inter",
})
export const metadata = {
title: process.env.NEXT_PUBLIC_APP_NAME ?? "Checkout",
description: "Soluções de certificação digital confiáveis e seguras para sua empresa",
icons: {
icon: [
{
url: "https://hebbkx1anhila5yf.public.blob.vercel-storage.com/WhatsApp%20Image%202025-01-31%20at%2022.10.07-5kqQGcVq7IsS13DgbQ5wYwozgoQHHJ.jpeg",
type: "image/jpeg",
},
],
},
generator: 'v0.app'
}
export default function RootLayout({ children }: { children: React.ReactNode }) {
return (
<html lang="pt-BR" suppressHydrationWarning>
<body className={cn("min-h-screen bg-background font-sans antialiased", kiro.variable, inter.variable)}>
<ClientRootLayoutContent>{children}</ClientRootLayoutContent>
</body>
</html>
)
}

70
app/login/page.tsx Normal file
View File

@@ -0,0 +1,70 @@
"use client"
import type React from "react"
import { useState } from "react"
import { useRouter } from "next/navigation"
import { Button } from "@/components/ui/button"
import { Card, CardContent, CardHeader, CardTitle, CardDescription, CardFooter } from "@/components/ui/card"
import { Input } from "@/components/ui/input"
import { Label } from "@/components/ui/label"
import { useToast } from "@/components/ui/use-toast"
import { useAuth } from "@/lib/auth-context"
import { appConfig } from "@/lib/config"
import { Icons } from "@/components/icons"
export default function LoginPage() {
const { login } = useAuth()
const router = useRouter()
const { toast } = useToast()
const [isLoading, setIsLoading] = useState(false)
async function onSubmit(event: React.FormEvent) {
event.preventDefault()
setIsLoading(true)
const formData = new FormData(event.target as HTMLFormElement)
const username = formData.get("username") as string
const password = formData.get("password") as string
const ok = await login(username, password)
if (ok) {
router.push("/admin")
} else {
toast({
title: "Acesso negado",
description: "Usuário ou senha incorretos.",
variant: "destructive",
})
setIsLoading(false)
}
}
return (
<div className="container flex h-screen w-screen flex-col items-center justify-center">
<Card className="w-[350px]">
<CardHeader className="space-y-1">
<CardTitle className="text-2xl">{appConfig.name}</CardTitle>
<CardDescription>Área administrativa</CardDescription>
</CardHeader>
<form onSubmit={onSubmit}>
<CardContent className="grid gap-4">
<div className="grid gap-2">
<Label htmlFor="username">Usuário</Label>
<Input id="username" name="username" type="text" required disabled={isLoading} />
</div>
<div className="grid gap-2">
<Label htmlFor="password">Senha</Label>
<Input id="password" name="password" type="password" required disabled={isLoading} />
</div>
</CardContent>
<CardFooter>
<Button className="w-full" type="submit" disabled={isLoading}>
{isLoading && <Icons.spinner className="mr-2 h-4 w-4 animate-spin" />}
Entrar
</Button>
</CardFooter>
</form>
</Card>
</div>
)
}

64
app/page.tsx Normal file
View File

@@ -0,0 +1,64 @@
export const dynamic = "force-dynamic"
import Link from "next/link"
import { getProdutos } from "@/lib/actions"
import { Button } from "@/components/ui/button"
import { Card, CardContent, CardFooter, CardHeader, CardTitle } from "@/components/ui/card"
import { appConfig } from "@/lib/config"
import { ShoppingBag } from "lucide-react"
export default async function HomePage() {
const produtos = await getProdutos()
return (
<div className="min-h-screen bg-gradient-to-b from-gray-50 to-white">
{/* Hero */}
<section className="container mx-auto py-20 px-4 text-center">
<h1 className="text-4xl sm:text-5xl font-bold text-primary mb-4">
{appConfig.name}
</h1>
<p className="text-lg text-muted-foreground mb-8 max-w-xl mx-auto">
Escolha um produto abaixo e conclua sua compra em poucos cliques.
</p>
<Button asChild size="lg">
<Link href="/comprar">
<ShoppingBag className="mr-2 h-5 w-5" />
Ver todos os produtos
</Link>
</Button>
</section>
{/* Produtos em destaque */}
{produtos.length > 0 && (
<section className="container mx-auto pb-20 px-4">
<h2 className="text-2xl font-bold text-center mb-10">Produtos disponíveis</h2>
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-6 max-w-5xl mx-auto">
{produtos.map((p) => (
<Card key={p.id} className="flex flex-col hover:shadow-lg transition-shadow">
<CardHeader>
<CardTitle className="text-lg">{p.nome}</CardTitle>
{p.descricao && (
<p className="text-sm text-muted-foreground">{p.descricao}</p>
)}
</CardHeader>
<CardContent className="flex-1">
<p className="text-3xl font-bold text-primary">
{(p.preco_centavos / 100).toLocaleString("pt-BR", {
style: "currency",
currency: "BRL",
})}
</p>
</CardContent>
<CardFooter>
<Button asChild className="w-full">
<Link href={`/produtos/${p.id}`}>Comprar agora</Link>
</Button>
</CardFooter>
</Card>
))}
</div>
</section>
)}
</div>
)
}

37
app/pedido/[id]/page.tsx Normal file
View File

@@ -0,0 +1,37 @@
import { createServiceClient } from "@/lib/supabase"
import PedidoStatus from "@/components/pedido-status"
import { notFound } from "next/navigation"
import type { Metadata } from "next"
type Props = { params: Promise<{ id: string }> }
export const metadata: Metadata = {
title: "Seu Pedido",
}
export default async function PedidoPage({ params }: Props) {
const { id } = await params
const supabase = createServiceClient()
const { data: pedido, error } = await supabase
.from("pedidos")
.select(
"id, status, valor_centavos, metodo_pagamento, pix_copia_cola, pix_qrcode_url, asaas_invoice_url, paid_at, clientes(nome, email), produtos(nome, validade, midia)"
)
.eq("id", id)
.single()
if (error || !pedido) notFound()
return (
<div className="container py-16 px-4">
<div className="max-w-2xl mx-auto mb-8 text-center">
<h1 className="text-3xl font-bold mb-2">Seu Pedido</h1>
<p className="text-muted-foreground">
Acompanhe o status do seu pedido
</p>
</div>
<PedidoStatus pedidoInicial={pedido} />
</div>
)
}

View File

@@ -0,0 +1,71 @@
import { getProdutoById } from "@/lib/actions"
import { ProductBuyForm } from "@/components/product-buy-form"
import { notFound } from "next/navigation"
import type { Metadata } from "next"
import { appConfig } from "@/lib/config"
type Props = { params: Promise<{ id: string }> }
export async function generateMetadata({ params }: Props): Promise<Metadata> {
try {
const { id } = await params
const produto = await getProdutoById(id)
if (!produto) return { title: "Produto não encontrado" }
return {
title: `${produto.nome}${appConfig.name}`,
description: produto.descricao ?? produto.nome,
}
} catch {
return { title: "Erro ao carregar produto" }
}
}
export default async function ProductPage({ params }: Props) {
const { id } = await params
const produto = await getProdutoById(id)
if (!produto) notFound()
const precoFormatado = (produto.preco_centavos / 100).toLocaleString("pt-BR", {
style: "currency",
currency: "BRL",
})
return (
<div className="bg-gradient-to-b from-gray-50 to-white min-h-screen">
<div className="container mx-auto py-16 px-4 sm:px-6 lg:px-8 max-w-5xl">
<div className="grid grid-cols-1 lg:grid-cols-2 gap-12">
{/* Detalhes do produto */}
<div>
<h1 className="text-3xl font-bold text-primary mb-4">{produto.nome}</h1>
{produto.descricao && (
<p className="text-gray-600 text-lg mb-6">{produto.descricao}</p>
)}
<div className="flex flex-wrap gap-3 text-sm text-gray-500">
{produto.tipo && (
<span className="bg-gray-100 px-3 py-1 rounded-full">
Tipo: <strong>{produto.tipo}</strong>
</span>
)}
{produto.validade && (
<span className="bg-gray-100 px-3 py-1 rounded-full">
Validade: <strong>{produto.validade}</strong>
</span>
)}
{produto.midia && (
<span className="bg-gray-100 px-3 py-1 rounded-full">
Mídia: <strong>{produto.midia}</strong>
</span>
)}
</div>
</div>
{/* Formulário de compra */}
<div className="bg-white p-8 rounded-2xl shadow-lg">
<p className="text-4xl font-bold mb-6 text-primary">{precoFormatado}</p>
<ProductBuyForm produtoId={produto.id} />
</div>
</div>
</div>
</div>
)
}

5
app/produtos/page.tsx Normal file
View File

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