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

View File

@@ -0,0 +1,133 @@
"use client"
import { useState } from "react"
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table"
import { Button } from "@/components/ui/button"
import { useToast } from "@/components/ui/use-toast"
import { Calendar, CheckCircle2, XCircle, Clock } from "lucide-react"
const statusConfig: Record<string, { label: string; color: string; icon: React.ReactNode }> = {
pendente: { label: "Pendente", color: "bg-yellow-100 text-yellow-800", icon: <Clock className="h-3 w-3" /> },
confirmado: { label: "Confirmado", color: "bg-blue-100 text-blue-800", icon: <Calendar className="h-3 w-3" /> },
realizado: { label: "Realizado", color: "bg-green-100 text-green-800", icon: <CheckCircle2 className="h-3 w-3" /> },
cancelado: { label: "Cancelado", color: "bg-red-100 text-red-800", icon: <XCircle className="h-3 w-3" /> },
}
type Agendamento = {
id: string
data_hora: string
status: string
observacoes: string | null
created_at: string
clientes: { nome: string; email: string } | null
produtos: { nome: string } | null
}
export function AgendamentosTable({ agendamentos: inicial }: { agendamentos: Agendamento[] }) {
const { toast } = useToast()
const [itens, setItens] = useState(inicial)
const [atualizando, setAtualizando] = useState<string | null>(null)
async function atualizarStatus(id: string, novoStatus: string) {
setAtualizando(id)
try {
const res = await fetch(`/api/agendamento/${id}/status`, {
method: "PATCH",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ status: novoStatus }),
})
if (!res.ok) throw new Error("Erro ao atualizar")
setItens((prev) =>
prev.map((a) => (a.id === id ? { ...a, status: novoStatus } : a))
)
toast({ title: `Status atualizado para "${novoStatus}"` })
} catch {
toast({ title: "Erro ao atualizar status", variant: "destructive" })
} finally {
setAtualizando(null)
}
}
if (itens.length === 0) {
return (
<div className="rounded-md border p-8 text-center text-muted-foreground">
Nenhum agendamento encontrado.
</div>
)
}
return (
<div className="rounded-md border">
<Table>
<TableHeader>
<TableRow>
<TableHead>Cliente</TableHead>
<TableHead>Produto</TableHead>
<TableHead>Data / Hora</TableHead>
<TableHead>Status</TableHead>
<TableHead>Observações</TableHead>
<TableHead>Ações</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{itens.map((a) => {
const cfg = statusConfig[a.status] ?? { label: a.status, color: "bg-gray-100 text-gray-800", icon: null }
const isLoading = atualizando === a.id
return (
<TableRow key={a.id}>
<TableCell>
<p className="font-medium text-sm">{a.clientes?.nome ?? "—"}</p>
<p className="text-xs text-muted-foreground">{a.clientes?.email ?? ""}</p>
</TableCell>
<TableCell className="text-sm">{a.produtos?.nome ?? "—"}</TableCell>
<TableCell className="text-sm whitespace-nowrap">
{new Date(a.data_hora).toLocaleString("pt-BR", {
day: "2-digit", month: "2-digit", year: "numeric",
hour: "2-digit", minute: "2-digit",
})}
</TableCell>
<TableCell>
<span className={`inline-flex items-center gap-1 px-2 py-0.5 rounded-full text-xs font-medium ${cfg.color}`}>
{cfg.icon}
{cfg.label}
</span>
</TableCell>
<TableCell className="text-sm text-muted-foreground max-w-[180px] truncate">
{a.observacoes ?? "—"}
</TableCell>
<TableCell>
<div className="flex gap-1">
{a.status !== "realizado" && (
<Button
size="sm"
variant="outline"
className="h-7 text-xs"
disabled={isLoading}
onClick={() => atualizarStatus(a.id, "realizado")}
>
<CheckCircle2 className="mr-1 h-3 w-3" />
Realizado
</Button>
)}
{a.status !== "cancelado" && a.status !== "realizado" && (
<Button
size="sm"
variant="outline"
className="h-7 text-xs text-red-600 hover:text-red-700"
disabled={isLoading}
onClick={() => atualizarStatus(a.id, "cancelado")}
>
<XCircle className="mr-1 h-3 w-3" />
Cancelar
</Button>
)}
</div>
</TableCell>
</TableRow>
)
})}
</TableBody>
</Table>
</div>
)
}

25
components/auth-guard.tsx Normal file
View File

@@ -0,0 +1,25 @@
"use client"
import { useEffect } from "react"
import { useRouter } from "next/navigation"
import { useToast } from "@/components/ui/use-toast"
import type React from "react" // Added import for React
export function AuthGuard({ children }: { children: React.ReactNode }) {
const router = useRouter()
const { toast } = useToast()
useEffect(() => {
const isLoggedIn = localStorage.getItem("isLoggedIn")
if (!isLoggedIn) {
toast({
title: "Acesso negado",
description: "Faça login para acessar a área administrativa.",
variant: "destructive",
})
router.push("/login")
}
}, [router, toast])
return <>{children}</>
}

239
components/cupom-table.tsx Normal file
View File

@@ -0,0 +1,239 @@
"use client"
import { useState } from "react"
import { Button } from "@/components/ui/button"
import { Input } from "@/components/ui/input"
import { Label } from "@/components/ui/label"
import { Badge } from "@/components/ui/badge"
import { Switch } from "@/components/ui/switch"
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
DialogTrigger,
} from "@/components/ui/dialog"
import { useToast } from "@/components/ui/use-toast"
import { criarCupom, atualizarCupom, deletarCupom } from "@/lib/actions"
import type { Cupom } from "@/lib/supabase"
import { Plus, Trash2, Star, Tag } from "lucide-react"
import { useRouter } from "next/navigation"
interface CupomTableProps {
cupons: Cupom[]
}
function NovoCupomDialog() {
const [open, setOpen] = useState(false)
const [loading, setLoading] = useState(false)
const { toast } = useToast()
const router = useRouter()
const [form, setForm] = useState({
codigo: "",
descricao: "",
percentual: "15",
validade: "",
destaque: false,
})
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault()
setLoading(true)
try {
await criarCupom({
codigo: form.codigo,
descricao: form.descricao,
percentual: Number(form.percentual),
validade: form.validade,
destaque: form.destaque,
})
toast({ title: "Cupom criado!" })
setOpen(false)
setForm({ codigo: "", descricao: "", percentual: "15", validade: "", destaque: false })
router.refresh()
} catch (err) {
toast({ title: "Erro", description: err instanceof Error ? err.message : "Erro ao criar cupom", variant: "destructive" })
} finally {
setLoading(false)
}
}
return (
<Dialog open={open} onOpenChange={setOpen}>
<DialogTrigger asChild>
<Button className="gap-2"><Plus className="w-4 h-4" /> Novo Cupom</Button>
</DialogTrigger>
<DialogContent>
<DialogHeader>
<DialogTitle>Criar cupom de desconto</DialogTitle>
</DialogHeader>
<form onSubmit={handleSubmit} className="space-y-4 mt-2">
<div className="space-y-1">
<Label>Código</Label>
<Input
required
placeholder="VIXCERT20"
value={form.codigo}
onChange={(e) => setForm({ ...form, codigo: e.target.value.toUpperCase() })}
/>
</div>
<div className="space-y-1">
<Label>Descrição</Label>
<Input
required
placeholder="Ex: Renovação de Certificado"
value={form.descricao}
onChange={(e) => setForm({ ...form, descricao: e.target.value })}
/>
</div>
<div className="grid grid-cols-2 gap-4">
<div className="space-y-1">
<Label>Desconto (%)</Label>
<Input
required
type="number"
min="1"
max="100"
value={form.percentual}
onChange={(e) => setForm({ ...form, percentual: e.target.value })}
/>
</div>
<div className="space-y-1">
<Label>Válido até</Label>
<Input
required
type="date"
value={form.validade}
onChange={(e) => setForm({ ...form, validade: e.target.value })}
/>
</div>
</div>
<div className="flex items-center gap-3">
<Switch
id="destaque"
checked={form.destaque}
onCheckedChange={(v) => setForm({ ...form, destaque: v })}
/>
<Label htmlFor="destaque" className="cursor-pointer flex items-center gap-1">
<Star className="w-4 h-4 text-yellow-500" /> Exibir em destaque no site
</Label>
</div>
<Button type="submit" className="w-full" disabled={loading}>
{loading ? "Criando..." : "Criar cupom"}
</Button>
</form>
</DialogContent>
</Dialog>
)
}
export function CupomTable({ cupons }: CupomTableProps) {
const { toast } = useToast()
const router = useRouter()
const handleToggleAtivo = async (cupom: Cupom) => {
try {
await atualizarCupom(cupom.id, { ativo: !cupom.ativo })
router.refresh()
} catch {
toast({ title: "Erro ao atualizar", variant: "destructive" })
}
}
const handleToggleDestaque = async (cupom: Cupom) => {
try {
await atualizarCupom(cupom.id, { destaque: !cupom.destaque })
router.refresh()
} catch {
toast({ title: "Erro ao atualizar", variant: "destructive" })
}
}
const handleDeletar = async (id: string) => {
if (!confirm("Deletar este cupom?")) return
try {
await deletarCupom(id)
toast({ title: "Cupom deletado" })
router.refresh()
} catch {
toast({ title: "Erro ao deletar", variant: "destructive" })
}
}
const hoje = new Date().toISOString().slice(0, 10)
return (
<Card>
<CardHeader className="flex flex-row items-center justify-between">
<CardTitle className="flex items-center gap-2">
<Tag className="w-5 h-5" /> Cupons de Desconto
</CardTitle>
<NovoCupomDialog />
</CardHeader>
<CardContent>
{cupons.length === 0 ? (
<p className="text-center text-gray-500 py-8">Nenhum cupom cadastrado.</p>
) : (
<div className="space-y-3">
{cupons.map((cupom) => {
const vencido = cupom.validade < hoje
const validadeFormatada = new Date(cupom.validade + "T12:00:00").toLocaleDateString("pt-BR")
return (
<div
key={cupom.id}
className={`flex items-center justify-between p-4 rounded-lg border ${
cupom.destaque ? "border-secondary/50 bg-secondary/5" : "border-gray-200"
}`}
>
<div className="flex items-center gap-4">
<div>
<div className="flex items-center gap-2 mb-1">
<span className="font-mono font-bold text-gray-800">{cupom.codigo}</span>
<Badge variant="secondary">{cupom.percentual}% OFF</Badge>
{cupom.destaque && (
<Badge className="bg-yellow-100 text-yellow-800 border-yellow-300">
<Star className="w-3 h-3 mr-1" /> Destaque
</Badge>
)}
{vencido && <Badge variant="destructive">Vencido</Badge>}
{!cupom.ativo && <Badge variant="outline">Inativo</Badge>}
</div>
<div className="text-sm text-gray-500">{cupom.descricao} · Até {validadeFormatada}</div>
</div>
</div>
<div className="flex items-center gap-3">
<div className="flex items-center gap-1 text-xs text-gray-500">
<Switch
checked={cupom.destaque}
onCheckedChange={() => handleToggleDestaque(cupom)}
title="Exibir em destaque"
/>
<span>Destaque</span>
</div>
<div className="flex items-center gap-1 text-xs text-gray-500">
<Switch
checked={cupom.ativo}
onCheckedChange={() => handleToggleAtivo(cupom)}
title="Ativo/Inativo"
/>
<span>Ativo</span>
</div>
<Button
variant="ghost"
size="icon"
className="text-red-500 hover:text-red-700"
onClick={() => handleDeletar(cupom.id)}
>
<Trash2 className="w-4 h-4" />
</Button>
</div>
</div>
)
})}
</div>
)}
</CardContent>
</Card>
)
}

View File

@@ -0,0 +1,86 @@
"use client"
import type React from "react"
import { useState } from "react"
import { useRouter } from "next/navigation"
import { Button } from "@/components/ui/button"
import { Input } from "@/components/ui/input"
import { Label } from "@/components/ui/label"
import { createCliente } from "@/lib/actions"
import { toast } from "@/components/ui/use-toast"
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
DialogTrigger,
DialogDescription,
} from "@/components/ui/dialog"
export function CustomerFormDialog() {
const [open, setOpen] = useState(false)
const router = useRouter()
const [formData, setFormData] = useState({
nome: "",
email: "",
telefone: "",
cpf_cnpj: "",
})
const handleSubmit = async (e: React.FormEvent<HTMLFormElement>) => {
e.preventDefault()
try {
await createCliente(formData)
toast({ title: "Cliente criado", description: "Adicionado com sucesso." })
setOpen(false)
router.refresh()
} catch (error) {
toast({
title: "Erro",
description: error instanceof Error ? error.message : "Tente novamente.",
variant: "destructive",
})
}
}
const handleChange = (e: React.ChangeEvent<HTMLInputElement>) => {
const { name, value } = e.target
setFormData((prev) => ({ ...prev, [name]: value }))
}
return (
<Dialog open={open} onOpenChange={setOpen}>
<DialogTrigger asChild>
<Button>Novo Cliente</Button>
</DialogTrigger>
<DialogContent className="sm:max-w-[425px]">
<DialogHeader>
<DialogTitle>Criar Novo Cliente</DialogTitle>
<DialogDescription>Preencha os dados do novo cliente.</DialogDescription>
</DialogHeader>
<form onSubmit={handleSubmit} className="space-y-4">
<div className="space-y-2">
<Label htmlFor="nome">Nome</Label>
<Input id="nome" name="nome" value={formData.nome} onChange={handleChange} required />
</div>
<div className="space-y-2">
<Label htmlFor="email">E-mail</Label>
<Input id="email" name="email" type="email" value={formData.email} onChange={handleChange} required />
</div>
<div className="space-y-2">
<Label htmlFor="cpf_cnpj">CPF / CNPJ</Label>
<Input id="cpf_cnpj" name="cpf_cnpj" value={formData.cpf_cnpj} onChange={handleChange} />
</div>
<div className="space-y-2">
<Label htmlFor="telefone">Telefone</Label>
<Input id="telefone" name="telefone" value={formData.telefone} onChange={handleChange} />
</div>
<div className="flex justify-end space-x-2">
<Button type="button" variant="outline" onClick={() => setOpen(false)}>Cancelar</Button>
<Button type="submit">Criar Cliente</Button>
</div>
</form>
</DialogContent>
</Dialog>
)
}

View File

@@ -0,0 +1,47 @@
"use client"
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table"
import type { Cliente } from "@/lib/supabase"
interface CustomerTableProps {
customers: Cliente[]
}
export function CustomerTable({ customers }: CustomerTableProps) {
if (customers.length === 0) {
return (
<div className="rounded-md border p-8 text-center text-muted-foreground">
Nenhum cliente cadastrado.
</div>
)
}
return (
<div className="rounded-md border">
<Table>
<TableHeader>
<TableRow>
<TableHead>Nome</TableHead>
<TableHead>E-mail</TableHead>
<TableHead>CPF/CNPJ</TableHead>
<TableHead>Telefone</TableHead>
<TableHead>Cadastro</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{customers.map((c) => (
<TableRow key={c.id}>
<TableCell className="font-medium">{c.nome}</TableCell>
<TableCell>{c.email}</TableCell>
<TableCell>{c.cpf_cnpj ?? "—"}</TableCell>
<TableCell>{c.telefone ?? "—"}</TableCell>
<TableCell className="text-sm text-muted-foreground">
{new Date(c.created_at).toLocaleDateString("pt-BR")}
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</div>
)
}

View File

@@ -0,0 +1,23 @@
import type React from "react" // Added import for React
interface DashboardHeaderProps {
heading: string
text?: string
children?: React.ReactNode
}
export function DashboardHeader({ heading, text, children }: DashboardHeaderProps) {
return (
<div className="flex items-center justify-between px-2">
<div className="grid gap-1">
<h1 className="font-heading text-3xl font-bold tracking-tight">{heading}</h1>
{text && <p className="font-sans text-lg text-muted-foreground">{text}</p>}
<p className="text-sm text-muted-foreground mt-2 font-sans">
Gerencie seus certificados digitais, acompanhe vendas e monitore o desempenho em tempo real. Todas as
ferramentas que você precisa em um lugar.
</p>
</div>
<div className="flex items-center gap-2">{children}</div>
</div>
)
}

View File

@@ -0,0 +1,12 @@
import { cn } from "@/lib/utils"
import type React from "react"
interface DashboardShellProps extends React.HTMLAttributes<HTMLDivElement> {}
export function DashboardShell({ children, className, ...props }: DashboardShellProps) {
return (
<div className={cn("container space-y-8 py-8", className)} {...props}>
{children}
</div>
)
}

View File

@@ -0,0 +1,63 @@
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"
import { DollarSign, Users, ShoppingCart, TrendingUp } from "lucide-react"
interface DashboardStatsProps {
receitaTotal: number
totalPedidosConfirmados: number
ticketMedio: number
pedidosPendentes: number
}
export function DashboardStats({
receitaTotal,
totalPedidosConfirmados,
ticketMedio,
pedidosPendentes,
}: DashboardStatsProps) {
const fmt = (v: number) =>
v.toLocaleString("pt-BR", { style: "currency", currency: "BRL" })
const stats = [
{
title: "Receita Total",
value: fmt(receitaTotal),
icon: DollarSign,
description: "Pedidos pagos (todos os tempos)",
},
{
title: "Vendas Confirmadas",
value: totalPedidosConfirmados.toString(),
icon: ShoppingCart,
description: "Pedidos recebidos ou confirmados",
},
{
title: "Ticket Médio",
value: fmt(ticketMedio),
icon: TrendingUp,
description: "Por venda confirmada",
},
{
title: "Pendentes",
value: pedidosPendentes.toString(),
icon: Users,
description: "Aguardando pagamento",
},
]
return (
<>
{stats.map((stat, index) => (
<Card key={index}>
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
<CardTitle className="font-heading text-sm font-medium">{stat.title}</CardTitle>
<stat.icon className="h-4 w-4 text-muted-foreground" />
</CardHeader>
<CardContent>
<div className="font-sans text-2xl font-bold">{stat.value}</div>
<p className="font-sans text-xs text-muted-foreground">{stat.description}</p>
</CardContent>
</Card>
))}
</>
)
}

View File

@@ -0,0 +1,65 @@
"use client"
import type React from "react"
import { ArrowDownIcon, ArrowUpIcon, CaretSortIcon, EyeNoneIcon } from "@radix-ui/react-icons"
import type { Column } from "@tanstack/react-table"
import { cn } from "@/lib/utils"
import { Button } from "@/components/ui/button"
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuSeparator,
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu"
interface DataTableColumnHeaderProps<TData, TValue> extends React.HTMLAttributes<HTMLDivElement> {
column: Column<TData, TValue>
title: string
}
export function DataTableColumnHeader<TData, TValue>({
column,
title,
className,
}: DataTableColumnHeaderProps<TData, TValue>) {
if (!column.getCanSort()) {
return <div className={cn(className)}>{title}</div>
}
return (
<div className={cn("flex items-center space-x-2", className)}>
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button variant="ghost" size="sm" className="-ml-3 h-8 data-[state=open]:bg-accent">
<span>{title}</span>
{column.getIsSorted() === "desc" ? (
<ArrowDownIcon className="ml-2 h-4 w-4" />
) : column.getIsSorted() === "asc" ? (
<ArrowUpIcon className="ml-2 h-4 w-4" />
) : (
<CaretSortIcon className="ml-2 h-4 w-4" />
)}
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="start">
<DropdownMenuItem onClick={() => column.toggleSorting(false)}>
<ArrowUpIcon className="mr-2 h-3.5 w-3.5 text-muted-foreground/70" />
Crescente
</DropdownMenuItem>
<DropdownMenuItem onClick={() => column.toggleSorting(true)}>
<ArrowDownIcon className="mr-2 h-3.5 w-3.5 text-muted-foreground/70" />
Decrescente
</DropdownMenuItem>
<DropdownMenuSeparator />
<DropdownMenuItem onClick={() => column.toggleVisibility(false)}>
<EyeNoneIcon className="mr-2 h-3.5 w-3.5 text-muted-foreground/70" />
Ocultar
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</div>
)
}

View File

@@ -0,0 +1,129 @@
"use client"
import type * as React from "react"
import { CheckIcon, PlusCircledIcon } from "@radix-ui/react-icons"
import type { Column } from "@tanstack/react-table"
import { cn } from "@/lib/utils"
import { Badge } from "@/components/ui/badge"
import { Button } from "@/components/ui/button"
import {
Command,
CommandEmpty,
CommandGroup,
CommandInput,
CommandItem,
CommandList,
CommandSeparator,
} from "@/components/ui/command"
import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover"
import { Separator } from "@/components/ui/separator"
interface DataTableFacetedFilterProps<TData, TValue> {
column?: Column<TData, TValue>
title?: string
options: {
label: string
value: string
icon?: React.ComponentType<{ className?: string }>
}[]
}
export function DataTableFacetedFilter<TData, TValue>({
column,
title,
options,
}: DataTableFacetedFilterProps<TData, TValue>) {
const facets = column?.getFacetedUniqueValues()
const selectedValues = new Set(column?.getFilterValue() as string[])
return (
<Popover>
<PopoverTrigger asChild>
<Button variant="outline" size="sm" className="h-8 border-dashed">
<PlusCircledIcon className="mr-2 h-4 w-4" />
{title}
{selectedValues?.size > 0 && (
<>
<Separator orientation="vertical" className="mx-2 h-4" />
<Badge variant="secondary" className="rounded-sm px-1 font-normal lg:hidden">
{selectedValues.size}
</Badge>
<div className="hidden space-x-1 lg:flex">
{selectedValues.size > 2 ? (
<Badge variant="secondary" className="rounded-sm px-1 font-normal">
{selectedValues.size} selecionados
</Badge>
) : (
options
.filter((option) => selectedValues.has(option.value))
.map((option) => (
<Badge variant="secondary" key={option.value} className="rounded-sm px-1 font-normal">
{option.label}
</Badge>
))
)}
</div>
</>
)}
</Button>
</PopoverTrigger>
<PopoverContent className="w-[200px] p-0" align="start">
<Command>
<CommandInput placeholder={title} />
<CommandList>
<CommandEmpty>Nenhum resultado encontrado.</CommandEmpty>
<CommandGroup>
{options.map((option) => {
const isSelected = selectedValues.has(option.value)
return (
<CommandItem
key={option.value}
onSelect={() => {
if (isSelected) {
selectedValues.delete(option.value)
} else {
selectedValues.add(option.value)
}
const filterValues = Array.from(selectedValues)
column?.setFilterValue(filterValues.length ? filterValues : undefined)
}}
>
<div
className={cn(
"mr-2 flex h-4 w-4 items-center justify-center rounded-sm border border-primary",
isSelected ? "bg-primary text-primary-foreground" : "opacity-50 [&_svg]:invisible",
)}
>
<CheckIcon className={cn("h-4 w-4")} />
</div>
{option.icon && <option.icon className="mr-2 h-4 w-4 text-muted-foreground" />}
<span>{option.label}</span>
{facets?.get(option.value) && (
<span className="ml-auto flex h-4 w-4 items-center justify-center font-mono text-xs">
{facets.get(option.value)}
</span>
)}
</CommandItem>
)
})}
</CommandGroup>
{selectedValues.size > 0 && (
<>
<CommandSeparator />
<CommandGroup>
<CommandItem
onSelect={() => column?.setFilterValue(undefined)}
className="justify-center text-center"
>
Limpar filtros
</CommandItem>
</CommandGroup>
</>
)}
</CommandList>
</Command>
</PopoverContent>
</Popover>
)
}

View File

@@ -0,0 +1,85 @@
"use client"
import { ChevronLeftIcon, ChevronRightIcon, DoubleArrowLeftIcon, DoubleArrowRightIcon } from "@radix-ui/react-icons"
import type { Table } from "@tanstack/react-table"
import { Button } from "@/components/ui/button"
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"
interface DataTablePaginationProps<TData> {
table: Table<TData>
}
export function DataTablePagination<TData>({ table }: DataTablePaginationProps<TData>) {
return (
<div className="flex items-center justify-between px-2">
<div className="flex-1 text-sm text-muted-foreground">
{table.getFilteredSelectedRowModel().rows.length} de {table.getFilteredRowModel().rows.length} linha(s)
selecionada(s).
</div>
<div className="flex items-center space-x-6 lg:space-x-8">
<div className="flex items-center space-x-2">
<p className="text-sm font-medium">Linhas por página</p>
<Select
value={`${table.getState().pagination.pageSize}`}
onValueChange={(value) => {
table.setPageSize(Number(value))
}}
>
<SelectTrigger className="h-8 w-[70px]">
<SelectValue placeholder={table.getState().pagination.pageSize} />
</SelectTrigger>
<SelectContent side="top">
{[10, 20, 30, 40, 50].map((pageSize) => (
<SelectItem key={pageSize} value={`${pageSize}`}>
{pageSize}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div className="flex w-[100px] items-center justify-center text-sm font-medium">
Página {table.getState().pagination.pageIndex + 1} de {table.getPageCount()}
</div>
<div className="flex items-center space-x-2">
<Button
variant="outline"
className="hidden h-8 w-8 p-0 lg:flex"
onClick={() => table.setPageIndex(0)}
disabled={!table.getCanPreviousPage()}
>
<span className="sr-only">Ir para primeira página</span>
<DoubleArrowLeftIcon className="h-4 w-4" />
</Button>
<Button
variant="outline"
className="h-8 w-8 p-0"
onClick={() => table.previousPage()}
disabled={!table.getCanPreviousPage()}
>
<span className="sr-only">Ir para página anterior</span>
<ChevronLeftIcon className="h-4 w-4" />
</Button>
<Button
variant="outline"
className="h-8 w-8 p-0"
onClick={() => table.nextPage()}
disabled={!table.getCanNextPage()}
>
<span className="sr-only">Ir para próxima página</span>
<ChevronRightIcon className="h-4 w-4" />
</Button>
<Button
variant="outline"
className="hidden h-8 w-8 p-0 lg:flex"
onClick={() => table.setPageIndex(table.getPageCount() - 1)}
disabled={!table.getCanNextPage()}
>
<span className="sr-only">Ir para última página</span>
<DoubleArrowRightIcon className="h-4 w-4" />
</Button>
</div>
</div>
</div>
)
}

View File

@@ -0,0 +1,45 @@
"use client"
import { DotsHorizontalIcon } from "@radix-ui/react-icons"
import type { Row } from "@tanstack/react-table"
import { Button } from "@/components/ui/button"
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuSeparator,
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu"
import { productSchema } from "@/lib/schema"
import type { z } from "zod"
type Product = z.infer<typeof productSchema>
interface DataTableRowActionsProps<TData> {
row: Row<TData>
}
export function DataTableRowActions<TData>({ row }: DataTableRowActionsProps<TData>) {
const product = productSchema.parse(row.original)
return (
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button variant="ghost" className="flex h-8 w-8 p-0 data-[state=open]:bg-muted">
<DotsHorizontalIcon className="h-4 w-4" />
<span className="sr-only">Abrir menu</span>
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end" className="w-[160px]">
<DropdownMenuItem>Editar</DropdownMenuItem>
<DropdownMenuItem>Duplicar</DropdownMenuItem>
<DropdownMenuSeparator />
<DropdownMenuItem>Arquivar</DropdownMenuItem>
<DropdownMenuSeparator />
<DropdownMenuItem>Excluir</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
)
}

View File

@@ -0,0 +1,48 @@
"use client"
import { Cross2Icon } from "@radix-ui/react-icons"
import type { Table } from "@tanstack/react-table"
import { Button } from "@/components/ui/button"
import { Input } from "@/components/ui/input"
import { DataTableViewOptions } from "@/components/data-table-view-options"
import { DataTableFacetedFilter } from "@/components/data-table-faceted-filter"
interface DataTableToolbarProps<TData> {
table: Table<TData>
}
export function DataTableToolbar<TData>({ table }: DataTableToolbarProps<TData>) {
const isFiltered = table.getState().columnFilters.length > 0
return (
<div className="flex items-center justify-between">
<div className="flex flex-1 items-center space-x-2">
<Input
placeholder="Filtrar produtos..."
value={(table.getColumn("name")?.getFilterValue() as string) ?? ""}
onChange={(event) => table.getColumn("name")?.setFilterValue(event.target.value)}
className="h-8 w-[150px] lg:w-[250px]"
/>
{table.getColumn("tipo") && (
<DataTableFacetedFilter
column={table.getColumn("tipo")}
title="Tipo"
options={[
{ label: "e-CNPJ", value: "e-CNPJ" },
{ label: "e-CPF", value: "e-CPF" },
{ label: "NF-e", value: "NF-e" },
]}
/>
)}
{isFiltered && (
<Button variant="ghost" onClick={() => table.resetColumnFilters()} className="h-8 px-2 lg:px-3">
Limpar
<Cross2Icon className="ml-2 h-4 w-4" />
</Button>
)}
</div>
<DataTableViewOptions table={table} />
</div>
)
}

View File

@@ -0,0 +1,50 @@
"use client"
import { DropdownMenuTrigger } from "@radix-ui/react-dropdown-menu"
import { MixerHorizontalIcon } from "@radix-ui/react-icons"
import type { Table } from "@tanstack/react-table"
import { Button } from "@/components/ui/button"
import {
DropdownMenu,
DropdownMenuCheckboxItem,
DropdownMenuContent,
DropdownMenuLabel,
DropdownMenuSeparator,
} from "@/components/ui/dropdown-menu"
interface DataTableViewOptionsProps<TData> {
table: Table<TData>
}
export function DataTableViewOptions<TData>({ table }: DataTableViewOptionsProps<TData>) {
return (
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button variant="outline" size="sm" className="ml-auto hidden h-8 lg:flex">
<MixerHorizontalIcon className="mr-2 h-4 w-4" />
Visualização
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end" className="w-[150px]">
<DropdownMenuLabel>Alternar colunas</DropdownMenuLabel>
<DropdownMenuSeparator />
{table
.getAllColumns()
.filter((column) => typeof column.accessorFn !== "undefined" && column.getCanHide())
.map((column) => {
return (
<DropdownMenuCheckboxItem
key={column.id}
className="capitalize"
checked={column.getIsVisible()}
onCheckedChange={(value) => column.toggleVisibility(!!value)}
>
{column.id}
</DropdownMenuCheckboxItem>
)
})}
</DropdownMenuContent>
</DropdownMenu>
)
}

97
components/data-table.tsx Normal file
View File

@@ -0,0 +1,97 @@
"use client"
import * as React from "react"
import {
type ColumnDef,
type ColumnFiltersState,
type SortingState,
type VisibilityState,
flexRender,
getCoreRowModel,
getFacetedRowModel,
getFacetedUniqueValues,
getFilteredRowModel,
getPaginationRowModel,
getSortedRowModel,
useReactTable,
} from "@tanstack/react-table"
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table"
import { DataTablePagination } from "@/components/data-table-pagination"
import { DataTableToolbar } from "@/components/data-table-toolbar"
interface DataTableProps<TData, TValue> {
columns: ColumnDef<TData, TValue>[]
data: TData[]
}
export function DataTable<TData, TValue>({ columns, data }: DataTableProps<TData, TValue>) {
const [rowSelection, setRowSelection] = React.useState({})
const [columnVisibility, setColumnVisibility] = React.useState<VisibilityState>({})
const [columnFilters, setColumnFilters] = React.useState<ColumnFiltersState>([])
const [sorting, setSorting] = React.useState<SortingState>([])
const table = useReactTable({
data,
columns,
state: {
sorting,
columnVisibility,
rowSelection,
columnFilters,
},
enableRowSelection: true,
onRowSelectionChange: setRowSelection,
onSortingChange: setSorting,
onColumnFiltersChange: setColumnFilters,
onColumnVisibilityChange: setColumnVisibility,
getCoreRowModel: getCoreRowModel(),
getFilteredRowModel: getFilteredRowModel(),
getPaginationRowModel: getPaginationRowModel(),
getSortedRowModel: getSortedRowModel(),
getFacetedRowModel: getFacetedRowModel(),
getFacetedUniqueValues: getFacetedUniqueValues(),
})
return (
<div className="space-y-4">
<DataTableToolbar table={table} />
<div className="rounded-md border">
<Table>
<TableHeader>
{table.getHeaderGroups().map((headerGroup) => (
<TableRow key={headerGroup.id}>
{headerGroup.headers.map((header) => {
return (
<TableHead key={header.id} colSpan={header.colSpan}>
{header.isPlaceholder ? null : flexRender(header.column.columnDef.header, header.getContext())}
</TableHead>
)
})}
</TableRow>
))}
</TableHeader>
<TableBody>
{table.getRowModel().rows?.length ? (
table.getRowModel().rows.map((row) => (
<TableRow key={row.id} data-state={row.getIsSelected() && "selected"}>
{row.getVisibleCells().map((cell) => (
<TableCell key={cell.id}>{flexRender(cell.column.columnDef.cell, cell.getContext())}</TableCell>
))}
</TableRow>
))
) : (
<TableRow>
<TableCell colSpan={columns.length} className="h-24 text-center">
Nenhum resultado.
</TableCell>
</TableRow>
)}
</TableBody>
</Table>
</div>
<DataTablePagination table={table} />
</div>
)
}

60
components/icons.tsx Normal file
View File

@@ -0,0 +1,60 @@
import {
AlertTriangle,
ArrowRight,
Check,
ChevronLeft,
ChevronRight,
Command,
CreditCard,
File,
FileText,
Github,
HelpCircle,
Image,
Laptop,
Loader2,
Moon,
MoreVertical,
Pizza,
Plus,
Settings,
SunMedium,
Trash,
Twitter,
User,
X,
type LucideIcon,
} from "lucide-react"
import { Apple, Google } from "@/components/brand-icons"
export type Icon = LucideIcon
export const Icons = {
logo: Command,
close: X,
spinner: Loader2,
chevronLeft: ChevronLeft,
chevronRight: ChevronRight,
trash: Trash,
post: FileText,
page: File,
media: Image,
settings: Settings,
billing: CreditCard,
ellipsis: MoreVertical,
add: Plus,
warning: AlertTriangle,
user: User,
arrowRight: ArrowRight,
help: HelpCircle,
pizza: Pizza,
sun: SunMedium,
moon: Moon,
laptop: Laptop,
gitHub: Github,
twitter: Twitter,
check: Check,
google: Google,
apple: Apple,
}

32
components/overview.tsx Normal file
View File

@@ -0,0 +1,32 @@
"use client"
import { Bar, BarChart, ResponsiveContainer, XAxis, YAxis } from "recharts"
interface OverviewProps {
data: number[]
}
export function Overview({ data }: OverviewProps) {
const months = ["Jan", "Fev", "Mar", "Abr", "Mai", "Jun", "Jul", "Ago", "Set", "Out", "Nov", "Dez"]
const chartData = data.map((value, index) => ({
name: months[index],
total: value,
}))
return (
<ResponsiveContainer width="100%" height={350}>
<BarChart data={chartData}>
<XAxis dataKey="name" stroke="#888888" fontSize={12} tickLine={false} axisLine={false} />
<YAxis
stroke="#888888"
fontSize={12}
tickLine={false}
axisLine={false}
tickFormatter={(value) => `R$${value}`}
/>
<Bar dataKey="total" fill="#adfa1d" radius={[4, 4, 0, 0]} />
</BarChart>
</ResponsiveContainer>
)
}

View File

@@ -0,0 +1,226 @@
"use client"
import { useState } from "react"
import { Dialog, DialogContent, DialogHeader, DialogTitle } from "@/components/ui/dialog"
import { Button } from "@/components/ui/button"
import { useToast } from "@/components/ui/use-toast"
import { RefreshCw, ExternalLink, Copy } from "lucide-react"
const statusColors: Record<string, string> = {
PENDING: "bg-yellow-100 text-yellow-800",
RECEIVED: "bg-green-100 text-green-800",
CONFIRMED: "bg-green-100 text-green-800",
OVERDUE: "bg-red-100 text-red-800",
REFUNDED: "bg-gray-100 text-gray-800",
CANCELLED: "bg-red-100 text-red-800",
}
const statusLabel: Record<string, string> = {
PENDING: "Pendente",
RECEIVED: "Pago",
CONFIRMED: "Confirmado",
OVERDUE: "Vencido",
REFUNDED: "Estornado",
CANCELLED: "Cancelado",
}
type Pedido = {
id: string
status: string
valor_centavos: number
metodo_pagamento: string | null
asaas_payment_id: string | null
asaas_invoice_url: string | null
pix_copia_cola: string | null
pix_qrcode_url: string | null
paid_at: string | null
created_at: string
clientes: {
nome: string
email: string
telefone: string | null
cpf_cnpj: string | null
} | null
produtos: {
nome: string
tipo: string
validade: string
midia: string | null
} | null
}
export function PedidoDetailModal({
pedido: pedidoInicial,
open,
onClose,
}: {
pedido: Pedido
open: boolean
onClose: () => void
}) {
const { toast } = useToast()
const [pedido, setPedido] = useState(pedidoInicial)
const [syncing, setSyncing] = useState(false)
const fmt = (v: number) =>
(v / 100).toLocaleString("pt-BR", { style: "currency", currency: "BRL" })
async function syncStatus() {
setSyncing(true)
try {
const res = await fetch(`/api/pedido/${pedido.id}/sync`, { method: "POST" })
const data = await res.json()
if (!res.ok) throw new Error(data.error)
setPedido((p) => ({ ...p, status: data.status }))
toast({ title: `Status atualizado: ${statusLabel[data.status] ?? data.status}` })
} catch (err) {
toast({
title: "Erro ao sincronizar",
description: err instanceof Error ? err.message : "Tente novamente",
variant: "destructive",
})
} finally {
setSyncing(false)
}
}
function copiar(texto: string, label: string) {
navigator.clipboard.writeText(texto)
toast({ title: `${label} copiado!` })
}
return (
<Dialog open={open} onOpenChange={onClose}>
<DialogContent className="max-w-lg max-h-[90vh] overflow-y-auto">
<DialogHeader>
<DialogTitle className="flex items-center justify-between gap-2 pr-6">
<span>Pedido <span className="font-mono text-sm">{pedido.id.slice(0, 8).toUpperCase()}</span></span>
<span className={`px-2 py-0.5 rounded-full text-xs font-medium ${statusColors[pedido.status] ?? ""}`}>
{statusLabel[pedido.status] ?? pedido.status}
</span>
</DialogTitle>
</DialogHeader>
<div className="space-y-5 mt-2">
{/* Cliente */}
<Section title="Cliente">
<Row label="Nome" value={pedido.clientes?.nome} />
<Row label="E-mail" value={pedido.clientes?.email} />
<Row label="Telefone" value={pedido.clientes?.telefone} />
<Row label="CPF/CNPJ" value={pedido.clientes?.cpf_cnpj} />
</Section>
{/* Produto */}
<Section title="Produto">
<Row label="Nome" value={pedido.produtos?.nome} />
<Row label="Tipo" value={pedido.produtos?.tipo} />
<Row label="Validade" value={pedido.produtos?.validade} />
<Row label="Mídia" value={pedido.produtos?.midia} />
</Section>
{/* Pagamento */}
<Section title="Pagamento">
<Row label="Valor" value={fmt(pedido.valor_centavos)} highlight />
<Row label="Método" value={pedido.metodo_pagamento} />
<Row label="ASAAS ID" value={pedido.asaas_payment_id} mono />
<Row
label="Criado em"
value={new Date(pedido.created_at).toLocaleString("pt-BR")}
/>
{pedido.paid_at && (
<Row
label="Pago em"
value={new Date(pedido.paid_at).toLocaleString("pt-BR")}
/>
)}
</Section>
{/* Ações */}
<div className="flex flex-wrap gap-2 pt-1">
<Button
size="sm"
variant="outline"
onClick={syncStatus}
disabled={syncing}
>
<RefreshCw className={`mr-2 h-3.5 w-3.5 ${syncing ? "animate-spin" : ""}`} />
Sync ASAAS
</Button>
{pedido.asaas_invoice_url && (
<Button
size="sm"
variant="outline"
onClick={() => window.open(pedido.asaas_invoice_url!, "_blank")}
>
<ExternalLink className="mr-2 h-3.5 w-3.5" />
Ver boleto
</Button>
)}
{pedido.pix_copia_cola && (
<Button
size="sm"
variant="outline"
onClick={() => copiar(pedido.pix_copia_cola!, "PIX copia-e-cola")}
>
<Copy className="mr-2 h-3.5 w-3.5" />
Copiar PIX
</Button>
)}
{pedido.asaas_payment_id && (
<Button
size="sm"
variant="outline"
onClick={() =>
window.open(
`https://www.asaas.com/index?tab=payment&id=${pedido.asaas_payment_id}`,
"_blank"
)
}
>
<ExternalLink className="mr-2 h-3.5 w-3.5" />
Ver no ASAAS
</Button>
)}
</div>
</div>
</DialogContent>
</Dialog>
)
}
function Section({ title, children }: { title: string; children: React.ReactNode }) {
return (
<div>
<p className="text-xs font-semibold text-muted-foreground uppercase tracking-wider mb-2">
{title}
</p>
<div className="rounded-lg border divide-y text-sm">{children}</div>
</div>
)
}
function Row({
label,
value,
mono,
highlight,
}: {
label: string
value?: string | null
mono?: boolean
highlight?: boolean
}) {
return (
<div className="flex justify-between items-center px-3 py-2 gap-4">
<span className="text-muted-foreground shrink-0">{label}</span>
<span
className={`text-right truncate ${mono ? "font-mono text-xs" : ""} ${highlight ? "font-semibold text-primary" : ""}`}
>
{value ?? "—"}
</span>
</div>
)
}

View File

@@ -0,0 +1,263 @@
"use client"
import { useEffect, useState, useCallback } from "react"
import { useRouter } from "next/navigation"
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"
import { Button } from "@/components/ui/button"
import { useToast } from "@/components/ui/use-toast"
import {
CheckCircle2,
Clock,
XCircle,
Copy,
ExternalLink,
Calendar,
Loader2,
} from "lucide-react"
type Pedido = {
id: string
status: string
valor_centavos: number
metodo_pagamento: string
pix_copia_cola: string | null
pix_qrcode_url: string | null
asaas_invoice_url: string | null
paid_at: string | null
clientes: { nome: string; email: string } | null
produtos: { nome: string; validade: string; midia: string | null } | null
}
const STATUS_PAGO = ["PAID", "RECEIVED", "CONFIRMED"]
const STATUS_CANCELADO = ["CANCELED", "CANCELLED", "OVERDUE", "REFUNDED"]
const LINK_AGENDAMENTO = process.env.NEXT_PUBLIC_AFTER_PAYMENT_REDIRECT ?? "/"
export default function PedidoStatus({ pedidoInicial }: { pedidoInicial: Pedido }) {
const router = useRouter()
const { toast } = useToast()
const [pedido, setPedido] = useState(pedidoInicial)
const [polling, setPolling] = useState(true)
const isPago = STATUS_PAGO.includes(pedido.status)
const isCancelado = STATUS_CANCELADO.includes(pedido.status)
const valorFormatado = (pedido.valor_centavos / 100).toLocaleString("pt-BR", {
style: "currency",
currency: "BRL",
})
const fetchStatus = useCallback(async () => {
try {
const res = await fetch(`/api/pedido/${pedido.id}`)
if (!res.ok) return
const data: Pedido = await res.json()
setPedido(data)
if (STATUS_PAGO.includes(data.status) || STATUS_CANCELADO.includes(data.status)) {
setPolling(false)
}
} catch {
// silencioso — tenta de novo no próximo ciclo
}
}, [pedido.id])
useEffect(() => {
if (!polling || isPago || isCancelado) return
const interval = setInterval(fetchStatus, 5000)
return () => clearInterval(interval)
}, [polling, isPago, isCancelado, fetchStatus])
function copiarPix() {
if (!pedido.pix_copia_cola) return
navigator.clipboard.writeText(pedido.pix_copia_cola)
toast({ title: "Código PIX copiado!" })
}
// ── Pago ──────────────────────────────────────────────────────────────
if (isPago) {
return (
<Card className="max-w-2xl mx-auto border-green-200 bg-green-50">
<CardHeader className="text-center pb-2">
<CheckCircle2 className="h-14 w-14 text-green-500 mx-auto mb-3" />
<CardTitle className="text-2xl text-green-800">Pagamento confirmado!</CardTitle>
<p className="text-green-700 text-sm mt-1">
{pedido.clientes?.nome}, seu certificado está sendo processado.
</p>
</CardHeader>
<CardContent className="space-y-5">
<ResumoCompra pedido={pedido} valorFormatado={valorFormatado} />
<div className="bg-white border border-green-200 rounded-xl p-5 text-center space-y-3">
<Calendar className="h-8 w-8 text-primary mx-auto" />
<p className="font-semibold text-lg">Próximo passo: agende sua validação</p>
<p className="text-sm text-muted-foreground">
Para emitir o certificado, é obrigatório realizar uma videoconferência de
Clique no botão abaixo para continuar para o próximo passo.
</p>
<Button
size="lg"
className="w-full mt-2"
onClick={() => router.push(LINK_AGENDAMENTO)}
>
<Calendar className="mr-2 h-4 w-4" />
Continuar
</Button>
<p className="text-xs text-muted-foreground">
O link também foi enviado para {pedido.clientes?.email}
</p>
</div>
</CardContent>
</Card>
)
}
// ── Cancelado / Vencido ───────────────────────────────────────────────
if (isCancelado) {
return (
<Card className="max-w-2xl mx-auto border-red-200 bg-red-50">
<CardHeader className="text-center pb-2">
<XCircle className="h-14 w-14 text-red-400 mx-auto mb-3" />
<CardTitle className="text-2xl text-red-800">Pagamento não realizado</CardTitle>
<p className="text-red-700 text-sm mt-1">
Este pedido foi cancelado ou expirou.
</p>
</CardHeader>
<CardContent className="space-y-4">
<ResumoCompra pedido={pedido} valorFormatado={valorFormatado} />
<Button variant="outline" className="w-full" onClick={() => router.push("/comprar")}>
Fazer novo pedido
</Button>
</CardContent>
</Card>
)
}
// ── PIX ───────────────────────────────────────────────────────────────
if (pedido.metodo_pagamento === "PIX") {
return (
<Card className="max-w-2xl mx-auto">
<CardHeader className="text-center pb-2">
<Clock className="h-10 w-10 text-yellow-500 mx-auto mb-2" />
<CardTitle className="text-xl">Aguardando pagamento PIX</CardTitle>
<p className="text-sm text-muted-foreground flex items-center justify-center gap-1">
<Loader2 className="h-3 w-3 animate-spin" />
Confirmação automática em segundos após o pagamento
</p>
</CardHeader>
<CardContent className="space-y-5">
<ResumoCompra pedido={pedido} valorFormatado={valorFormatado} />
{pedido.pix_qrcode_url && (
<div className="flex justify-center">
<img
src={`data:image/png;base64,${pedido.pix_qrcode_url}`}
alt="QR Code PIX"
className="w-52 h-52 rounded-xl border"
/>
</div>
)}
{pedido.pix_copia_cola && (
<div className="space-y-2">
<p className="text-xs font-medium text-muted-foreground uppercase tracking-wide">
PIX Copia e Cola
</p>
<div className="bg-muted rounded-lg p-3 text-xs break-all select-all font-mono leading-relaxed">
{pedido.pix_copia_cola}
</div>
<Button variant="outline" className="w-full" onClick={copiarPix}>
<Copy className="mr-2 h-4 w-4" />
Copiar código PIX
</Button>
</div>
)}
<p className="text-xs text-center text-muted-foreground">
Valor: <strong>{valorFormatado}</strong>
</p>
</CardContent>
</Card>
)
}
// ── BOLETO ────────────────────────────────────────────────────────────
if (pedido.metodo_pagamento === "BOLETO") {
return (
<Card className="max-w-2xl mx-auto">
<CardHeader className="text-center pb-2">
<Clock className="h-10 w-10 text-yellow-500 mx-auto mb-2" />
<CardTitle className="text-xl">Boleto gerado</CardTitle>
<p className="text-sm text-muted-foreground">
Após o pagamento, a confirmação pode levar até 2 dias úteis.
</p>
</CardHeader>
<CardContent className="space-y-5">
<ResumoCompra pedido={pedido} valorFormatado={valorFormatado} />
{pedido.asaas_invoice_url && (
<Button
className="w-full"
onClick={() => window.open(pedido.asaas_invoice_url!, "_blank")}
>
<ExternalLink className="mr-2 h-4 w-4" />
Abrir boleto
</Button>
)}
<p className="text-xs text-center text-muted-foreground">
O link do boleto também foi enviado para{" "}
<strong>{pedido.clientes?.email}</strong>
</p>
</CardContent>
</Card>
)
}
// ── CARTÃO (pendente/processando) ────────────────────────────────────
return (
<Card className="max-w-2xl mx-auto">
<CardHeader className="text-center pb-2">
<Loader2 className="h-10 w-10 text-blue-500 mx-auto mb-2 animate-spin" />
<CardTitle className="text-xl">Processando pagamento</CardTitle>
<p className="text-sm text-muted-foreground">Aguarde enquanto confirmamos seu cartão.</p>
</CardHeader>
<CardContent>
<ResumoCompra pedido={pedido} valorFormatado={valorFormatado} />
</CardContent>
</Card>
)
}
function ResumoCompra({
pedido,
valorFormatado,
}: {
pedido: Pedido
valorFormatado: string
}) {
return (
<div className="bg-muted rounded-xl p-4 space-y-1 text-sm">
<div className="flex justify-between">
<span className="text-muted-foreground">Produto</span>
<span className="font-medium">{pedido.produtos?.nome ?? "—"}</span>
</div>
<div className="flex justify-between">
<span className="text-muted-foreground">Validade</span>
<span>{pedido.produtos?.validade ?? "—"}</span>
</div>
{pedido.produtos?.midia && (
<div className="flex justify-between">
<span className="text-muted-foreground">Mídia</span>
<span>{pedido.produtos.midia}</span>
</div>
)}
<div className="flex justify-between">
<span className="text-muted-foreground">Valor</span>
<span className="font-semibold text-primary">{valorFormatado}</span>
</div>
<div className="flex justify-between">
<span className="text-muted-foreground">Pedido</span>
<span className="font-mono text-xs">{pedido.id.slice(0, 8).toUpperCase()}</span>
</div>
</div>
)
}

View File

@@ -0,0 +1,122 @@
"use client"
import { useState } from "react"
import { useRouter } from "next/navigation"
import { Button } from "@/components/ui/button"
import { Input } from "@/components/ui/input"
import { Label } from "@/components/ui/label"
import { useToast } from "@/components/ui/use-toast"
import { criarCheckout } from "@/lib/actions"
type Metodo = "PIX" | "BOLETO" | "CREDIT_CARD"
export function ProductBuyForm({
produtoId,
}: {
produtoId: string
}) {
const router = useRouter()
const { toast } = useToast()
const [isLoading, setIsLoading] = useState(false)
const [metodo, setMetodo] = useState<Metodo>("PIX")
const [form, setForm] = useState({
nome: "",
email: "",
cpfCnpj: "",
telefone: "",
})
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault()
setIsLoading(true)
try {
const res = await criarCheckout({
produtoId,
cliente: {
nome: form.nome,
email: form.email,
cpfCnpj: form.cpfCnpj.replace(/\D/g, ""),
telefone: form.telefone,
},
metodo,
})
router.push(`/pedido/${res.pedidoId}`)
} catch (error) {
toast({
title: "Erro ao processar pagamento",
description: error instanceof Error ? error.message : "Tente novamente.",
variant: "destructive",
})
setIsLoading(false)
}
}
return (
<form onSubmit={handleSubmit} className="space-y-4">
<div className="space-y-2">
<Label htmlFor="nome">Nome completo</Label>
<Input
id="nome"
required
value={form.nome}
onChange={(e) => setForm({ ...form, nome: e.target.value })}
/>
</div>
<div className="space-y-2">
<Label htmlFor="email">E-mail</Label>
<Input
id="email"
type="email"
required
value={form.email}
onChange={(e) => setForm({ ...form, email: e.target.value })}
/>
</div>
<div className="space-y-2">
<Label htmlFor="cpfCnpj">CPF / CNPJ</Label>
<Input
id="cpfCnpj"
required
placeholder="000.000.000-00"
value={form.cpfCnpj}
onChange={(e) => setForm({ ...form, cpfCnpj: e.target.value })}
/>
</div>
<div className="space-y-2">
<Label htmlFor="telefone">Telefone</Label>
<Input
id="telefone"
placeholder="(11) 99999-9999"
value={form.telefone}
onChange={(e) => setForm({ ...form, telefone: e.target.value })}
/>
</div>
<div className="space-y-2">
<Label>Forma de pagamento</Label>
<div className="flex gap-2">
{(["PIX", "BOLETO", "CREDIT_CARD"] as Metodo[]).map((m) => (
<button
key={m}
type="button"
onClick={() => setMetodo(m)}
className={`flex-1 py-2 rounded-lg border text-sm font-medium transition-colors ${
metodo === m
? "border-primary bg-primary text-white"
: "border-gray-300 hover:border-primary"
}`}
>
{m === "PIX" ? "PIX" : m === "BOLETO" ? "Boleto" : "Cartão"}
</button>
))}
</div>
</div>
<Button type="submit" className="w-full" disabled={isLoading}>
{isLoading ? "Processando..." : `Pagar com ${metodo === "PIX" ? "PIX" : metodo === "BOLETO" ? "Boleto" : "Cartão"}`}
</Button>
</form>
)
}

View File

@@ -0,0 +1,171 @@
"use client"
import type React from "react"
import { useState, useEffect } from "react"
import { useRouter } from "next/navigation"
import { Button } from "@/components/ui/button"
import { Input } from "@/components/ui/input"
import { Label } from "@/components/ui/label"
import { Textarea } from "@/components/ui/textarea"
import { createProduto, updateProduto } from "@/lib/actions"
import { toast } from "@/components/ui/use-toast"
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
DialogTrigger,
DialogDescription,
} from "@/components/ui/dialog"
import type { Produto } from "@/lib/supabase"
type TipoOptions = "PF" | "PJ" | "SSL" | "NFe"
interface ProductFormDialogProps {
product?: Produto | null
}
const parseCurrency = (value: string) => Number(value.replace(/\D/g, ""))
const formatCurrency = (value: string) => {
const amount = Number(value.replace(/\D/g, "")) / 100
return new Intl.NumberFormat("pt-BR", { style: "currency", currency: "BRL" }).format(amount)
}
export function ProductFormDialog({ product }: ProductFormDialogProps) {
const [open, setOpen] = useState(false)
const router = useRouter()
const [formData, setFormData] = useState({
nome: "",
descricao: "",
price: "0",
tipo: "PF" as TipoOptions,
validade: "",
midia: "",
})
useEffect(() => {
if (product) {
setFormData({
nome: product.nome,
descricao: product.descricao ?? "",
price: formatCurrency((product.preco_centavos).toString()),
tipo: product.tipo,
validade: product.validade,
midia: product.midia ?? "",
})
}
}, [product])
const handleSubmit = async (e: React.FormEvent<HTMLFormElement>) => {
e.preventDefault()
try {
const preco_centavos = parseCurrency(formData.price)
if (isNaN(preco_centavos) || preco_centavos <= 0) throw new Error("Preço inválido")
const payload = {
nome: formData.nome,
descricao: formData.descricao,
tipo: formData.tipo,
validade: formData.validade,
midia: formData.midia || undefined,
preco_centavos,
}
if (product) {
await updateProduto(product.id, payload)
toast({ title: "Produto atualizado", description: "Alterações salvas com sucesso." })
} else {
await createProduto(payload)
toast({ title: "Produto criado", description: "Adicionado com sucesso." })
}
setOpen(false)
router.refresh()
} catch (error) {
toast({
title: "Erro",
description: error instanceof Error ? error.message : "Tente novamente.",
variant: "destructive",
})
}
}
const handleChange = (e: React.ChangeEvent<HTMLInputElement | HTMLTextAreaElement | HTMLSelectElement>) => {
const { name, value } = e.target
setFormData((prev) => ({ ...prev, [name]: value }))
}
return (
<Dialog open={open} onOpenChange={setOpen}>
<DialogTrigger asChild>
<Button onClick={() => setOpen(true)}>{product ? "Editar Produto" : "Novo Produto"}</Button>
</DialogTrigger>
<DialogContent className="sm:max-w-[425px]">
<DialogHeader>
<DialogTitle>{product ? "Editar Produto" : "Criar Novo Produto"}</DialogTitle>
<DialogDescription>Preencha os dados do certificado.</DialogDescription>
</DialogHeader>
<form onSubmit={handleSubmit} className="space-y-4">
<div className="space-y-2">
<Label htmlFor="nome">Nome</Label>
<Input id="nome" name="nome" value={formData.nome} onChange={handleChange} required />
</div>
<div className="space-y-2">
<Label htmlFor="descricao">Descrição</Label>
<Textarea id="descricao" name="descricao" value={formData.descricao} onChange={handleChange} />
</div>
<div className="space-y-2">
<Label htmlFor="price">Preço</Label>
<Input
id="price"
name="price"
value={formData.price}
onChange={(e) => setFormData((prev) => ({ ...prev, price: formatCurrency(e.target.value) }))}
required
/>
</div>
<div className="space-y-2">
<Label htmlFor="tipo">Tipo</Label>
<select
id="tipo"
name="tipo"
value={formData.tipo}
onChange={handleChange}
className="w-full rounded-md border border-input bg-background px-3 py-2 text-sm"
required
>
<option value="PF">PF Pessoa Física</option>
<option value="PJ">PJ Pessoa Jurídica</option>
<option value="SSL">SSL</option>
<option value="NFe">NFe</option>
</select>
</div>
<div className="space-y-2">
<Label htmlFor="validade">Validade</Label>
<Input id="validade" name="validade" placeholder="ex: 1 ano" value={formData.validade} onChange={handleChange} required />
</div>
<div className="space-y-2">
<Label htmlFor="midia">Mídia</Label>
<select
id="midia"
name="midia"
value={formData.midia}
onChange={handleChange}
className="w-full rounded-md border border-input bg-background px-3 py-2 text-sm"
>
<option value="">Selecione...</option>
<option value="Token">Token</option>
<option value="Cartão">Cartão</option>
<option value="Nuvem">Nuvem</option>
<option value="Sem mídia">Sem mídia</option>
</select>
</div>
<div className="flex justify-end gap-2">
<Button type="button" variant="outline" onClick={() => setOpen(false)}>Cancelar</Button>
<Button type="submit">{product ? "Salvar" : "Criar"}</Button>
</div>
</form>
</DialogContent>
</Dialog>
)
}

View File

@@ -0,0 +1,139 @@
"use client"
import { useState } from "react"
import Link from "next/link"
import { useRouter } from "next/navigation"
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table"
import { Button } from "@/components/ui/button"
import { Badge } from "@/components/ui/badge"
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
DialogDescription,
DialogFooter,
} from "@/components/ui/dialog"
import { deleteProduto } from "@/lib/actions"
import { toast } from "@/components/ui/use-toast"
import type { Produto } from "@/lib/supabase"
interface ProductTableProps {
products: Produto[]
}
export function ProductTable({ products }: ProductTableProps) {
const router = useRouter()
const [selectedProduct, setSelectedProduct] = useState<Produto | null>(null)
const [isDeleteDialogOpen, setIsDeleteDialogOpen] = useState(false)
const handleDelete = async () => {
if (!selectedProduct) return
const result = await deleteProduto(selectedProduct.id)
if (result.success) {
toast({ title: "Produto excluído com sucesso." })
setIsDeleteDialogOpen(false)
setSelectedProduct(null)
router.refresh()
} else {
toast({ title: "Erro ao excluir produto.", variant: "destructive" })
}
}
const precoFormatado = (centavos: number) =>
(centavos / 100).toLocaleString("pt-BR", { style: "currency", currency: "BRL" })
return (
<>
<div className="rounded-md border">
<Table>
<TableHeader>
<TableRow>
<TableHead>Nome</TableHead>
<TableHead>Tipo</TableHead>
<TableHead>Validade</TableHead>
<TableHead>Mídia</TableHead>
<TableHead>Preço</TableHead>
<TableHead>Status</TableHead>
<TableHead></TableHead>
</TableRow>
</TableHeader>
<TableBody>
{products.length === 0 ? (
<TableRow>
<TableCell colSpan={7} className="h-24 text-center text-muted-foreground">
Nenhum produto cadastrado.
</TableCell>
</TableRow>
) : (
products.map((product) => (
<TableRow key={product.id} className="cursor-pointer hover:bg-muted/50" onClick={() => setSelectedProduct(product)}>
<TableCell className="font-medium">{product.nome}</TableCell>
<TableCell><Badge variant="outline">{product.tipo}</Badge></TableCell>
<TableCell>{product.validade}</TableCell>
<TableCell>{product.midia ?? "—"}</TableCell>
<TableCell>{precoFormatado(product.preco_centavos)}</TableCell>
<TableCell>
<Badge variant={product.ativo ? "default" : "secondary"}>
{product.ativo ? "Ativo" : "Inativo"}
</Badge>
</TableCell>
<TableCell>
<Button variant="ghost" size="sm" onClick={(e) => { e.stopPropagation(); setSelectedProduct(product) }}>
Detalhes
</Button>
</TableCell>
</TableRow>
))
)}
</TableBody>
</Table>
</div>
{/* Modal de detalhes */}
<Dialog open={!!selectedProduct && !isDeleteDialogOpen} onOpenChange={(open) => !open && setSelectedProduct(null)}>
<DialogContent className="sm:max-w-[600px]">
{selectedProduct && (
<>
<DialogHeader>
<DialogTitle>{selectedProduct.nome}</DialogTitle>
<DialogDescription>{selectedProduct.descricao ?? "Sem descrição"}</DialogDescription>
</DialogHeader>
<div className="grid grid-cols-2 gap-4 mt-4 text-sm">
<div><span className="font-medium">Tipo:</span> {selectedProduct.tipo}</div>
<div><span className="font-medium">Validade:</span> {selectedProduct.validade}</div>
<div><span className="font-medium">Mídia:</span> {selectedProduct.midia ?? "—"}</div>
<div><span className="font-medium">Preço:</span> {precoFormatado(selectedProduct.preco_centavos)}</div>
<div><span className="font-medium">Status:</span> {selectedProduct.ativo ? "Ativo" : "Inativo"}</div>
</div>
<div className="flex justify-end gap-2 mt-6">
<Button asChild variant="outline">
<Link href={`/produtos/${selectedProduct.id}`}>Ver página</Link>
</Button>
<Button variant="destructive" onClick={() => setIsDeleteDialogOpen(true)}>
Excluir
</Button>
</div>
</>
)}
</DialogContent>
</Dialog>
{/* Confirm delete */}
<Dialog open={isDeleteDialogOpen} onOpenChange={setIsDeleteDialogOpen}>
<DialogContent>
<DialogHeader>
<DialogTitle>Confirmar Exclusão</DialogTitle>
<DialogDescription>
Tem certeza que deseja excluir &quot;{selectedProduct?.nome}&quot;? Esta ação não pode ser desfeita.
</DialogDescription>
</DialogHeader>
<DialogFooter>
<Button variant="outline" onClick={() => setIsDeleteDialogOpen(false)}>Cancelar</Button>
<Button variant="destructive" onClick={handleDelete}>Excluir</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</>
)
}

View File

@@ -0,0 +1,67 @@
import { Skeleton } from "@/components/ui/skeleton"
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table"
export function ProductsTableSkeleton() {
return (
<div className="w-full">
<div className="flex items-center py-4">
<Skeleton className="h-8 w-[250px]" />
<Skeleton className="ml-auto h-8 w-[100px]" />
</div>
<div className="rounded-md border">
<Table>
<TableHeader>
<TableRow>
<TableHead className="w-[50px]">
<Skeleton className="h-4 w-4" />
</TableHead>
<TableHead>
<Skeleton className="h-4 w-[100px]" />
</TableHead>
<TableHead>
<Skeleton className="h-4 w-[100px]" />
</TableHead>
<TableHead>
<Skeleton className="h-4 w-[100px]" />
</TableHead>
<TableHead>
<Skeleton className="h-4 w-[100px]" />
</TableHead>
<TableHead className="text-right">
<Skeleton className="h-4 w-[100px] ml-auto" />
</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{Array.from({ length: 5 }).map((_, i) => (
<TableRow key={i}>
<TableCell>
<Skeleton className="h-4 w-4" />
</TableCell>
<TableCell>
<Skeleton className="h-4 w-[200px]" />
</TableCell>
<TableCell>
<Skeleton className="h-4 w-[150px]" />
</TableCell>
<TableCell>
<Skeleton className="h-4 w-[100px]" />
</TableCell>
<TableCell>
<Skeleton className="h-4 w-[100px]" />
</TableCell>
<TableCell className="text-right">
<Skeleton className="h-8 w-8 ml-auto" />
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</div>
<div className="flex items-center justify-end space-x-2 py-4">
<Skeleton className="h-8 w-[100px]" />
<Skeleton className="h-8 w-[70px]" />
</div>
</div>
)
}

View File

@@ -0,0 +1,60 @@
import { Avatar, AvatarFallback } from "@/components/ui/avatar"
interface VendaRecente {
id: string
valor_centavos: number
created_at: string
metodo_pagamento: string | null
clientes: { nome: string; email: string } | null
produtos: { nome: string } | null
}
export function RecentSales({ vendas }: { vendas: VendaRecente[] }) {
if (vendas.length === 0) {
return (
<p className="text-sm text-muted-foreground text-center py-4">
Nenhuma venda confirmada ainda.
</p>
)
}
return (
<div className="space-y-6">
{vendas.map((v) => {
const initials = (v.clientes?.nome ?? "?")
.split(" ")
.slice(0, 2)
.map((n) => n[0])
.join("")
.toUpperCase()
return (
<div key={v.id} className="flex items-center gap-4">
<Avatar className="h-9 w-9">
<AvatarFallback className="text-xs">{initials}</AvatarFallback>
</Avatar>
<div className="flex-1 min-w-0">
<p className="text-sm font-medium leading-none truncate">
{v.clientes?.nome ?? "—"}
</p>
<p className="text-xs text-muted-foreground truncate mt-0.5">
{v.produtos?.nome ?? "—"}
</p>
</div>
<div className="text-right shrink-0">
<p className="text-sm font-semibold">
{(v.valor_centavos / 100).toLocaleString("pt-BR", {
style: "currency",
currency: "BRL",
})}
</p>
<p className="text-xs text-muted-foreground">
{new Date(v.created_at).toLocaleDateString("pt-BR")}
</p>
</div>
</div>
)
})}
</div>
)
}

View File

@@ -0,0 +1,69 @@
"use client"
import { useState } from "react"
import Link from "next/link"
import { useRouter } from "next/navigation"
import { Button } from "@/components/ui/button"
import { Sheet, SheetContent, SheetTitle, SheetTrigger } from "@/components/ui/sheet"
import { useAuth } from "@/lib/auth-context"
import { User, Menu, X, ShoppingBag } from "lucide-react"
import { appConfig } from "@/lib/config"
export function SiteHeader() {
const { isLoggedIn } = useAuth()
const router = useRouter()
const [isMenuOpen, setIsMenuOpen] = useState(false)
return (
<header className="sticky top-0 z-50 w-full bg-white border-b">
<div className="container flex h-16 items-center justify-between">
<Link href="/" className="flex items-center space-x-2">
{appConfig.logoUrl ? (
<img src={appConfig.logoUrl} alt={appConfig.name} className="h-8 w-auto" />
) : (
<span className="text-xl font-bold text-primary">{appConfig.name}</span>
)}
</Link>
<nav className="hidden md:flex items-center space-x-6">
<Button asChild>
<Link href="/comprar">
<ShoppingBag className="mr-2 h-4 w-4" />
Ver produtos
</Link>
</Button>
</nav>
<Sheet open={isMenuOpen} onOpenChange={setIsMenuOpen}>
<SheetTrigger asChild>
<Button variant="ghost" size="icon" className="md:hidden">
{isMenuOpen ? <X className="h-5 w-5" /> : <Menu className="h-5 w-5" />}
</Button>
</SheetTrigger>
<SheetContent side="right" className="w-[300px]">
<SheetTitle className="sr-only">Menu</SheetTitle>
<nav className="flex flex-col space-y-4 mt-4">
<Button
className="w-full"
onClick={() => { setIsMenuOpen(false); router.push("/comprar") }}
>
<ShoppingBag className="mr-2 h-4 w-4" />
Ver produtos
</Button>
{isLoggedIn && (
<Button
variant="outline"
onClick={() => { setIsMenuOpen(false); router.push("/admin") }}
className="w-full"
>
<User className="h-4 w-4 mr-2" />
Dashboard
</Button>
)}
</nav>
</SheetContent>
</Sheet>
</div>
</header>
)
}

View File

@@ -0,0 +1,7 @@
"use client"
import { ThemeProvider as NextThemesProvider } from "next-themes"
import type { ThemeProviderProps } from "next-themes"
export function ThemeProvider({ children, ...props }: ThemeProviderProps) {
return <NextThemesProvider {...props}>{children}</NextThemesProvider>
}

View File

@@ -0,0 +1,197 @@
"use client"
import { useState, useMemo } from "react"
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table"
import { Input } from "@/components/ui/input"
import { Button } from "@/components/ui/button"
import { PedidoDetailModal } from "@/components/pedido-detail-modal"
import { Search, Download } from "lucide-react"
import type { Pedido } from "@/lib/supabase"
const statusColors: Record<string, string> = {
PENDING: "bg-yellow-100 text-yellow-800",
RECEIVED: "bg-green-100 text-green-800",
CONFIRMED: "bg-green-100 text-green-800",
OVERDUE: "bg-red-100 text-red-800",
REFUNDED: "bg-gray-100 text-gray-800",
CANCELLED: "bg-red-100 text-red-800",
}
const statusLabel: Record<string, string> = {
PENDING: "Pendente",
RECEIVED: "Pago",
CONFIRMED: "Confirmado",
OVERDUE: "Vencido",
REFUNDED: "Estornado",
CANCELLED: "Cancelado",
}
const ALL_STATUSES = ["PENDING", "RECEIVED", "CONFIRMED", "OVERDUE", "REFUNDED", "CANCELLED"]
type PedidoRich = Pedido & {
clientes?: { nome: string; email: string; telefone?: string | null; cpf_cnpj?: string | null } | null
produtos?: { nome: string; tipo: string; validade?: string; midia?: string | null } | null
asaas_invoice_url?: string | null
pix_copia_cola?: string | null
pix_qrcode_url?: string | null
paid_at?: string | null
}
export function TransactionTable({ transactions }: { transactions: PedidoRich[] }) {
const [busca, setBusca] = useState("")
const [filtroStatus, setFiltroStatus] = useState<string | null>(null)
const [pedidoSelecionado, setPedidoSelecionado] = useState<PedidoRich | null>(null)
const fmt = (v: number) =>
(v / 100).toLocaleString("pt-BR", { style: "currency", currency: "BRL" })
const filtrados = useMemo(() => {
return transactions.filter((t) => {
const matchStatus = !filtroStatus || t.status === filtroStatus
const q = busca.toLowerCase()
const matchBusca =
!q ||
t.id.toLowerCase().includes(q) ||
(t.clientes?.nome ?? "").toLowerCase().includes(q) ||
(t.clientes?.email ?? "").toLowerCase().includes(q) ||
(t.produtos?.nome ?? "").toLowerCase().includes(q)
return matchStatus && matchBusca
})
}, [transactions, busca, filtroStatus])
function exportCSV() {
const header = ["ID", "Cliente", "Email", "Produto", "Valor", "Método", "Status", "Data"]
const rows = filtrados.map((t) => [
t.id,
t.clientes?.nome ?? "",
t.clientes?.email ?? "",
t.produtos?.nome ?? "",
(t.valor_centavos / 100).toFixed(2),
t.metodo_pagamento ?? "",
t.status,
new Date(t.created_at).toLocaleDateString("pt-BR"),
])
const csv = [header, ...rows].map((r) => r.map((c) => `"${c}"`).join(",")).join("\n")
const blob = new Blob([csv], { type: "text/csv;charset=utf-8;" })
const url = URL.createObjectURL(blob)
const a = document.createElement("a")
a.href = url
a.download = `pedidos-${new Date().toISOString().slice(0, 10)}.csv`
a.click()
URL.revokeObjectURL(url)
}
if (transactions.length === 0) {
return (
<div className="rounded-md border p-8 text-center text-muted-foreground">
Nenhum pedido encontrado.
</div>
)
}
return (
<div className="space-y-3">
{/* Toolbar */}
<div className="flex flex-wrap gap-2 items-center justify-between">
<div className="flex gap-2 flex-wrap">
<div className="relative">
<Search className="absolute left-2.5 top-2.5 h-4 w-4 text-muted-foreground" />
<Input
placeholder="Buscar cliente, produto, ID..."
className="pl-8 h-9 w-64"
value={busca}
onChange={(e) => setBusca(e.target.value)}
/>
</div>
<div className="flex gap-1 flex-wrap">
<Button
size="sm"
variant={filtroStatus === null ? "default" : "outline"}
className="h-9 text-xs"
onClick={() => setFiltroStatus(null)}
>
Todos
</Button>
{ALL_STATUSES.map((s) => (
<Button
key={s}
size="sm"
variant={filtroStatus === s ? "default" : "outline"}
className="h-9 text-xs"
onClick={() => setFiltroStatus(filtroStatus === s ? null : s)}
>
{statusLabel[s]}
</Button>
))}
</div>
</div>
<Button size="sm" variant="outline" className="h-9" onClick={exportCSV}>
<Download className="mr-2 h-4 w-4" />
Exportar CSV
</Button>
</div>
{/* Contagem */}
<p className="text-xs text-muted-foreground">
{filtrados.length} pedido{filtrados.length !== 1 ? "s" : ""} encontrado{filtrados.length !== 1 ? "s" : ""}
</p>
{/* Tabela */}
<div className="rounded-md border">
<Table>
<TableHeader>
<TableRow>
<TableHead>ID</TableHead>
<TableHead>Cliente</TableHead>
<TableHead>Produto</TableHead>
<TableHead>Valor</TableHead>
<TableHead>Método</TableHead>
<TableHead>Status</TableHead>
<TableHead>Data</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{filtrados.length === 0 ? (
<TableRow>
<TableCell colSpan={7} className="text-center text-muted-foreground py-8">
Nenhum pedido encontrado com esses filtros.
</TableCell>
</TableRow>
) : (
filtrados.map((t) => (
<TableRow
key={t.id}
className="cursor-pointer hover:bg-muted/50"
onClick={() => setPedidoSelecionado(t)}
>
<TableCell className="font-mono text-xs">{t.id.slice(0, 8).toUpperCase()}</TableCell>
<TableCell className="max-w-[120px] truncate">{t.clientes?.nome ?? "—"}</TableCell>
<TableCell className="max-w-[140px] truncate">{t.produtos?.nome ?? "—"}</TableCell>
<TableCell className="font-medium">{fmt(t.valor_centavos)}</TableCell>
<TableCell className="text-xs">{t.metodo_pagamento ?? "—"}</TableCell>
<TableCell>
<span className={`px-2 py-0.5 rounded-full text-xs font-medium ${statusColors[t.status] ?? ""}`}>
{statusLabel[t.status] ?? t.status}
</span>
</TableCell>
<TableCell className="text-sm text-muted-foreground whitespace-nowrap">
{new Date(t.created_at).toLocaleDateString("pt-BR")}
</TableCell>
</TableRow>
))
)}
</TableBody>
</Table>
</div>
{/* Modal detalhe */}
{pedidoSelecionado && (
<PedidoDetailModal
pedido={pedidoSelecionado as Parameters<typeof PedidoDetailModal>[0]["pedido"]}
open={!!pedidoSelecionado}
onClose={() => setPedidoSelecionado(null)}
/>
)}
</div>
)
}

View File

@@ -0,0 +1,58 @@
"use client"
import * as React from "react"
import * as AccordionPrimitive from "@radix-ui/react-accordion"
import { ChevronDown } from "lucide-react"
import { cn } from "@/lib/utils"
const Accordion = AccordionPrimitive.Root
const AccordionItem = React.forwardRef<
React.ElementRef<typeof AccordionPrimitive.Item>,
React.ComponentPropsWithoutRef<typeof AccordionPrimitive.Item>
>(({ className, ...props }, ref) => (
<AccordionPrimitive.Item
ref={ref}
className={cn("border-b", className)}
{...props}
/>
))
AccordionItem.displayName = "AccordionItem"
const AccordionTrigger = React.forwardRef<
React.ElementRef<typeof AccordionPrimitive.Trigger>,
React.ComponentPropsWithoutRef<typeof AccordionPrimitive.Trigger>
>(({ className, children, ...props }, ref) => (
<AccordionPrimitive.Header className="flex">
<AccordionPrimitive.Trigger
ref={ref}
className={cn(
"flex flex-1 items-center justify-between py-4 font-medium transition-all hover:underline [&[data-state=open]>svg]:rotate-180",
className
)}
{...props}
>
{children}
<ChevronDown className="h-4 w-4 shrink-0 transition-transform duration-200" />
</AccordionPrimitive.Trigger>
</AccordionPrimitive.Header>
))
AccordionTrigger.displayName = AccordionPrimitive.Trigger.displayName
const AccordionContent = React.forwardRef<
React.ElementRef<typeof AccordionPrimitive.Content>,
React.ComponentPropsWithoutRef<typeof AccordionPrimitive.Content>
>(({ className, children, ...props }, ref) => (
<AccordionPrimitive.Content
ref={ref}
className="overflow-hidden text-sm transition-all data-[state=closed]:animate-accordion-up data-[state=open]:animate-accordion-down"
{...props}
>
<div className={cn("pb-4 pt-0", className)}>{children}</div>
</AccordionPrimitive.Content>
))
AccordionContent.displayName = AccordionPrimitive.Content.displayName
export { Accordion, AccordionItem, AccordionTrigger, AccordionContent }

50
components/ui/avatar.tsx Normal file
View File

@@ -0,0 +1,50 @@
"use client"
import * as React from "react"
import * as AvatarPrimitive from "@radix-ui/react-avatar"
import { cn } from "@/lib/utils"
const Avatar = React.forwardRef<
React.ElementRef<typeof AvatarPrimitive.Root>,
React.ComponentPropsWithoutRef<typeof AvatarPrimitive.Root>
>(({ className, ...props }, ref) => (
<AvatarPrimitive.Root
ref={ref}
className={cn(
"relative flex h-10 w-10 shrink-0 overflow-hidden rounded-full",
className
)}
{...props}
/>
))
Avatar.displayName = AvatarPrimitive.Root.displayName
const AvatarImage = React.forwardRef<
React.ElementRef<typeof AvatarPrimitive.Image>,
React.ComponentPropsWithoutRef<typeof AvatarPrimitive.Image>
>(({ className, ...props }, ref) => (
<AvatarPrimitive.Image
ref={ref}
className={cn("aspect-square h-full w-full", className)}
{...props}
/>
))
AvatarImage.displayName = AvatarPrimitive.Image.displayName
const AvatarFallback = React.forwardRef<
React.ElementRef<typeof AvatarPrimitive.Fallback>,
React.ComponentPropsWithoutRef<typeof AvatarPrimitive.Fallback>
>(({ className, ...props }, ref) => (
<AvatarPrimitive.Fallback
ref={ref}
className={cn(
"flex h-full w-full items-center justify-center rounded-full bg-muted",
className
)}
{...props}
/>
))
AvatarFallback.displayName = AvatarPrimitive.Fallback.displayName
export { Avatar, AvatarImage, AvatarFallback }

36
components/ui/badge.tsx Normal file
View File

@@ -0,0 +1,36 @@
import * as React from "react"
import { cva, type VariantProps } from "class-variance-authority"
import { cn } from "@/lib/utils"
const badgeVariants = cva(
"inline-flex items-center rounded-full border px-2.5 py-0.5 text-xs font-semibold transition-colors focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2",
{
variants: {
variant: {
default:
"border-transparent bg-primary text-primary-foreground hover:bg-primary/80",
secondary:
"border-transparent bg-secondary text-secondary-foreground hover:bg-secondary/80",
destructive:
"border-transparent bg-destructive text-destructive-foreground hover:bg-destructive/80",
outline: "text-foreground",
},
},
defaultVariants: {
variant: "default",
},
}
)
export interface BadgeProps
extends React.HTMLAttributes<HTMLDivElement>,
VariantProps<typeof badgeVariants> {}
function Badge({ className, variant, ...props }: BadgeProps) {
return (
<div className={cn(badgeVariants({ variant }), className)} {...props} />
)
}
export { Badge, badgeVariants }

47
components/ui/button.tsx Normal file
View File

@@ -0,0 +1,47 @@
import * as React from "react"
import { Slot } from "@radix-ui/react-slot"
import { cva, type VariantProps } from "class-variance-authority"
import { cn } from "@/lib/utils"
const buttonVariants = cva(
"inline-flex items-center justify-center whitespace-nowrap rounded-md text-sm font-medium ring-offset-background transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50",
{
variants: {
variant: {
default: "bg-primary text-primary-foreground hover:bg-primary/90",
destructive: "bg-destructive text-destructive-foreground hover:bg-destructive/90",
outline: "border border-input bg-background hover:bg-accent hover:text-accent-foreground",
secondary: "bg-secondary text-secondary-foreground hover:bg-secondary/80",
ghost: "hover:bg-accent hover:text-accent-foreground",
link: "text-primary underline-offset-4 hover:underline",
},
size: {
default: "h-10 px-4 py-2",
sm: "h-9 rounded-md px-3",
lg: "h-11 rounded-md px-8",
icon: "h-10 w-10",
},
},
defaultVariants: {
variant: "default",
size: "default",
},
},
)
export interface ButtonProps
extends React.ButtonHTMLAttributes<HTMLButtonElement>,
VariantProps<typeof buttonVariants> {
asChild?: boolean
}
const Button = React.forwardRef<HTMLButtonElement, ButtonProps>(
({ className, variant, size, asChild = false, ...props }, ref) => {
const Comp = asChild ? Slot : "button"
return <Comp className={cn(buttonVariants({ variant, size, className }))} ref={ref} {...props} />
},
)
Button.displayName = "Button"
export { Button, buttonVariants }

View File

@@ -0,0 +1,21 @@
"use client"
import * as React from "react"
import { DayPicker } from "react-day-picker"
import { cn } from "@/lib/utils"
export type CalendarProps = React.ComponentProps<typeof DayPicker>
function Calendar({ className, classNames, showOutsideDays = true, ...props }: CalendarProps) {
return (
<DayPicker
showOutsideDays={showOutsideDays}
className={cn("p-3", className)}
classNames={classNames}
{...props}
/>
)
}
Calendar.displayName = "Calendar"
export { Calendar }

79
components/ui/card.tsx Normal file
View File

@@ -0,0 +1,79 @@
import * as React from "react"
import { cn } from "@/lib/utils"
const Card = React.forwardRef<
HTMLDivElement,
React.HTMLAttributes<HTMLDivElement>
>(({ className, ...props }, ref) => (
<div
ref={ref}
className={cn(
"rounded-lg border bg-card text-card-foreground shadow-sm",
className
)}
{...props}
/>
))
Card.displayName = "Card"
const CardHeader = React.forwardRef<
HTMLDivElement,
React.HTMLAttributes<HTMLDivElement>
>(({ className, ...props }, ref) => (
<div
ref={ref}
className={cn("flex flex-col space-y-1.5 p-6", className)}
{...props}
/>
))
CardHeader.displayName = "CardHeader"
const CardTitle = React.forwardRef<
HTMLDivElement,
React.HTMLAttributes<HTMLDivElement>
>(({ className, ...props }, ref) => (
<div
ref={ref}
className={cn(
"text-2xl font-semibold leading-none tracking-tight",
className
)}
{...props}
/>
))
CardTitle.displayName = "CardTitle"
const CardDescription = React.forwardRef<
HTMLDivElement,
React.HTMLAttributes<HTMLDivElement>
>(({ className, ...props }, ref) => (
<div
ref={ref}
className={cn("text-sm text-muted-foreground", className)}
{...props}
/>
))
CardDescription.displayName = "CardDescription"
const CardContent = React.forwardRef<
HTMLDivElement,
React.HTMLAttributes<HTMLDivElement>
>(({ className, ...props }, ref) => (
<div ref={ref} className={cn("p-6 pt-0", className)} {...props} />
))
CardContent.displayName = "CardContent"
const CardFooter = React.forwardRef<
HTMLDivElement,
React.HTMLAttributes<HTMLDivElement>
>(({ className, ...props }, ref) => (
<div
ref={ref}
className={cn("flex items-center p-6 pt-0", className)}
{...props}
/>
))
CardFooter.displayName = "CardFooter"
export { Card, CardHeader, CardFooter, CardTitle, CardDescription, CardContent }

View File

@@ -0,0 +1,30 @@
"use client"
import * as React from "react"
import * as CheckboxPrimitive from "@radix-ui/react-checkbox"
import { Check } from "lucide-react"
import { cn } from "@/lib/utils"
const Checkbox = React.forwardRef<
React.ElementRef<typeof CheckboxPrimitive.Root>,
React.ComponentPropsWithoutRef<typeof CheckboxPrimitive.Root>
>(({ className, ...props }, ref) => (
<CheckboxPrimitive.Root
ref={ref}
className={cn(
"peer h-4 w-4 shrink-0 rounded-sm border border-primary ring-offset-background focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50 data-[state=checked]:bg-primary data-[state=checked]:text-primary-foreground",
className
)}
{...props}
>
<CheckboxPrimitive.Indicator
className={cn("flex items-center justify-center text-current")}
>
<Check className="h-4 w-4" />
</CheckboxPrimitive.Indicator>
</CheckboxPrimitive.Root>
))
Checkbox.displayName = CheckboxPrimitive.Root.displayName
export { Checkbox }

69
components/ui/command.tsx Normal file
View File

@@ -0,0 +1,69 @@
"use client"
import * as React from "react"
import { Command as CommandPrimitive } from "cmdk"
import { cn } from "@/lib/utils"
const Command = React.forwardRef<
React.ElementRef<typeof CommandPrimitive>,
React.ComponentPropsWithoutRef<typeof CommandPrimitive>
>(({ className, ...props }, ref) => (
<CommandPrimitive
ref={ref}
className={cn("flex h-full w-full flex-col overflow-hidden rounded-md bg-popover text-popover-foreground", className)}
{...props}
/>
))
Command.displayName = CommandPrimitive.displayName
const CommandInput = React.forwardRef<
React.ElementRef<typeof CommandPrimitive.Input>,
React.ComponentPropsWithoutRef<typeof CommandPrimitive.Input>
>(({ className, ...props }, ref) => (
<CommandPrimitive.Input
ref={ref}
className={cn("flex h-10 w-full rounded-md bg-transparent py-3 text-sm outline-none placeholder:text-muted-foreground disabled:cursor-not-allowed disabled:opacity-50", className)}
{...props}
/>
))
CommandInput.displayName = CommandPrimitive.Input.displayName
const CommandList = React.forwardRef<
React.ElementRef<typeof CommandPrimitive.List>,
React.ComponentPropsWithoutRef<typeof CommandPrimitive.List>
>(({ className, ...props }, ref) => (
<CommandPrimitive.List ref={ref} className={cn("max-h-[300px] overflow-y-auto overflow-x-hidden", className)} {...props} />
))
CommandList.displayName = CommandPrimitive.List.displayName
const CommandEmpty = React.forwardRef<
React.ElementRef<typeof CommandPrimitive.Empty>,
React.ComponentPropsWithoutRef<typeof CommandPrimitive.Empty>
>((props, ref) => <CommandPrimitive.Empty ref={ref} className="py-6 text-center text-sm" {...props} />)
CommandEmpty.displayName = CommandPrimitive.Empty.displayName
const CommandGroup = React.forwardRef<
React.ElementRef<typeof CommandPrimitive.Group>,
React.ComponentPropsWithoutRef<typeof CommandPrimitive.Group>
>(({ className, ...props }, ref) => (
<CommandPrimitive.Group ref={ref} className={cn("overflow-hidden p-1 text-foreground [&_[cmdk-group-heading]]:px-2 [&_[cmdk-group-heading]]:py-1.5 [&_[cmdk-group-heading]]:text-xs [&_[cmdk-group-heading]]:font-medium [&_[cmdk-group-heading]]:text-muted-foreground", className)} {...props} />
))
CommandGroup.displayName = CommandPrimitive.Group.displayName
const CommandItem = React.forwardRef<
React.ElementRef<typeof CommandPrimitive.Item>,
React.ComponentPropsWithoutRef<typeof CommandPrimitive.Item>
>(({ className, ...props }, ref) => (
<CommandPrimitive.Item ref={ref} className={cn("relative flex cursor-default select-none items-center rounded-sm px-2 py-1.5 text-sm outline-none aria-selected:bg-accent aria-selected:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50", className)} {...props} />
))
CommandItem.displayName = CommandPrimitive.Item.displayName
const CommandSeparator = React.forwardRef<
React.ElementRef<typeof CommandPrimitive.Separator>,
React.ComponentPropsWithoutRef<typeof CommandPrimitive.Separator>
>(({ className, ...props }, ref) => (
<CommandPrimitive.Separator ref={ref} className={cn("-mx-1 h-px bg-border", className)} {...props} />
))
CommandSeparator.displayName = CommandPrimitive.Separator.displayName
export { Command, CommandInput, CommandList, CommandEmpty, CommandGroup, CommandItem, CommandSeparator }

97
components/ui/dialog.tsx Normal file
View File

@@ -0,0 +1,97 @@
"use client"
import * as React from "react"
import * as DialogPrimitive from "@radix-ui/react-dialog"
import { X } from "lucide-react"
import { cn } from "@/lib/utils"
const Dialog = DialogPrimitive.Root
const DialogTrigger = DialogPrimitive.Trigger
const DialogPortal = DialogPrimitive.Portal
const DialogClose = DialogPrimitive.Close
const DialogOverlay = React.forwardRef<
React.ElementRef<typeof DialogPrimitive.Overlay>,
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Overlay>
>(({ className, ...props }, ref) => (
<DialogPrimitive.Overlay
ref={ref}
className={cn(
"fixed inset-0 z-50 bg-background/80 backdrop-blur-sm data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0",
className,
)}
{...props}
/>
))
DialogOverlay.displayName = DialogPrimitive.Overlay.displayName
const DialogContent = React.forwardRef<
React.ElementRef<typeof DialogPrimitive.Content>,
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Content>
>(({ className, children, ...props }, ref) => (
<DialogPortal>
<DialogOverlay />
<DialogPrimitive.Content
ref={ref}
className={cn(
"fixed left-[50%] top-[50%] z-50 grid w-full max-w-lg translate-x-[-50%] translate-y-[-50%] gap-4 border bg-background p-6 shadow-lg duration-200 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[state=closed]:slide-out-to-left-1/2 data-[state=closed]:slide-out-to-top-[48%] data-[state=open]:slide-in-from-left-1/2 data-[state=open]:slide-in-from-top-[48%] sm:rounded-[20px]",
className,
)}
{...props}
>
{children}
<DialogPrimitive.Close className="absolute right-4 top-4 rounded-sm opacity-70 ring-offset-background transition-opacity hover:opacity-100 focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2 disabled:pointer-events-none data-[state=open]:bg-accent data-[state=open]:text-muted-foreground">
<X className="h-4 w-4" />
<span className="sr-only">Close</span>
</DialogPrimitive.Close>
</DialogPrimitive.Content>
</DialogPortal>
))
DialogContent.displayName = DialogPrimitive.Content.displayName
const DialogHeader = ({ className, ...props }: React.HTMLAttributes<HTMLDivElement>) => (
<div className={cn("flex flex-col space-y-1.5 text-center sm:text-left", className)} {...props} />
)
DialogHeader.displayName = "DialogHeader"
const DialogFooter = ({ className, ...props }: React.HTMLAttributes<HTMLDivElement>) => (
<div className={cn("flex flex-col-reverse sm:flex-row sm:justify-end sm:space-x-2", className)} {...props} />
)
DialogFooter.displayName = "DialogFooter"
const DialogTitle = React.forwardRef<
React.ElementRef<typeof DialogPrimitive.Title>,
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Title>
>(({ className, ...props }, ref) => (
<DialogPrimitive.Title
ref={ref}
className={cn("text-lg font-semibold leading-none tracking-tight", className)}
{...props}
/>
))
DialogTitle.displayName = DialogPrimitive.Title.displayName
const DialogDescription = React.forwardRef<
React.ElementRef<typeof DialogPrimitive.Description>,
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Description>
>(({ className, ...props }, ref) => (
<DialogPrimitive.Description ref={ref} className={cn("text-sm text-muted-foreground", className)} {...props} />
))
DialogDescription.displayName = DialogPrimitive.Description.displayName
export {
Dialog,
DialogPortal,
DialogOverlay,
DialogClose,
DialogTrigger,
DialogContent,
DialogHeader,
DialogFooter,
DialogTitle,
DialogDescription,
}

View File

@@ -0,0 +1,100 @@
"use client"
import * as React from "react"
import * as DropdownMenuPrimitive from "@radix-ui/react-dropdown-menu"
import { cn } from "@/lib/utils"
const DropdownMenu = DropdownMenuPrimitive.Root
const DropdownMenuTrigger = DropdownMenuPrimitive.Trigger
const DropdownMenuGroup = DropdownMenuPrimitive.Group
const DropdownMenuSub = DropdownMenuPrimitive.Sub
const DropdownMenuRadioGroup = DropdownMenuPrimitive.RadioGroup
const DropdownMenuContent = React.forwardRef<
React.ElementRef<typeof DropdownMenuPrimitive.Content>,
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.Content>
>(({ className, sideOffset = 4, ...props }, ref) => (
<DropdownMenuPrimitive.Portal>
<DropdownMenuPrimitive.Content
ref={ref}
sideOffset={sideOffset}
className={cn(
"z-50 min-w-[8rem] overflow-hidden rounded-md border bg-popover p-1 text-popover-foreground shadow-md data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2",
className
)}
{...props}
/>
</DropdownMenuPrimitive.Portal>
))
DropdownMenuContent.displayName = DropdownMenuPrimitive.Content.displayName
const DropdownMenuItem = React.forwardRef<
React.ElementRef<typeof DropdownMenuPrimitive.Item>,
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.Item> & { inset?: boolean }
>(({ className, inset, ...props }, ref) => (
<DropdownMenuPrimitive.Item
ref={ref}
className={cn(
"relative flex cursor-default select-none items-center rounded-sm px-2 py-1.5 text-sm outline-none transition-colors focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50",
inset && "pl-8",
className
)}
{...props}
/>
))
DropdownMenuItem.displayName = DropdownMenuPrimitive.Item.displayName
const DropdownMenuCheckboxItem = React.forwardRef<
React.ElementRef<typeof DropdownMenuPrimitive.CheckboxItem>,
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.CheckboxItem>
>(({ className, children, checked, ...props }, ref) => (
<DropdownMenuPrimitive.CheckboxItem
ref={ref}
className={cn(
"relative flex cursor-default select-none items-center rounded-sm py-1.5 pl-8 pr-2 text-sm outline-none transition-colors focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50",
className
)}
checked={checked}
{...props}
>
{children}
</DropdownMenuPrimitive.CheckboxItem>
))
DropdownMenuCheckboxItem.displayName = DropdownMenuPrimitive.CheckboxItem.displayName
const DropdownMenuLabel = React.forwardRef<
React.ElementRef<typeof DropdownMenuPrimitive.Label>,
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.Label> & { inset?: boolean }
>(({ className, inset, ...props }, ref) => (
<DropdownMenuPrimitive.Label
ref={ref}
className={cn("px-2 py-1.5 text-sm font-semibold", inset && "pl-8", className)}
{...props}
/>
))
DropdownMenuLabel.displayName = DropdownMenuPrimitive.Label.displayName
const DropdownMenuSeparator = React.forwardRef<
React.ElementRef<typeof DropdownMenuPrimitive.Separator>,
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.Separator>
>(({ className, ...props }, ref) => (
<DropdownMenuPrimitive.Separator
ref={ref}
className={cn("-mx-1 my-1 h-px bg-muted", className)}
{...props}
/>
))
DropdownMenuSeparator.displayName = DropdownMenuPrimitive.Separator.displayName
export {
DropdownMenu,
DropdownMenuTrigger,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuCheckboxItem,
DropdownMenuLabel,
DropdownMenuSeparator,
DropdownMenuGroup,
DropdownMenuSub,
DropdownMenuRadioGroup,
}

22
components/ui/input.tsx Normal file
View File

@@ -0,0 +1,22 @@
import * as React from "react"
import { cn } from "@/lib/utils"
const Input = React.forwardRef<HTMLInputElement, React.ComponentProps<"input">>(
({ className, type, ...props }, ref) => {
return (
<input
type={type}
className={cn(
"flex h-10 w-full rounded-md border border-input bg-background px-3 py-2 text-base ring-offset-background file:border-0 file:bg-transparent file:text-sm file:font-medium file:text-foreground placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50 md:text-sm",
className
)}
ref={ref}
{...props}
/>
)
}
)
Input.displayName = "Input"
export { Input }

26
components/ui/label.tsx Normal file
View File

@@ -0,0 +1,26 @@
"use client"
import * as React from "react"
import * as LabelPrimitive from "@radix-ui/react-label"
import { cva, type VariantProps } from "class-variance-authority"
import { cn } from "@/lib/utils"
const labelVariants = cva(
"text-sm font-medium leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70"
)
const Label = React.forwardRef<
React.ElementRef<typeof LabelPrimitive.Root>,
React.ComponentPropsWithoutRef<typeof LabelPrimitive.Root> &
VariantProps<typeof labelVariants>
>(({ className, ...props }, ref) => (
<LabelPrimitive.Root
ref={ref}
className={cn(labelVariants(), className)}
{...props}
/>
))
Label.displayName = LabelPrimitive.Root.displayName
export { Label }

29
components/ui/popover.tsx Normal file
View File

@@ -0,0 +1,29 @@
"use client"
import * as React from "react"
import * as PopoverPrimitive from "@radix-ui/react-popover"
import { cn } from "@/lib/utils"
const Popover = PopoverPrimitive.Root
const PopoverTrigger = PopoverPrimitive.Trigger
const PopoverContent = React.forwardRef<
React.ElementRef<typeof PopoverPrimitive.Content>,
React.ComponentPropsWithoutRef<typeof PopoverPrimitive.Content>
>(({ className, align = "center", sideOffset = 4, ...props }, ref) => (
<PopoverPrimitive.Portal>
<PopoverPrimitive.Content
ref={ref}
align={align}
sideOffset={sideOffset}
className={cn(
"z-50 w-72 rounded-md border bg-popover p-4 text-popover-foreground shadow-md outline-none data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2",
className
)}
{...props}
/>
</PopoverPrimitive.Portal>
))
PopoverContent.displayName = PopoverPrimitive.Content.displayName
export { Popover, PopoverTrigger, PopoverContent }

160
components/ui/select.tsx Normal file
View File

@@ -0,0 +1,160 @@
"use client"
import * as React from "react"
import * as SelectPrimitive from "@radix-ui/react-select"
import { Check, ChevronDown, ChevronUp } from "lucide-react"
import { cn } from "@/lib/utils"
const Select = SelectPrimitive.Root
const SelectGroup = SelectPrimitive.Group
const SelectValue = SelectPrimitive.Value
const SelectTrigger = React.forwardRef<
React.ElementRef<typeof SelectPrimitive.Trigger>,
React.ComponentPropsWithoutRef<typeof SelectPrimitive.Trigger>
>(({ className, children, ...props }, ref) => (
<SelectPrimitive.Trigger
ref={ref}
className={cn(
"flex h-10 w-full items-center justify-between rounded-md border border-input bg-background px-3 py-2 text-sm ring-offset-background placeholder:text-muted-foreground focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50 [&>span]:line-clamp-1",
className
)}
{...props}
>
{children}
<SelectPrimitive.Icon asChild>
<ChevronDown className="h-4 w-4 opacity-50" />
</SelectPrimitive.Icon>
</SelectPrimitive.Trigger>
))
SelectTrigger.displayName = SelectPrimitive.Trigger.displayName
const SelectScrollUpButton = React.forwardRef<
React.ElementRef<typeof SelectPrimitive.ScrollUpButton>,
React.ComponentPropsWithoutRef<typeof SelectPrimitive.ScrollUpButton>
>(({ className, ...props }, ref) => (
<SelectPrimitive.ScrollUpButton
ref={ref}
className={cn(
"flex cursor-default items-center justify-center py-1",
className
)}
{...props}
>
<ChevronUp className="h-4 w-4" />
</SelectPrimitive.ScrollUpButton>
))
SelectScrollUpButton.displayName = SelectPrimitive.ScrollUpButton.displayName
const SelectScrollDownButton = React.forwardRef<
React.ElementRef<typeof SelectPrimitive.ScrollDownButton>,
React.ComponentPropsWithoutRef<typeof SelectPrimitive.ScrollDownButton>
>(({ className, ...props }, ref) => (
<SelectPrimitive.ScrollDownButton
ref={ref}
className={cn(
"flex cursor-default items-center justify-center py-1",
className
)}
{...props}
>
<ChevronDown className="h-4 w-4" />
</SelectPrimitive.ScrollDownButton>
))
SelectScrollDownButton.displayName =
SelectPrimitive.ScrollDownButton.displayName
const SelectContent = React.forwardRef<
React.ElementRef<typeof SelectPrimitive.Content>,
React.ComponentPropsWithoutRef<typeof SelectPrimitive.Content>
>(({ className, children, position = "popper", ...props }, ref) => (
<SelectPrimitive.Portal>
<SelectPrimitive.Content
ref={ref}
className={cn(
"relative z-50 max-h-96 min-w-[8rem] overflow-hidden rounded-md border bg-popover text-popover-foreground shadow-md data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2",
position === "popper" &&
"data-[side=bottom]:translate-y-1 data-[side=left]:-translate-x-1 data-[side=right]:translate-x-1 data-[side=top]:-translate-y-1",
className
)}
position={position}
{...props}
>
<SelectScrollUpButton />
<SelectPrimitive.Viewport
className={cn(
"p-1",
position === "popper" &&
"h-[var(--radix-select-trigger-height)] w-full min-w-[var(--radix-select-trigger-width)]"
)}
>
{children}
</SelectPrimitive.Viewport>
<SelectScrollDownButton />
</SelectPrimitive.Content>
</SelectPrimitive.Portal>
))
SelectContent.displayName = SelectPrimitive.Content.displayName
const SelectLabel = React.forwardRef<
React.ElementRef<typeof SelectPrimitive.Label>,
React.ComponentPropsWithoutRef<typeof SelectPrimitive.Label>
>(({ className, ...props }, ref) => (
<SelectPrimitive.Label
ref={ref}
className={cn("py-1.5 pl-8 pr-2 text-sm font-semibold", className)}
{...props}
/>
))
SelectLabel.displayName = SelectPrimitive.Label.displayName
const SelectItem = React.forwardRef<
React.ElementRef<typeof SelectPrimitive.Item>,
React.ComponentPropsWithoutRef<typeof SelectPrimitive.Item>
>(({ className, children, ...props }, ref) => (
<SelectPrimitive.Item
ref={ref}
className={cn(
"relative flex w-full cursor-default select-none items-center rounded-sm py-1.5 pl-8 pr-2 text-sm outline-none focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50",
className
)}
{...props}
>
<span className="absolute left-2 flex h-3.5 w-3.5 items-center justify-center">
<SelectPrimitive.ItemIndicator>
<Check className="h-4 w-4" />
</SelectPrimitive.ItemIndicator>
</span>
<SelectPrimitive.ItemText>{children}</SelectPrimitive.ItemText>
</SelectPrimitive.Item>
))
SelectItem.displayName = SelectPrimitive.Item.displayName
const SelectSeparator = React.forwardRef<
React.ElementRef<typeof SelectPrimitive.Separator>,
React.ComponentPropsWithoutRef<typeof SelectPrimitive.Separator>
>(({ className, ...props }, ref) => (
<SelectPrimitive.Separator
ref={ref}
className={cn("-mx-1 my-1 h-px bg-muted", className)}
{...props}
/>
))
SelectSeparator.displayName = SelectPrimitive.Separator.displayName
export {
Select,
SelectGroup,
SelectValue,
SelectTrigger,
SelectContent,
SelectLabel,
SelectItem,
SelectSeparator,
SelectScrollUpButton,
SelectScrollDownButton,
}

View File

@@ -0,0 +1,14 @@
"use client"
import * as React from "react"
import * as SeparatorPrimitive from "@radix-ui/react-separator"
import { cn } from "@/lib/utils"
const Separator = React.forwardRef<
React.ElementRef<typeof SeparatorPrimitive.Root>,
React.ComponentPropsWithoutRef<typeof SeparatorPrimitive.Root>
>(({ className, orientation = "horizontal", decorative = true, ...props }, ref) => (
<SeparatorPrimitive.Root ref={ref} decorative={decorative} orientation={orientation}
className={cn("shrink-0 bg-border", orientation === "horizontal" ? "h-[1px] w-full" : "h-full w-[1px]", className)}
{...props} />
))
Separator.displayName = SeparatorPrimitive.Root.displayName
export { Separator }

109
components/ui/sheet.tsx Normal file
View File

@@ -0,0 +1,109 @@
"use client"
import * as React from "react"
import * as SheetPrimitive from "@radix-ui/react-dialog"
import { cva, type VariantProps } from "class-variance-authority"
import { X } from "lucide-react"
import { cn } from "@/lib/utils"
const Sheet = SheetPrimitive.Root
const SheetTrigger = SheetPrimitive.Trigger
const SheetClose = SheetPrimitive.Close
const SheetPortal = SheetPrimitive.Portal
const SheetOverlay = React.forwardRef<
React.ElementRef<typeof SheetPrimitive.Overlay>,
React.ComponentPropsWithoutRef<typeof SheetPrimitive.Overlay>
>(({ className, ...props }, ref) => (
<SheetPrimitive.Overlay
className={cn(
"fixed inset-0 z-50 bg-black/80 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0",
className,
)}
{...props}
ref={ref}
/>
))
SheetOverlay.displayName = SheetPrimitive.Overlay.displayName
const sheetVariants = cva(
"fixed z-50 gap-4 bg-background p-6 shadow-lg transition ease-in-out data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:duration-300 data-[state=open]:duration-500",
{
variants: {
side: {
top: "inset-x-0 top-0 border-b data-[state=closed]:slide-out-to-top data-[state=open]:slide-in-from-top",
bottom:
"inset-x-0 bottom-0 border-t data-[state=closed]:slide-out-to-bottom data-[state=open]:slide-in-from-bottom",
left: "inset-y-0 left-0 h-full w-3/4 border-r data-[state=closed]:slide-out-to-left data-[state=open]:slide-in-from-left sm:max-w-sm",
right:
"inset-y-0 right-0 h-full w-3/4 border-l data-[state=closed]:slide-out-to-right data-[state=open]:slide-in-from-right sm:max-w-sm",
},
},
defaultVariants: {
side: "right",
},
},
)
interface SheetContentProps
extends React.ComponentPropsWithoutRef<typeof SheetPrimitive.Content>,
VariantProps<typeof sheetVariants> {}
const SheetContent = React.forwardRef<React.ElementRef<typeof SheetPrimitive.Content>, SheetContentProps>(
({ side = "right", className, children, ...props }, ref) => (
<SheetPortal>
<SheetOverlay />
<SheetPrimitive.Content ref={ref} className={cn(sheetVariants({ side }), className)} {...props}>
{children}
<SheetPrimitive.Close className="absolute right-4 top-4 rounded-sm opacity-70 ring-offset-background transition-opacity hover:opacity-100 focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2 disabled:pointer-events-none data-[state=open]:bg-secondary">
<X className="h-4 w-4" />
<span className="sr-only">Close</span>
</SheetPrimitive.Close>
</SheetPrimitive.Content>
</SheetPortal>
),
)
SheetContent.displayName = SheetPrimitive.Content.displayName
const SheetHeader = ({ className, ...props }: React.HTMLAttributes<HTMLDivElement>) => (
<div className={cn("flex flex-col space-y-2 text-center sm:text-left", className)} {...props} />
)
SheetHeader.displayName = "SheetHeader"
const SheetFooter = ({ className, ...props }: React.HTMLAttributes<HTMLDivElement>) => (
<div className={cn("flex flex-col-reverse sm:flex-row sm:justify-end sm:space-x-2", className)} {...props} />
)
SheetFooter.displayName = "SheetFooter"
const SheetTitle = React.forwardRef<
React.ElementRef<typeof SheetPrimitive.Title>,
React.ComponentPropsWithoutRef<typeof SheetPrimitive.Title>
>(({ className, ...props }, ref) => (
<SheetPrimitive.Title ref={ref} className={cn("text-lg font-semibold text-foreground", className)} {...props} />
))
SheetTitle.displayName = SheetPrimitive.Title.displayName
const SheetDescription = React.forwardRef<
React.ElementRef<typeof SheetPrimitive.Description>,
React.ComponentPropsWithoutRef<typeof SheetPrimitive.Description>
>(({ className, ...props }, ref) => (
<SheetPrimitive.Description ref={ref} className={cn("text-sm text-muted-foreground", className)} {...props} />
))
SheetDescription.displayName = SheetPrimitive.Description.displayName
export {
Sheet,
SheetPortal,
SheetOverlay,
SheetTrigger,
SheetClose,
SheetContent,
SheetHeader,
SheetFooter,
SheetTitle,
SheetDescription,
}

View File

@@ -0,0 +1,7 @@
import { cn } from "@/lib/utils"
function Skeleton({ className, ...props }: React.HTMLAttributes<HTMLDivElement>) {
return <div className={cn("animate-pulse rounded-md bg-muted", className)} {...props} />
}
export { Skeleton }

28
components/ui/switch.tsx Normal file
View File

@@ -0,0 +1,28 @@
"use client"
import * as React from "react"
import * as SwitchPrimitives from "@radix-ui/react-switch"
import { cn } from "@/lib/utils"
const Switch = React.forwardRef<
React.ElementRef<typeof SwitchPrimitives.Root>,
React.ComponentPropsWithoutRef<typeof SwitchPrimitives.Root>
>(({ className, ...props }, ref) => (
<SwitchPrimitives.Root
className={cn(
"peer inline-flex h-6 w-11 shrink-0 cursor-pointer items-center rounded-full border-2 border-transparent transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:ring-offset-background disabled:cursor-not-allowed disabled:opacity-50 data-[state=checked]:bg-primary data-[state=unchecked]:bg-input",
className
)}
{...props}
ref={ref}
>
<SwitchPrimitives.Thumb
className={cn(
"pointer-events-none block h-5 w-5 rounded-full bg-background shadow-lg ring-0 transition-transform data-[state=checked]:translate-x-5 data-[state=unchecked]:translate-x-0"
)}
/>
</SwitchPrimitives.Root>
))
Switch.displayName = SwitchPrimitives.Root.displayName
export { Switch }

117
components/ui/table.tsx Normal file
View File

@@ -0,0 +1,117 @@
import * as React from "react"
import { cn } from "@/lib/utils"
const Table = React.forwardRef<
HTMLTableElement,
React.HTMLAttributes<HTMLTableElement>
>(({ className, ...props }, ref) => (
<div className="relative w-full overflow-auto">
<table
ref={ref}
className={cn("w-full caption-bottom text-sm", className)}
{...props}
/>
</div>
))
Table.displayName = "Table"
const TableHeader = React.forwardRef<
HTMLTableSectionElement,
React.HTMLAttributes<HTMLTableSectionElement>
>(({ className, ...props }, ref) => (
<thead ref={ref} className={cn("[&_tr]:border-b", className)} {...props} />
))
TableHeader.displayName = "TableHeader"
const TableBody = React.forwardRef<
HTMLTableSectionElement,
React.HTMLAttributes<HTMLTableSectionElement>
>(({ className, ...props }, ref) => (
<tbody
ref={ref}
className={cn("[&_tr:last-child]:border-0", className)}
{...props}
/>
))
TableBody.displayName = "TableBody"
const TableFooter = React.forwardRef<
HTMLTableSectionElement,
React.HTMLAttributes<HTMLTableSectionElement>
>(({ className, ...props }, ref) => (
<tfoot
ref={ref}
className={cn(
"border-t bg-muted/50 font-medium [&>tr]:last:border-b-0",
className
)}
{...props}
/>
))
TableFooter.displayName = "TableFooter"
const TableRow = React.forwardRef<
HTMLTableRowElement,
React.HTMLAttributes<HTMLTableRowElement>
>(({ className, ...props }, ref) => (
<tr
ref={ref}
className={cn(
"border-b transition-colors hover:bg-muted/50 data-[state=selected]:bg-muted",
className
)}
{...props}
/>
))
TableRow.displayName = "TableRow"
const TableHead = React.forwardRef<
HTMLTableCellElement,
React.ThHTMLAttributes<HTMLTableCellElement>
>(({ className, ...props }, ref) => (
<th
ref={ref}
className={cn(
"h-12 px-4 text-left align-middle font-medium text-muted-foreground [&:has([role=checkbox])]:pr-0",
className
)}
{...props}
/>
))
TableHead.displayName = "TableHead"
const TableCell = React.forwardRef<
HTMLTableCellElement,
React.TdHTMLAttributes<HTMLTableCellElement>
>(({ className, ...props }, ref) => (
<td
ref={ref}
className={cn("p-4 align-middle [&:has([role=checkbox])]:pr-0", className)}
{...props}
/>
))
TableCell.displayName = "TableCell"
const TableCaption = React.forwardRef<
HTMLTableCaptionElement,
React.HTMLAttributes<HTMLTableCaptionElement>
>(({ className, ...props }, ref) => (
<caption
ref={ref}
className={cn("mt-4 text-sm text-muted-foreground", className)}
{...props}
/>
))
TableCaption.displayName = "TableCaption"
export {
Table,
TableHeader,
TableBody,
TableFooter,
TableHead,
TableRow,
TableCell,
TableCaption,
}

55
components/ui/tabs.tsx Normal file
View File

@@ -0,0 +1,55 @@
"use client"
import * as React from "react"
import * as TabsPrimitive from "@radix-ui/react-tabs"
import { cn } from "@/lib/utils"
const Tabs = TabsPrimitive.Root
const TabsList = React.forwardRef<
React.ElementRef<typeof TabsPrimitive.List>,
React.ComponentPropsWithoutRef<typeof TabsPrimitive.List>
>(({ className, ...props }, ref) => (
<TabsPrimitive.List
ref={ref}
className={cn(
"inline-flex h-10 items-center justify-center rounded-md bg-muted p-1 text-muted-foreground",
className
)}
{...props}
/>
))
TabsList.displayName = TabsPrimitive.List.displayName
const TabsTrigger = React.forwardRef<
React.ElementRef<typeof TabsPrimitive.Trigger>,
React.ComponentPropsWithoutRef<typeof TabsPrimitive.Trigger>
>(({ className, ...props }, ref) => (
<TabsPrimitive.Trigger
ref={ref}
className={cn(
"inline-flex items-center justify-center whitespace-nowrap rounded-sm px-3 py-1.5 text-sm font-medium ring-offset-background transition-all focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50 data-[state=active]:bg-background data-[state=active]:text-foreground data-[state=active]:shadow-sm",
className
)}
{...props}
/>
))
TabsTrigger.displayName = TabsPrimitive.Trigger.displayName
const TabsContent = React.forwardRef<
React.ElementRef<typeof TabsPrimitive.Content>,
React.ComponentPropsWithoutRef<typeof TabsPrimitive.Content>
>(({ className, ...props }, ref) => (
<TabsPrimitive.Content
ref={ref}
className={cn(
"mt-2 ring-offset-background focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2",
className
)}
{...props}
/>
))
TabsContent.displayName = TabsPrimitive.Content.displayName
export { Tabs, TabsList, TabsTrigger, TabsContent }

View File

@@ -0,0 +1,22 @@
import * as React from "react"
import { cn } from "@/lib/utils"
const Textarea = React.forwardRef<
HTMLTextAreaElement,
React.ComponentProps<"textarea">
>(({ className, ...props }, ref) => {
return (
<textarea
className={cn(
"flex min-h-[80px] w-full rounded-md border border-input bg-background px-3 py-2 text-base ring-offset-background placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50 md:text-sm",
className
)}
ref={ref}
{...props}
/>
)
})
Textarea.displayName = "Textarea"
export { Textarea }

113
components/ui/toast.tsx Normal file
View File

@@ -0,0 +1,113 @@
"use client"
import * as React from "react"
import * as ToastPrimitives from "@radix-ui/react-toast"
import { cva, type VariantProps } from "class-variance-authority"
import { X } from "lucide-react"
import { cn } from "@/lib/utils"
const ToastProvider = ToastPrimitives.Provider
const ToastViewport = React.forwardRef<
React.ElementRef<typeof ToastPrimitives.Viewport>,
React.ComponentPropsWithoutRef<typeof ToastPrimitives.Viewport>
>(({ className, ...props }, ref) => (
<ToastPrimitives.Viewport
ref={ref}
className={cn(
"fixed top-0 z-[100] flex max-h-screen w-full flex-col-reverse p-4 sm:bottom-0 sm:right-0 sm:top-auto sm:flex-col md:max-w-[420px]",
className,
)}
{...props}
/>
))
ToastViewport.displayName = ToastPrimitives.Viewport.displayName
const toastVariants = cva(
"group pointer-events-auto relative flex w-full items-center justify-between space-x-4 overflow-hidden rounded-md border p-6 pr-8 shadow-lg transition-all data-[swipe=cancel]:translate-x-0 data-[swipe=end]:translate-x-[var(--radix-toast-swipe-end-x)] data-[swipe=move]:translate-x-[var(--radix-toast-swipe-move-x)] data-[swipe=move]:transition-none data-[state=open]:animate-in data-[state=closed]:animate-out data-[swipe=end]:animate-out data-[state=closed]:fade-out-80 data-[state=closed]:slide-out-to-right-full data-[state=open]:slide-in-from-top-full data-[state=open]:sm:slide-in-from-bottom-full",
{
variants: {
variant: {
default: "border bg-background text-foreground",
destructive: "destructive group border-destructive bg-destructive text-destructive-foreground",
},
},
defaultVariants: {
variant: "default",
},
},
)
const Toast = React.forwardRef<
React.ElementRef<typeof ToastPrimitives.Root>,
React.ComponentPropsWithoutRef<typeof ToastPrimitives.Root> & VariantProps<typeof toastVariants>
>(({ className, variant, ...props }, ref) => {
return <ToastPrimitives.Root ref={ref} className={cn(toastVariants({ variant }), className)} {...props} />
})
Toast.displayName = ToastPrimitives.Root.displayName
const ToastAction = React.forwardRef<
React.ElementRef<typeof ToastPrimitives.Action>,
React.ComponentPropsWithoutRef<typeof ToastPrimitives.Action>
>(({ className, ...props }, ref) => (
<ToastPrimitives.Action
ref={ref}
className={cn(
"inline-flex h-8 shrink-0 items-center justify-center rounded-md border bg-transparent px-3 text-sm font-medium ring-offset-background transition-colors hover:bg-secondary focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2 disabled:pointer-events-none disabled:opacity-50 group-[.destructive]:border-muted/40 group-[.destructive]:hover:border-destructive/30 group-[.destructive]:hover:bg-destructive group-[.destructive]:hover:text-destructive-foreground group-[.destructive]:focus:ring-destructive",
className,
)}
{...props}
/>
))
ToastAction.displayName = ToastPrimitives.Action.displayName
const ToastClose = React.forwardRef<
React.ElementRef<typeof ToastPrimitives.Close>,
React.ComponentPropsWithoutRef<typeof ToastPrimitives.Close>
>(({ className, ...props }, ref) => (
<ToastPrimitives.Close
ref={ref}
className={cn(
"absolute right-2 top-2 rounded-md p-1 text-foreground/50 opacity-0 transition-opacity hover:text-foreground focus:opacity-100 focus:outline-none focus:ring-2 group-hover:opacity-100 group-[.destructive]:text-red-300 group-[.destructive]:hover:text-red-50 group-[.destructive]:focus:ring-red-400 group-[.destructive]:focus:ring-offset-red-600",
className,
)}
toast-close=""
{...props}
>
<X className="h-4 w-4" />
</ToastPrimitives.Close>
))
ToastClose.displayName = ToastPrimitives.Close.displayName
const ToastTitle = React.forwardRef<
React.ElementRef<typeof ToastPrimitives.Title>,
React.ComponentPropsWithoutRef<typeof ToastPrimitives.Title>
>(({ className, ...props }, ref) => (
<ToastPrimitives.Title ref={ref} className={cn("text-sm font-semibold", className)} {...props} />
))
ToastTitle.displayName = ToastPrimitives.Title.displayName
const ToastDescription = React.forwardRef<
React.ElementRef<typeof ToastPrimitives.Description>,
React.ComponentPropsWithoutRef<typeof ToastPrimitives.Description>
>(({ className, ...props }, ref) => (
<ToastPrimitives.Description ref={ref} className={cn("text-sm opacity-90", className)} {...props} />
))
ToastDescription.displayName = ToastPrimitives.Description.displayName
type ToastProps = React.ComponentPropsWithoutRef<typeof Toast>
type ToastActionElement = React.ReactElement<typeof ToastAction>
export {
type ToastProps,
type ToastActionElement,
ToastProvider,
ToastViewport,
Toast,
ToastTitle,
ToastDescription,
ToastClose,
ToastAction,
}

24
components/ui/toaster.tsx Normal file
View File

@@ -0,0 +1,24 @@
"use client"
import { Toast, ToastClose, ToastDescription, ToastProvider, ToastTitle, ToastViewport } from "@/components/ui/toast"
import { useToast } from "@/hooks/use-toast"
export function Toaster() {
const { toasts } = useToast()
return (
<ToastProvider>
{toasts.map(({ id, title, description, action, ...props }) => (
<Toast key={id} {...props}>
<div className="grid gap-1">
{title && <ToastTitle>{title}</ToastTitle>}
{description && <ToastDescription>{description}</ToastDescription>}
</div>
{action}
<ToastClose />
</Toast>
))}
<ToastViewport />
</ToastProvider>
)
}

194
components/ui/use-toast.ts Normal file
View File

@@ -0,0 +1,194 @@
"use client"
// Inspired by react-hot-toast library
import * as React from "react"
import type {
ToastActionElement,
ToastProps,
} from "@/components/ui/toast"
const TOAST_LIMIT = 1
const TOAST_REMOVE_DELAY = 1000000
type ToasterToast = ToastProps & {
id: string
title?: React.ReactNode
description?: React.ReactNode
action?: ToastActionElement
}
const actionTypes = {
ADD_TOAST: "ADD_TOAST",
UPDATE_TOAST: "UPDATE_TOAST",
DISMISS_TOAST: "DISMISS_TOAST",
REMOVE_TOAST: "REMOVE_TOAST",
} as const
let count = 0
function genId() {
count = (count + 1) % Number.MAX_SAFE_INTEGER
return count.toString()
}
type ActionType = typeof actionTypes
type Action =
| {
type: ActionType["ADD_TOAST"]
toast: ToasterToast
}
| {
type: ActionType["UPDATE_TOAST"]
toast: Partial<ToasterToast>
}
| {
type: ActionType["DISMISS_TOAST"]
toastId?: ToasterToast["id"]
}
| {
type: ActionType["REMOVE_TOAST"]
toastId?: ToasterToast["id"]
}
interface State {
toasts: ToasterToast[]
}
const toastTimeouts = new Map<string, ReturnType<typeof setTimeout>>()
const addToRemoveQueue = (toastId: string) => {
if (toastTimeouts.has(toastId)) {
return
}
const timeout = setTimeout(() => {
toastTimeouts.delete(toastId)
dispatch({
type: "REMOVE_TOAST",
toastId: toastId,
})
}, TOAST_REMOVE_DELAY)
toastTimeouts.set(toastId, timeout)
}
export const reducer = (state: State, action: Action): State => {
switch (action.type) {
case "ADD_TOAST":
return {
...state,
toasts: [action.toast, ...state.toasts].slice(0, TOAST_LIMIT),
}
case "UPDATE_TOAST":
return {
...state,
toasts: state.toasts.map((t) =>
t.id === action.toast.id ? { ...t, ...action.toast } : t
),
}
case "DISMISS_TOAST": {
const { toastId } = action
// ! Side effects ! - This could be extracted into a dismissToast() action,
// but I'll keep it here for simplicity
if (toastId) {
addToRemoveQueue(toastId)
} else {
state.toasts.forEach((toast) => {
addToRemoveQueue(toast.id)
})
}
return {
...state,
toasts: state.toasts.map((t) =>
t.id === toastId || toastId === undefined
? {
...t,
open: false,
}
: t
),
}
}
case "REMOVE_TOAST":
if (action.toastId === undefined) {
return {
...state,
toasts: [],
}
}
return {
...state,
toasts: state.toasts.filter((t) => t.id !== action.toastId),
}
}
}
const listeners: Array<(state: State) => void> = []
let memoryState: State = { toasts: [] }
function dispatch(action: Action) {
memoryState = reducer(memoryState, action)
listeners.forEach((listener) => {
listener(memoryState)
})
}
type Toast = Omit<ToasterToast, "id">
function toast({ ...props }: Toast) {
const id = genId()
const update = (props: ToasterToast) =>
dispatch({
type: "UPDATE_TOAST",
toast: { ...props, id },
})
const dismiss = () => dispatch({ type: "DISMISS_TOAST", toastId: id })
dispatch({
type: "ADD_TOAST",
toast: {
...props,
id,
open: true,
onOpenChange: (open) => {
if (!open) dismiss()
},
},
})
return {
id: id,
dismiss,
update,
}
}
function useToast() {
const [state, setState] = React.useState<State>(memoryState)
React.useEffect(() => {
listeners.push(setState)
return () => {
const index = listeners.indexOf(setState)
if (index > -1) {
listeners.splice(index, 1)
}
}
}, [state])
return {
...state,
toast,
dismiss: (toastId?: string) => dispatch({ type: "DISMISS_TOAST", toastId }),
}
}
export { useToast, toast }