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:
133
components/agendamentos-table.tsx
Normal file
133
components/agendamentos-table.tsx
Normal file
@@ -0,0 +1,133 @@
|
||||
"use client"
|
||||
|
||||
import { useState } from "react"
|
||||
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table"
|
||||
import { Button } from "@/components/ui/button"
|
||||
import { useToast } from "@/components/ui/use-toast"
|
||||
import { Calendar, CheckCircle2, XCircle, Clock } from "lucide-react"
|
||||
|
||||
const statusConfig: Record<string, { label: string; color: string; icon: React.ReactNode }> = {
|
||||
pendente: { label: "Pendente", color: "bg-yellow-100 text-yellow-800", icon: <Clock className="h-3 w-3" /> },
|
||||
confirmado: { label: "Confirmado", color: "bg-blue-100 text-blue-800", icon: <Calendar className="h-3 w-3" /> },
|
||||
realizado: { label: "Realizado", color: "bg-green-100 text-green-800", icon: <CheckCircle2 className="h-3 w-3" /> },
|
||||
cancelado: { label: "Cancelado", color: "bg-red-100 text-red-800", icon: <XCircle className="h-3 w-3" /> },
|
||||
}
|
||||
|
||||
type Agendamento = {
|
||||
id: string
|
||||
data_hora: string
|
||||
status: string
|
||||
observacoes: string | null
|
||||
created_at: string
|
||||
clientes: { nome: string; email: string } | null
|
||||
produtos: { nome: string } | null
|
||||
}
|
||||
|
||||
export function AgendamentosTable({ agendamentos: inicial }: { agendamentos: Agendamento[] }) {
|
||||
const { toast } = useToast()
|
||||
const [itens, setItens] = useState(inicial)
|
||||
const [atualizando, setAtualizando] = useState<string | null>(null)
|
||||
|
||||
async function atualizarStatus(id: string, novoStatus: string) {
|
||||
setAtualizando(id)
|
||||
try {
|
||||
const res = await fetch(`/api/agendamento/${id}/status`, {
|
||||
method: "PATCH",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ status: novoStatus }),
|
||||
})
|
||||
if (!res.ok) throw new Error("Erro ao atualizar")
|
||||
setItens((prev) =>
|
||||
prev.map((a) => (a.id === id ? { ...a, status: novoStatus } : a))
|
||||
)
|
||||
toast({ title: `Status atualizado para "${novoStatus}"` })
|
||||
} catch {
|
||||
toast({ title: "Erro ao atualizar status", variant: "destructive" })
|
||||
} finally {
|
||||
setAtualizando(null)
|
||||
}
|
||||
}
|
||||
|
||||
if (itens.length === 0) {
|
||||
return (
|
||||
<div className="rounded-md border p-8 text-center text-muted-foreground">
|
||||
Nenhum agendamento encontrado.
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="rounded-md border">
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead>Cliente</TableHead>
|
||||
<TableHead>Produto</TableHead>
|
||||
<TableHead>Data / Hora</TableHead>
|
||||
<TableHead>Status</TableHead>
|
||||
<TableHead>Observações</TableHead>
|
||||
<TableHead>Ações</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{itens.map((a) => {
|
||||
const cfg = statusConfig[a.status] ?? { label: a.status, color: "bg-gray-100 text-gray-800", icon: null }
|
||||
const isLoading = atualizando === a.id
|
||||
return (
|
||||
<TableRow key={a.id}>
|
||||
<TableCell>
|
||||
<p className="font-medium text-sm">{a.clientes?.nome ?? "—"}</p>
|
||||
<p className="text-xs text-muted-foreground">{a.clientes?.email ?? ""}</p>
|
||||
</TableCell>
|
||||
<TableCell className="text-sm">{a.produtos?.nome ?? "—"}</TableCell>
|
||||
<TableCell className="text-sm whitespace-nowrap">
|
||||
{new Date(a.data_hora).toLocaleString("pt-BR", {
|
||||
day: "2-digit", month: "2-digit", year: "numeric",
|
||||
hour: "2-digit", minute: "2-digit",
|
||||
})}
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<span className={`inline-flex items-center gap-1 px-2 py-0.5 rounded-full text-xs font-medium ${cfg.color}`}>
|
||||
{cfg.icon}
|
||||
{cfg.label}
|
||||
</span>
|
||||
</TableCell>
|
||||
<TableCell className="text-sm text-muted-foreground max-w-[180px] truncate">
|
||||
{a.observacoes ?? "—"}
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<div className="flex gap-1">
|
||||
{a.status !== "realizado" && (
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
className="h-7 text-xs"
|
||||
disabled={isLoading}
|
||||
onClick={() => atualizarStatus(a.id, "realizado")}
|
||||
>
|
||||
<CheckCircle2 className="mr-1 h-3 w-3" />
|
||||
Realizado
|
||||
</Button>
|
||||
)}
|
||||
{a.status !== "cancelado" && a.status !== "realizado" && (
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
className="h-7 text-xs text-red-600 hover:text-red-700"
|
||||
disabled={isLoading}
|
||||
onClick={() => atualizarStatus(a.id, "cancelado")}
|
||||
>
|
||||
<XCircle className="mr-1 h-3 w-3" />
|
||||
Cancelar
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
)
|
||||
})}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
25
components/auth-guard.tsx
Normal file
25
components/auth-guard.tsx
Normal file
@@ -0,0 +1,25 @@
|
||||
"use client"
|
||||
|
||||
import { useEffect } from "react"
|
||||
import { useRouter } from "next/navigation"
|
||||
import { useToast } from "@/components/ui/use-toast"
|
||||
import type React from "react" // Added import for React
|
||||
|
||||
export function AuthGuard({ children }: { children: React.ReactNode }) {
|
||||
const router = useRouter()
|
||||
const { toast } = useToast()
|
||||
|
||||
useEffect(() => {
|
||||
const isLoggedIn = localStorage.getItem("isLoggedIn")
|
||||
if (!isLoggedIn) {
|
||||
toast({
|
||||
title: "Acesso negado",
|
||||
description: "Faça login para acessar a área administrativa.",
|
||||
variant: "destructive",
|
||||
})
|
||||
router.push("/login")
|
||||
}
|
||||
}, [router, toast])
|
||||
|
||||
return <>{children}</>
|
||||
}
|
||||
239
components/cupom-table.tsx
Normal file
239
components/cupom-table.tsx
Normal file
@@ -0,0 +1,239 @@
|
||||
"use client"
|
||||
|
||||
import { useState } from "react"
|
||||
import { Button } from "@/components/ui/button"
|
||||
import { Input } from "@/components/ui/input"
|
||||
import { Label } from "@/components/ui/label"
|
||||
import { Badge } from "@/components/ui/badge"
|
||||
import { Switch } from "@/components/ui/switch"
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
DialogTrigger,
|
||||
} from "@/components/ui/dialog"
|
||||
import { useToast } from "@/components/ui/use-toast"
|
||||
import { criarCupom, atualizarCupom, deletarCupom } from "@/lib/actions"
|
||||
import type { Cupom } from "@/lib/supabase"
|
||||
import { Plus, Trash2, Star, Tag } from "lucide-react"
|
||||
import { useRouter } from "next/navigation"
|
||||
|
||||
interface CupomTableProps {
|
||||
cupons: Cupom[]
|
||||
}
|
||||
|
||||
function NovoCupomDialog() {
|
||||
const [open, setOpen] = useState(false)
|
||||
const [loading, setLoading] = useState(false)
|
||||
const { toast } = useToast()
|
||||
const router = useRouter()
|
||||
const [form, setForm] = useState({
|
||||
codigo: "",
|
||||
descricao: "",
|
||||
percentual: "15",
|
||||
validade: "",
|
||||
destaque: false,
|
||||
})
|
||||
|
||||
const handleSubmit = async (e: React.FormEvent) => {
|
||||
e.preventDefault()
|
||||
setLoading(true)
|
||||
try {
|
||||
await criarCupom({
|
||||
codigo: form.codigo,
|
||||
descricao: form.descricao,
|
||||
percentual: Number(form.percentual),
|
||||
validade: form.validade,
|
||||
destaque: form.destaque,
|
||||
})
|
||||
toast({ title: "Cupom criado!" })
|
||||
setOpen(false)
|
||||
setForm({ codigo: "", descricao: "", percentual: "15", validade: "", destaque: false })
|
||||
router.refresh()
|
||||
} catch (err) {
|
||||
toast({ title: "Erro", description: err instanceof Error ? err.message : "Erro ao criar cupom", variant: "destructive" })
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={setOpen}>
|
||||
<DialogTrigger asChild>
|
||||
<Button className="gap-2"><Plus className="w-4 h-4" /> Novo Cupom</Button>
|
||||
</DialogTrigger>
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
<DialogTitle>Criar cupom de desconto</DialogTitle>
|
||||
</DialogHeader>
|
||||
<form onSubmit={handleSubmit} className="space-y-4 mt-2">
|
||||
<div className="space-y-1">
|
||||
<Label>Código</Label>
|
||||
<Input
|
||||
required
|
||||
placeholder="VIXCERT20"
|
||||
value={form.codigo}
|
||||
onChange={(e) => setForm({ ...form, codigo: e.target.value.toUpperCase() })}
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
<Label>Descrição</Label>
|
||||
<Input
|
||||
required
|
||||
placeholder="Ex: Renovação de Certificado"
|
||||
value={form.descricao}
|
||||
onChange={(e) => setForm({ ...form, descricao: e.target.value })}
|
||||
/>
|
||||
</div>
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div className="space-y-1">
|
||||
<Label>Desconto (%)</Label>
|
||||
<Input
|
||||
required
|
||||
type="number"
|
||||
min="1"
|
||||
max="100"
|
||||
value={form.percentual}
|
||||
onChange={(e) => setForm({ ...form, percentual: e.target.value })}
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
<Label>Válido até</Label>
|
||||
<Input
|
||||
required
|
||||
type="date"
|
||||
value={form.validade}
|
||||
onChange={(e) => setForm({ ...form, validade: e.target.value })}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-3">
|
||||
<Switch
|
||||
id="destaque"
|
||||
checked={form.destaque}
|
||||
onCheckedChange={(v) => setForm({ ...form, destaque: v })}
|
||||
/>
|
||||
<Label htmlFor="destaque" className="cursor-pointer flex items-center gap-1">
|
||||
<Star className="w-4 h-4 text-yellow-500" /> Exibir em destaque no site
|
||||
</Label>
|
||||
</div>
|
||||
<Button type="submit" className="w-full" disabled={loading}>
|
||||
{loading ? "Criando..." : "Criar cupom"}
|
||||
</Button>
|
||||
</form>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
)
|
||||
}
|
||||
|
||||
export function CupomTable({ cupons }: CupomTableProps) {
|
||||
const { toast } = useToast()
|
||||
const router = useRouter()
|
||||
|
||||
const handleToggleAtivo = async (cupom: Cupom) => {
|
||||
try {
|
||||
await atualizarCupom(cupom.id, { ativo: !cupom.ativo })
|
||||
router.refresh()
|
||||
} catch {
|
||||
toast({ title: "Erro ao atualizar", variant: "destructive" })
|
||||
}
|
||||
}
|
||||
|
||||
const handleToggleDestaque = async (cupom: Cupom) => {
|
||||
try {
|
||||
await atualizarCupom(cupom.id, { destaque: !cupom.destaque })
|
||||
router.refresh()
|
||||
} catch {
|
||||
toast({ title: "Erro ao atualizar", variant: "destructive" })
|
||||
}
|
||||
}
|
||||
|
||||
const handleDeletar = async (id: string) => {
|
||||
if (!confirm("Deletar este cupom?")) return
|
||||
try {
|
||||
await deletarCupom(id)
|
||||
toast({ title: "Cupom deletado" })
|
||||
router.refresh()
|
||||
} catch {
|
||||
toast({ title: "Erro ao deletar", variant: "destructive" })
|
||||
}
|
||||
}
|
||||
|
||||
const hoje = new Date().toISOString().slice(0, 10)
|
||||
|
||||
return (
|
||||
<Card>
|
||||
<CardHeader className="flex flex-row items-center justify-between">
|
||||
<CardTitle className="flex items-center gap-2">
|
||||
<Tag className="w-5 h-5" /> Cupons de Desconto
|
||||
</CardTitle>
|
||||
<NovoCupomDialog />
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{cupons.length === 0 ? (
|
||||
<p className="text-center text-gray-500 py-8">Nenhum cupom cadastrado.</p>
|
||||
) : (
|
||||
<div className="space-y-3">
|
||||
{cupons.map((cupom) => {
|
||||
const vencido = cupom.validade < hoje
|
||||
const validadeFormatada = new Date(cupom.validade + "T12:00:00").toLocaleDateString("pt-BR")
|
||||
return (
|
||||
<div
|
||||
key={cupom.id}
|
||||
className={`flex items-center justify-between p-4 rounded-lg border ${
|
||||
cupom.destaque ? "border-secondary/50 bg-secondary/5" : "border-gray-200"
|
||||
}`}
|
||||
>
|
||||
<div className="flex items-center gap-4">
|
||||
<div>
|
||||
<div className="flex items-center gap-2 mb-1">
|
||||
<span className="font-mono font-bold text-gray-800">{cupom.codigo}</span>
|
||||
<Badge variant="secondary">{cupom.percentual}% OFF</Badge>
|
||||
{cupom.destaque && (
|
||||
<Badge className="bg-yellow-100 text-yellow-800 border-yellow-300">
|
||||
<Star className="w-3 h-3 mr-1" /> Destaque
|
||||
</Badge>
|
||||
)}
|
||||
{vencido && <Badge variant="destructive">Vencido</Badge>}
|
||||
{!cupom.ativo && <Badge variant="outline">Inativo</Badge>}
|
||||
</div>
|
||||
<div className="text-sm text-gray-500">{cupom.descricao} · Até {validadeFormatada}</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="flex items-center gap-1 text-xs text-gray-500">
|
||||
<Switch
|
||||
checked={cupom.destaque}
|
||||
onCheckedChange={() => handleToggleDestaque(cupom)}
|
||||
title="Exibir em destaque"
|
||||
/>
|
||||
<span>Destaque</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-1 text-xs text-gray-500">
|
||||
<Switch
|
||||
checked={cupom.ativo}
|
||||
onCheckedChange={() => handleToggleAtivo(cupom)}
|
||||
title="Ativo/Inativo"
|
||||
/>
|
||||
<span>Ativo</span>
|
||||
</div>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="text-red-500 hover:text-red-700"
|
||||
onClick={() => handleDeletar(cupom.id)}
|
||||
>
|
||||
<Trash2 className="w-4 h-4" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
)
|
||||
}
|
||||
86
components/customer-form-dialog.tsx
Normal file
86
components/customer-form-dialog.tsx
Normal file
@@ -0,0 +1,86 @@
|
||||
"use client"
|
||||
|
||||
import type React from "react"
|
||||
import { useState } from "react"
|
||||
import { useRouter } from "next/navigation"
|
||||
import { Button } from "@/components/ui/button"
|
||||
import { Input } from "@/components/ui/input"
|
||||
import { Label } from "@/components/ui/label"
|
||||
import { createCliente } from "@/lib/actions"
|
||||
import { toast } from "@/components/ui/use-toast"
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
DialogTrigger,
|
||||
DialogDescription,
|
||||
} from "@/components/ui/dialog"
|
||||
|
||||
export function CustomerFormDialog() {
|
||||
const [open, setOpen] = useState(false)
|
||||
const router = useRouter()
|
||||
const [formData, setFormData] = useState({
|
||||
nome: "",
|
||||
email: "",
|
||||
telefone: "",
|
||||
cpf_cnpj: "",
|
||||
})
|
||||
|
||||
const handleSubmit = async (e: React.FormEvent<HTMLFormElement>) => {
|
||||
e.preventDefault()
|
||||
try {
|
||||
await createCliente(formData)
|
||||
toast({ title: "Cliente criado", description: "Adicionado com sucesso." })
|
||||
setOpen(false)
|
||||
router.refresh()
|
||||
} catch (error) {
|
||||
toast({
|
||||
title: "Erro",
|
||||
description: error instanceof Error ? error.message : "Tente novamente.",
|
||||
variant: "destructive",
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
const handleChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const { name, value } = e.target
|
||||
setFormData((prev) => ({ ...prev, [name]: value }))
|
||||
}
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={setOpen}>
|
||||
<DialogTrigger asChild>
|
||||
<Button>Novo Cliente</Button>
|
||||
</DialogTrigger>
|
||||
<DialogContent className="sm:max-w-[425px]">
|
||||
<DialogHeader>
|
||||
<DialogTitle>Criar Novo Cliente</DialogTitle>
|
||||
<DialogDescription>Preencha os dados do novo cliente.</DialogDescription>
|
||||
</DialogHeader>
|
||||
<form onSubmit={handleSubmit} className="space-y-4">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="nome">Nome</Label>
|
||||
<Input id="nome" name="nome" value={formData.nome} onChange={handleChange} required />
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="email">E-mail</Label>
|
||||
<Input id="email" name="email" type="email" value={formData.email} onChange={handleChange} required />
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="cpf_cnpj">CPF / CNPJ</Label>
|
||||
<Input id="cpf_cnpj" name="cpf_cnpj" value={formData.cpf_cnpj} onChange={handleChange} />
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="telefone">Telefone</Label>
|
||||
<Input id="telefone" name="telefone" value={formData.telefone} onChange={handleChange} />
|
||||
</div>
|
||||
<div className="flex justify-end space-x-2">
|
||||
<Button type="button" variant="outline" onClick={() => setOpen(false)}>Cancelar</Button>
|
||||
<Button type="submit">Criar Cliente</Button>
|
||||
</div>
|
||||
</form>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
)
|
||||
}
|
||||
47
components/customer-table.tsx
Normal file
47
components/customer-table.tsx
Normal file
@@ -0,0 +1,47 @@
|
||||
"use client"
|
||||
|
||||
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table"
|
||||
import type { Cliente } from "@/lib/supabase"
|
||||
|
||||
interface CustomerTableProps {
|
||||
customers: Cliente[]
|
||||
}
|
||||
|
||||
export function CustomerTable({ customers }: CustomerTableProps) {
|
||||
if (customers.length === 0) {
|
||||
return (
|
||||
<div className="rounded-md border p-8 text-center text-muted-foreground">
|
||||
Nenhum cliente cadastrado.
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="rounded-md border">
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead>Nome</TableHead>
|
||||
<TableHead>E-mail</TableHead>
|
||||
<TableHead>CPF/CNPJ</TableHead>
|
||||
<TableHead>Telefone</TableHead>
|
||||
<TableHead>Cadastro</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{customers.map((c) => (
|
||||
<TableRow key={c.id}>
|
||||
<TableCell className="font-medium">{c.nome}</TableCell>
|
||||
<TableCell>{c.email}</TableCell>
|
||||
<TableCell>{c.cpf_cnpj ?? "—"}</TableCell>
|
||||
<TableCell>{c.telefone ?? "—"}</TableCell>
|
||||
<TableCell className="text-sm text-muted-foreground">
|
||||
{new Date(c.created_at).toLocaleDateString("pt-BR")}
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
23
components/dashboard-header.tsx
Normal file
23
components/dashboard-header.tsx
Normal file
@@ -0,0 +1,23 @@
|
||||
import type React from "react" // Added import for React
|
||||
|
||||
interface DashboardHeaderProps {
|
||||
heading: string
|
||||
text?: string
|
||||
children?: React.ReactNode
|
||||
}
|
||||
|
||||
export function DashboardHeader({ heading, text, children }: DashboardHeaderProps) {
|
||||
return (
|
||||
<div className="flex items-center justify-between px-2">
|
||||
<div className="grid gap-1">
|
||||
<h1 className="font-heading text-3xl font-bold tracking-tight">{heading}</h1>
|
||||
{text && <p className="font-sans text-lg text-muted-foreground">{text}</p>}
|
||||
<p className="text-sm text-muted-foreground mt-2 font-sans">
|
||||
Gerencie seus certificados digitais, acompanhe vendas e monitore o desempenho em tempo real. Todas as
|
||||
ferramentas que você precisa em um só lugar.
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">{children}</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
12
components/dashboard-shell.tsx
Normal file
12
components/dashboard-shell.tsx
Normal file
@@ -0,0 +1,12 @@
|
||||
import { cn } from "@/lib/utils"
|
||||
import type React from "react"
|
||||
|
||||
interface DashboardShellProps extends React.HTMLAttributes<HTMLDivElement> {}
|
||||
|
||||
export function DashboardShell({ children, className, ...props }: DashboardShellProps) {
|
||||
return (
|
||||
<div className={cn("container space-y-8 py-8", className)} {...props}>
|
||||
{children}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
63
components/dashboard-stats.tsx
Normal file
63
components/dashboard-stats.tsx
Normal file
@@ -0,0 +1,63 @@
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"
|
||||
import { DollarSign, Users, ShoppingCart, TrendingUp } from "lucide-react"
|
||||
|
||||
interface DashboardStatsProps {
|
||||
receitaTotal: number
|
||||
totalPedidosConfirmados: number
|
||||
ticketMedio: number
|
||||
pedidosPendentes: number
|
||||
}
|
||||
|
||||
export function DashboardStats({
|
||||
receitaTotal,
|
||||
totalPedidosConfirmados,
|
||||
ticketMedio,
|
||||
pedidosPendentes,
|
||||
}: DashboardStatsProps) {
|
||||
const fmt = (v: number) =>
|
||||
v.toLocaleString("pt-BR", { style: "currency", currency: "BRL" })
|
||||
|
||||
const stats = [
|
||||
{
|
||||
title: "Receita Total",
|
||||
value: fmt(receitaTotal),
|
||||
icon: DollarSign,
|
||||
description: "Pedidos pagos (todos os tempos)",
|
||||
},
|
||||
{
|
||||
title: "Vendas Confirmadas",
|
||||
value: totalPedidosConfirmados.toString(),
|
||||
icon: ShoppingCart,
|
||||
description: "Pedidos recebidos ou confirmados",
|
||||
},
|
||||
{
|
||||
title: "Ticket Médio",
|
||||
value: fmt(ticketMedio),
|
||||
icon: TrendingUp,
|
||||
description: "Por venda confirmada",
|
||||
},
|
||||
{
|
||||
title: "Pendentes",
|
||||
value: pedidosPendentes.toString(),
|
||||
icon: Users,
|
||||
description: "Aguardando pagamento",
|
||||
},
|
||||
]
|
||||
|
||||
return (
|
||||
<>
|
||||
{stats.map((stat, index) => (
|
||||
<Card key={index}>
|
||||
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
|
||||
<CardTitle className="font-heading text-sm font-medium">{stat.title}</CardTitle>
|
||||
<stat.icon className="h-4 w-4 text-muted-foreground" />
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="font-sans text-2xl font-bold">{stat.value}</div>
|
||||
<p className="font-sans text-xs text-muted-foreground">{stat.description}</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
))}
|
||||
</>
|
||||
)
|
||||
}
|
||||
65
components/data-table-column-header.tsx
Normal file
65
components/data-table-column-header.tsx
Normal file
@@ -0,0 +1,65 @@
|
||||
"use client"
|
||||
|
||||
import type React from "react"
|
||||
|
||||
import { ArrowDownIcon, ArrowUpIcon, CaretSortIcon, EyeNoneIcon } from "@radix-ui/react-icons"
|
||||
import type { Column } from "@tanstack/react-table"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
import { Button } from "@/components/ui/button"
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuSeparator,
|
||||
DropdownMenuTrigger,
|
||||
} from "@/components/ui/dropdown-menu"
|
||||
|
||||
interface DataTableColumnHeaderProps<TData, TValue> extends React.HTMLAttributes<HTMLDivElement> {
|
||||
column: Column<TData, TValue>
|
||||
title: string
|
||||
}
|
||||
|
||||
export function DataTableColumnHeader<TData, TValue>({
|
||||
column,
|
||||
title,
|
||||
className,
|
||||
}: DataTableColumnHeaderProps<TData, TValue>) {
|
||||
if (!column.getCanSort()) {
|
||||
return <div className={cn(className)}>{title}</div>
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={cn("flex items-center space-x-2", className)}>
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button variant="ghost" size="sm" className="-ml-3 h-8 data-[state=open]:bg-accent">
|
||||
<span>{title}</span>
|
||||
{column.getIsSorted() === "desc" ? (
|
||||
<ArrowDownIcon className="ml-2 h-4 w-4" />
|
||||
) : column.getIsSorted() === "asc" ? (
|
||||
<ArrowUpIcon className="ml-2 h-4 w-4" />
|
||||
) : (
|
||||
<CaretSortIcon className="ml-2 h-4 w-4" />
|
||||
)}
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="start">
|
||||
<DropdownMenuItem onClick={() => column.toggleSorting(false)}>
|
||||
<ArrowUpIcon className="mr-2 h-3.5 w-3.5 text-muted-foreground/70" />
|
||||
Crescente
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem onClick={() => column.toggleSorting(true)}>
|
||||
<ArrowDownIcon className="mr-2 h-3.5 w-3.5 text-muted-foreground/70" />
|
||||
Decrescente
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuSeparator />
|
||||
<DropdownMenuItem onClick={() => column.toggleVisibility(false)}>
|
||||
<EyeNoneIcon className="mr-2 h-3.5 w-3.5 text-muted-foreground/70" />
|
||||
Ocultar
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
129
components/data-table-faceted-filter.tsx
Normal file
129
components/data-table-faceted-filter.tsx
Normal file
@@ -0,0 +1,129 @@
|
||||
"use client"
|
||||
|
||||
import type * as React from "react"
|
||||
import { CheckIcon, PlusCircledIcon } from "@radix-ui/react-icons"
|
||||
import type { Column } from "@tanstack/react-table"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
import { Badge } from "@/components/ui/badge"
|
||||
import { Button } from "@/components/ui/button"
|
||||
import {
|
||||
Command,
|
||||
CommandEmpty,
|
||||
CommandGroup,
|
||||
CommandInput,
|
||||
CommandItem,
|
||||
CommandList,
|
||||
CommandSeparator,
|
||||
} from "@/components/ui/command"
|
||||
import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover"
|
||||
import { Separator } from "@/components/ui/separator"
|
||||
|
||||
interface DataTableFacetedFilterProps<TData, TValue> {
|
||||
column?: Column<TData, TValue>
|
||||
title?: string
|
||||
options: {
|
||||
label: string
|
||||
value: string
|
||||
icon?: React.ComponentType<{ className?: string }>
|
||||
}[]
|
||||
}
|
||||
|
||||
export function DataTableFacetedFilter<TData, TValue>({
|
||||
column,
|
||||
title,
|
||||
options,
|
||||
}: DataTableFacetedFilterProps<TData, TValue>) {
|
||||
const facets = column?.getFacetedUniqueValues()
|
||||
const selectedValues = new Set(column?.getFilterValue() as string[])
|
||||
|
||||
return (
|
||||
<Popover>
|
||||
<PopoverTrigger asChild>
|
||||
<Button variant="outline" size="sm" className="h-8 border-dashed">
|
||||
<PlusCircledIcon className="mr-2 h-4 w-4" />
|
||||
{title}
|
||||
{selectedValues?.size > 0 && (
|
||||
<>
|
||||
<Separator orientation="vertical" className="mx-2 h-4" />
|
||||
<Badge variant="secondary" className="rounded-sm px-1 font-normal lg:hidden">
|
||||
{selectedValues.size}
|
||||
</Badge>
|
||||
<div className="hidden space-x-1 lg:flex">
|
||||
{selectedValues.size > 2 ? (
|
||||
<Badge variant="secondary" className="rounded-sm px-1 font-normal">
|
||||
{selectedValues.size} selecionados
|
||||
</Badge>
|
||||
) : (
|
||||
options
|
||||
.filter((option) => selectedValues.has(option.value))
|
||||
.map((option) => (
|
||||
<Badge variant="secondary" key={option.value} className="rounded-sm px-1 font-normal">
|
||||
{option.label}
|
||||
</Badge>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</Button>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent className="w-[200px] p-0" align="start">
|
||||
<Command>
|
||||
<CommandInput placeholder={title} />
|
||||
<CommandList>
|
||||
<CommandEmpty>Nenhum resultado encontrado.</CommandEmpty>
|
||||
<CommandGroup>
|
||||
{options.map((option) => {
|
||||
const isSelected = selectedValues.has(option.value)
|
||||
return (
|
||||
<CommandItem
|
||||
key={option.value}
|
||||
onSelect={() => {
|
||||
if (isSelected) {
|
||||
selectedValues.delete(option.value)
|
||||
} else {
|
||||
selectedValues.add(option.value)
|
||||
}
|
||||
const filterValues = Array.from(selectedValues)
|
||||
column?.setFilterValue(filterValues.length ? filterValues : undefined)
|
||||
}}
|
||||
>
|
||||
<div
|
||||
className={cn(
|
||||
"mr-2 flex h-4 w-4 items-center justify-center rounded-sm border border-primary",
|
||||
isSelected ? "bg-primary text-primary-foreground" : "opacity-50 [&_svg]:invisible",
|
||||
)}
|
||||
>
|
||||
<CheckIcon className={cn("h-4 w-4")} />
|
||||
</div>
|
||||
{option.icon && <option.icon className="mr-2 h-4 w-4 text-muted-foreground" />}
|
||||
<span>{option.label}</span>
|
||||
{facets?.get(option.value) && (
|
||||
<span className="ml-auto flex h-4 w-4 items-center justify-center font-mono text-xs">
|
||||
{facets.get(option.value)}
|
||||
</span>
|
||||
)}
|
||||
</CommandItem>
|
||||
)
|
||||
})}
|
||||
</CommandGroup>
|
||||
{selectedValues.size > 0 && (
|
||||
<>
|
||||
<CommandSeparator />
|
||||
<CommandGroup>
|
||||
<CommandItem
|
||||
onSelect={() => column?.setFilterValue(undefined)}
|
||||
className="justify-center text-center"
|
||||
>
|
||||
Limpar filtros
|
||||
</CommandItem>
|
||||
</CommandGroup>
|
||||
</>
|
||||
)}
|
||||
</CommandList>
|
||||
</Command>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
)
|
||||
}
|
||||
85
components/data-table-pagination.tsx
Normal file
85
components/data-table-pagination.tsx
Normal file
@@ -0,0 +1,85 @@
|
||||
"use client"
|
||||
|
||||
import { ChevronLeftIcon, ChevronRightIcon, DoubleArrowLeftIcon, DoubleArrowRightIcon } from "@radix-ui/react-icons"
|
||||
import type { Table } from "@tanstack/react-table"
|
||||
|
||||
import { Button } from "@/components/ui/button"
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"
|
||||
|
||||
interface DataTablePaginationProps<TData> {
|
||||
table: Table<TData>
|
||||
}
|
||||
|
||||
export function DataTablePagination<TData>({ table }: DataTablePaginationProps<TData>) {
|
||||
return (
|
||||
<div className="flex items-center justify-between px-2">
|
||||
<div className="flex-1 text-sm text-muted-foreground">
|
||||
{table.getFilteredSelectedRowModel().rows.length} de {table.getFilteredRowModel().rows.length} linha(s)
|
||||
selecionada(s).
|
||||
</div>
|
||||
<div className="flex items-center space-x-6 lg:space-x-8">
|
||||
<div className="flex items-center space-x-2">
|
||||
<p className="text-sm font-medium">Linhas por página</p>
|
||||
<Select
|
||||
value={`${table.getState().pagination.pageSize}`}
|
||||
onValueChange={(value) => {
|
||||
table.setPageSize(Number(value))
|
||||
}}
|
||||
>
|
||||
<SelectTrigger className="h-8 w-[70px]">
|
||||
<SelectValue placeholder={table.getState().pagination.pageSize} />
|
||||
</SelectTrigger>
|
||||
<SelectContent side="top">
|
||||
{[10, 20, 30, 40, 50].map((pageSize) => (
|
||||
<SelectItem key={pageSize} value={`${pageSize}`}>
|
||||
{pageSize}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
<div className="flex w-[100px] items-center justify-center text-sm font-medium">
|
||||
Página {table.getState().pagination.pageIndex + 1} de {table.getPageCount()}
|
||||
</div>
|
||||
<div className="flex items-center space-x-2">
|
||||
<Button
|
||||
variant="outline"
|
||||
className="hidden h-8 w-8 p-0 lg:flex"
|
||||
onClick={() => table.setPageIndex(0)}
|
||||
disabled={!table.getCanPreviousPage()}
|
||||
>
|
||||
<span className="sr-only">Ir para primeira página</span>
|
||||
<DoubleArrowLeftIcon className="h-4 w-4" />
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
className="h-8 w-8 p-0"
|
||||
onClick={() => table.previousPage()}
|
||||
disabled={!table.getCanPreviousPage()}
|
||||
>
|
||||
<span className="sr-only">Ir para página anterior</span>
|
||||
<ChevronLeftIcon className="h-4 w-4" />
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
className="h-8 w-8 p-0"
|
||||
onClick={() => table.nextPage()}
|
||||
disabled={!table.getCanNextPage()}
|
||||
>
|
||||
<span className="sr-only">Ir para próxima página</span>
|
||||
<ChevronRightIcon className="h-4 w-4" />
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
className="hidden h-8 w-8 p-0 lg:flex"
|
||||
onClick={() => table.setPageIndex(table.getPageCount() - 1)}
|
||||
disabled={!table.getCanNextPage()}
|
||||
>
|
||||
<span className="sr-only">Ir para última página</span>
|
||||
<DoubleArrowRightIcon className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
45
components/data-table-row-actions.tsx
Normal file
45
components/data-table-row-actions.tsx
Normal file
@@ -0,0 +1,45 @@
|
||||
"use client"
|
||||
|
||||
import { DotsHorizontalIcon } from "@radix-ui/react-icons"
|
||||
import type { Row } from "@tanstack/react-table"
|
||||
|
||||
import { Button } from "@/components/ui/button"
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuSeparator,
|
||||
DropdownMenuTrigger,
|
||||
} from "@/components/ui/dropdown-menu"
|
||||
|
||||
import { productSchema } from "@/lib/schema"
|
||||
import type { z } from "zod"
|
||||
|
||||
type Product = z.infer<typeof productSchema>
|
||||
|
||||
interface DataTableRowActionsProps<TData> {
|
||||
row: Row<TData>
|
||||
}
|
||||
|
||||
export function DataTableRowActions<TData>({ row }: DataTableRowActionsProps<TData>) {
|
||||
const product = productSchema.parse(row.original)
|
||||
|
||||
return (
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button variant="ghost" className="flex h-8 w-8 p-0 data-[state=open]:bg-muted">
|
||||
<DotsHorizontalIcon className="h-4 w-4" />
|
||||
<span className="sr-only">Abrir menu</span>
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end" className="w-[160px]">
|
||||
<DropdownMenuItem>Editar</DropdownMenuItem>
|
||||
<DropdownMenuItem>Duplicar</DropdownMenuItem>
|
||||
<DropdownMenuSeparator />
|
||||
<DropdownMenuItem>Arquivar</DropdownMenuItem>
|
||||
<DropdownMenuSeparator />
|
||||
<DropdownMenuItem>Excluir</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
)
|
||||
}
|
||||
48
components/data-table-toolbar.tsx
Normal file
48
components/data-table-toolbar.tsx
Normal file
@@ -0,0 +1,48 @@
|
||||
"use client"
|
||||
|
||||
import { Cross2Icon } from "@radix-ui/react-icons"
|
||||
import type { Table } from "@tanstack/react-table"
|
||||
|
||||
import { Button } from "@/components/ui/button"
|
||||
import { Input } from "@/components/ui/input"
|
||||
import { DataTableViewOptions } from "@/components/data-table-view-options"
|
||||
import { DataTableFacetedFilter } from "@/components/data-table-faceted-filter"
|
||||
|
||||
interface DataTableToolbarProps<TData> {
|
||||
table: Table<TData>
|
||||
}
|
||||
|
||||
export function DataTableToolbar<TData>({ table }: DataTableToolbarProps<TData>) {
|
||||
const isFiltered = table.getState().columnFilters.length > 0
|
||||
|
||||
return (
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex flex-1 items-center space-x-2">
|
||||
<Input
|
||||
placeholder="Filtrar produtos..."
|
||||
value={(table.getColumn("name")?.getFilterValue() as string) ?? ""}
|
||||
onChange={(event) => table.getColumn("name")?.setFilterValue(event.target.value)}
|
||||
className="h-8 w-[150px] lg:w-[250px]"
|
||||
/>
|
||||
{table.getColumn("tipo") && (
|
||||
<DataTableFacetedFilter
|
||||
column={table.getColumn("tipo")}
|
||||
title="Tipo"
|
||||
options={[
|
||||
{ label: "e-CNPJ", value: "e-CNPJ" },
|
||||
{ label: "e-CPF", value: "e-CPF" },
|
||||
{ label: "NF-e", value: "NF-e" },
|
||||
]}
|
||||
/>
|
||||
)}
|
||||
{isFiltered && (
|
||||
<Button variant="ghost" onClick={() => table.resetColumnFilters()} className="h-8 px-2 lg:px-3">
|
||||
Limpar
|
||||
<Cross2Icon className="ml-2 h-4 w-4" />
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
<DataTableViewOptions table={table} />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
50
components/data-table-view-options.tsx
Normal file
50
components/data-table-view-options.tsx
Normal file
@@ -0,0 +1,50 @@
|
||||
"use client"
|
||||
|
||||
import { DropdownMenuTrigger } from "@radix-ui/react-dropdown-menu"
|
||||
import { MixerHorizontalIcon } from "@radix-ui/react-icons"
|
||||
import type { Table } from "@tanstack/react-table"
|
||||
|
||||
import { Button } from "@/components/ui/button"
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuCheckboxItem,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuLabel,
|
||||
DropdownMenuSeparator,
|
||||
} from "@/components/ui/dropdown-menu"
|
||||
|
||||
interface DataTableViewOptionsProps<TData> {
|
||||
table: Table<TData>
|
||||
}
|
||||
|
||||
export function DataTableViewOptions<TData>({ table }: DataTableViewOptionsProps<TData>) {
|
||||
return (
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button variant="outline" size="sm" className="ml-auto hidden h-8 lg:flex">
|
||||
<MixerHorizontalIcon className="mr-2 h-4 w-4" />
|
||||
Visualização
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end" className="w-[150px]">
|
||||
<DropdownMenuLabel>Alternar colunas</DropdownMenuLabel>
|
||||
<DropdownMenuSeparator />
|
||||
{table
|
||||
.getAllColumns()
|
||||
.filter((column) => typeof column.accessorFn !== "undefined" && column.getCanHide())
|
||||
.map((column) => {
|
||||
return (
|
||||
<DropdownMenuCheckboxItem
|
||||
key={column.id}
|
||||
className="capitalize"
|
||||
checked={column.getIsVisible()}
|
||||
onCheckedChange={(value) => column.toggleVisibility(!!value)}
|
||||
>
|
||||
{column.id}
|
||||
</DropdownMenuCheckboxItem>
|
||||
)
|
||||
})}
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
)
|
||||
}
|
||||
97
components/data-table.tsx
Normal file
97
components/data-table.tsx
Normal file
@@ -0,0 +1,97 @@
|
||||
"use client"
|
||||
|
||||
import * as React from "react"
|
||||
import {
|
||||
type ColumnDef,
|
||||
type ColumnFiltersState,
|
||||
type SortingState,
|
||||
type VisibilityState,
|
||||
flexRender,
|
||||
getCoreRowModel,
|
||||
getFacetedRowModel,
|
||||
getFacetedUniqueValues,
|
||||
getFilteredRowModel,
|
||||
getPaginationRowModel,
|
||||
getSortedRowModel,
|
||||
useReactTable,
|
||||
} from "@tanstack/react-table"
|
||||
|
||||
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table"
|
||||
|
||||
import { DataTablePagination } from "@/components/data-table-pagination"
|
||||
import { DataTableToolbar } from "@/components/data-table-toolbar"
|
||||
|
||||
interface DataTableProps<TData, TValue> {
|
||||
columns: ColumnDef<TData, TValue>[]
|
||||
data: TData[]
|
||||
}
|
||||
|
||||
export function DataTable<TData, TValue>({ columns, data }: DataTableProps<TData, TValue>) {
|
||||
const [rowSelection, setRowSelection] = React.useState({})
|
||||
const [columnVisibility, setColumnVisibility] = React.useState<VisibilityState>({})
|
||||
const [columnFilters, setColumnFilters] = React.useState<ColumnFiltersState>([])
|
||||
const [sorting, setSorting] = React.useState<SortingState>([])
|
||||
|
||||
const table = useReactTable({
|
||||
data,
|
||||
columns,
|
||||
state: {
|
||||
sorting,
|
||||
columnVisibility,
|
||||
rowSelection,
|
||||
columnFilters,
|
||||
},
|
||||
enableRowSelection: true,
|
||||
onRowSelectionChange: setRowSelection,
|
||||
onSortingChange: setSorting,
|
||||
onColumnFiltersChange: setColumnFilters,
|
||||
onColumnVisibilityChange: setColumnVisibility,
|
||||
getCoreRowModel: getCoreRowModel(),
|
||||
getFilteredRowModel: getFilteredRowModel(),
|
||||
getPaginationRowModel: getPaginationRowModel(),
|
||||
getSortedRowModel: getSortedRowModel(),
|
||||
getFacetedRowModel: getFacetedRowModel(),
|
||||
getFacetedUniqueValues: getFacetedUniqueValues(),
|
||||
})
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<DataTableToolbar table={table} />
|
||||
<div className="rounded-md border">
|
||||
<Table>
|
||||
<TableHeader>
|
||||
{table.getHeaderGroups().map((headerGroup) => (
|
||||
<TableRow key={headerGroup.id}>
|
||||
{headerGroup.headers.map((header) => {
|
||||
return (
|
||||
<TableHead key={header.id} colSpan={header.colSpan}>
|
||||
{header.isPlaceholder ? null : flexRender(header.column.columnDef.header, header.getContext())}
|
||||
</TableHead>
|
||||
)
|
||||
})}
|
||||
</TableRow>
|
||||
))}
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{table.getRowModel().rows?.length ? (
|
||||
table.getRowModel().rows.map((row) => (
|
||||
<TableRow key={row.id} data-state={row.getIsSelected() && "selected"}>
|
||||
{row.getVisibleCells().map((cell) => (
|
||||
<TableCell key={cell.id}>{flexRender(cell.column.columnDef.cell, cell.getContext())}</TableCell>
|
||||
))}
|
||||
</TableRow>
|
||||
))
|
||||
) : (
|
||||
<TableRow>
|
||||
<TableCell colSpan={columns.length} className="h-24 text-center">
|
||||
Nenhum resultado.
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
)}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
<DataTablePagination table={table} />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
60
components/icons.tsx
Normal file
60
components/icons.tsx
Normal file
@@ -0,0 +1,60 @@
|
||||
import {
|
||||
AlertTriangle,
|
||||
ArrowRight,
|
||||
Check,
|
||||
ChevronLeft,
|
||||
ChevronRight,
|
||||
Command,
|
||||
CreditCard,
|
||||
File,
|
||||
FileText,
|
||||
Github,
|
||||
HelpCircle,
|
||||
Image,
|
||||
Laptop,
|
||||
Loader2,
|
||||
Moon,
|
||||
MoreVertical,
|
||||
Pizza,
|
||||
Plus,
|
||||
Settings,
|
||||
SunMedium,
|
||||
Trash,
|
||||
Twitter,
|
||||
User,
|
||||
X,
|
||||
type LucideIcon,
|
||||
} from "lucide-react"
|
||||
|
||||
import { Apple, Google } from "@/components/brand-icons"
|
||||
|
||||
export type Icon = LucideIcon
|
||||
|
||||
export const Icons = {
|
||||
logo: Command,
|
||||
close: X,
|
||||
spinner: Loader2,
|
||||
chevronLeft: ChevronLeft,
|
||||
chevronRight: ChevronRight,
|
||||
trash: Trash,
|
||||
post: FileText,
|
||||
page: File,
|
||||
media: Image,
|
||||
settings: Settings,
|
||||
billing: CreditCard,
|
||||
ellipsis: MoreVertical,
|
||||
add: Plus,
|
||||
warning: AlertTriangle,
|
||||
user: User,
|
||||
arrowRight: ArrowRight,
|
||||
help: HelpCircle,
|
||||
pizza: Pizza,
|
||||
sun: SunMedium,
|
||||
moon: Moon,
|
||||
laptop: Laptop,
|
||||
gitHub: Github,
|
||||
twitter: Twitter,
|
||||
check: Check,
|
||||
google: Google,
|
||||
apple: Apple,
|
||||
}
|
||||
32
components/overview.tsx
Normal file
32
components/overview.tsx
Normal file
@@ -0,0 +1,32 @@
|
||||
"use client"
|
||||
|
||||
import { Bar, BarChart, ResponsiveContainer, XAxis, YAxis } from "recharts"
|
||||
|
||||
interface OverviewProps {
|
||||
data: number[]
|
||||
}
|
||||
|
||||
export function Overview({ data }: OverviewProps) {
|
||||
const months = ["Jan", "Fev", "Mar", "Abr", "Mai", "Jun", "Jul", "Ago", "Set", "Out", "Nov", "Dez"]
|
||||
|
||||
const chartData = data.map((value, index) => ({
|
||||
name: months[index],
|
||||
total: value,
|
||||
}))
|
||||
|
||||
return (
|
||||
<ResponsiveContainer width="100%" height={350}>
|
||||
<BarChart data={chartData}>
|
||||
<XAxis dataKey="name" stroke="#888888" fontSize={12} tickLine={false} axisLine={false} />
|
||||
<YAxis
|
||||
stroke="#888888"
|
||||
fontSize={12}
|
||||
tickLine={false}
|
||||
axisLine={false}
|
||||
tickFormatter={(value) => `R$${value}`}
|
||||
/>
|
||||
<Bar dataKey="total" fill="#adfa1d" radius={[4, 4, 0, 0]} />
|
||||
</BarChart>
|
||||
</ResponsiveContainer>
|
||||
)
|
||||
}
|
||||
226
components/pedido-detail-modal.tsx
Normal file
226
components/pedido-detail-modal.tsx
Normal file
@@ -0,0 +1,226 @@
|
||||
"use client"
|
||||
|
||||
import { useState } from "react"
|
||||
import { Dialog, DialogContent, DialogHeader, DialogTitle } from "@/components/ui/dialog"
|
||||
import { Button } from "@/components/ui/button"
|
||||
import { useToast } from "@/components/ui/use-toast"
|
||||
import { RefreshCw, ExternalLink, Copy } from "lucide-react"
|
||||
|
||||
const statusColors: Record<string, string> = {
|
||||
PENDING: "bg-yellow-100 text-yellow-800",
|
||||
RECEIVED: "bg-green-100 text-green-800",
|
||||
CONFIRMED: "bg-green-100 text-green-800",
|
||||
OVERDUE: "bg-red-100 text-red-800",
|
||||
REFUNDED: "bg-gray-100 text-gray-800",
|
||||
CANCELLED: "bg-red-100 text-red-800",
|
||||
}
|
||||
|
||||
const statusLabel: Record<string, string> = {
|
||||
PENDING: "Pendente",
|
||||
RECEIVED: "Pago",
|
||||
CONFIRMED: "Confirmado",
|
||||
OVERDUE: "Vencido",
|
||||
REFUNDED: "Estornado",
|
||||
CANCELLED: "Cancelado",
|
||||
}
|
||||
|
||||
type Pedido = {
|
||||
id: string
|
||||
status: string
|
||||
valor_centavos: number
|
||||
metodo_pagamento: string | null
|
||||
asaas_payment_id: string | null
|
||||
asaas_invoice_url: string | null
|
||||
pix_copia_cola: string | null
|
||||
pix_qrcode_url: string | null
|
||||
paid_at: string | null
|
||||
created_at: string
|
||||
clientes: {
|
||||
nome: string
|
||||
email: string
|
||||
telefone: string | null
|
||||
cpf_cnpj: string | null
|
||||
} | null
|
||||
produtos: {
|
||||
nome: string
|
||||
tipo: string
|
||||
validade: string
|
||||
midia: string | null
|
||||
} | null
|
||||
}
|
||||
|
||||
export function PedidoDetailModal({
|
||||
pedido: pedidoInicial,
|
||||
open,
|
||||
onClose,
|
||||
}: {
|
||||
pedido: Pedido
|
||||
open: boolean
|
||||
onClose: () => void
|
||||
}) {
|
||||
const { toast } = useToast()
|
||||
const [pedido, setPedido] = useState(pedidoInicial)
|
||||
const [syncing, setSyncing] = useState(false)
|
||||
|
||||
const fmt = (v: number) =>
|
||||
(v / 100).toLocaleString("pt-BR", { style: "currency", currency: "BRL" })
|
||||
|
||||
async function syncStatus() {
|
||||
setSyncing(true)
|
||||
try {
|
||||
const res = await fetch(`/api/pedido/${pedido.id}/sync`, { method: "POST" })
|
||||
const data = await res.json()
|
||||
if (!res.ok) throw new Error(data.error)
|
||||
setPedido((p) => ({ ...p, status: data.status }))
|
||||
toast({ title: `Status atualizado: ${statusLabel[data.status] ?? data.status}` })
|
||||
} catch (err) {
|
||||
toast({
|
||||
title: "Erro ao sincronizar",
|
||||
description: err instanceof Error ? err.message : "Tente novamente",
|
||||
variant: "destructive",
|
||||
})
|
||||
} finally {
|
||||
setSyncing(false)
|
||||
}
|
||||
}
|
||||
|
||||
function copiar(texto: string, label: string) {
|
||||
navigator.clipboard.writeText(texto)
|
||||
toast({ title: `${label} copiado!` })
|
||||
}
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={onClose}>
|
||||
<DialogContent className="max-w-lg max-h-[90vh] overflow-y-auto">
|
||||
<DialogHeader>
|
||||
<DialogTitle className="flex items-center justify-between gap-2 pr-6">
|
||||
<span>Pedido <span className="font-mono text-sm">{pedido.id.slice(0, 8).toUpperCase()}</span></span>
|
||||
<span className={`px-2 py-0.5 rounded-full text-xs font-medium ${statusColors[pedido.status] ?? ""}`}>
|
||||
{statusLabel[pedido.status] ?? pedido.status}
|
||||
</span>
|
||||
</DialogTitle>
|
||||
</DialogHeader>
|
||||
|
||||
<div className="space-y-5 mt-2">
|
||||
{/* Cliente */}
|
||||
<Section title="Cliente">
|
||||
<Row label="Nome" value={pedido.clientes?.nome} />
|
||||
<Row label="E-mail" value={pedido.clientes?.email} />
|
||||
<Row label="Telefone" value={pedido.clientes?.telefone} />
|
||||
<Row label="CPF/CNPJ" value={pedido.clientes?.cpf_cnpj} />
|
||||
</Section>
|
||||
|
||||
{/* Produto */}
|
||||
<Section title="Produto">
|
||||
<Row label="Nome" value={pedido.produtos?.nome} />
|
||||
<Row label="Tipo" value={pedido.produtos?.tipo} />
|
||||
<Row label="Validade" value={pedido.produtos?.validade} />
|
||||
<Row label="Mídia" value={pedido.produtos?.midia} />
|
||||
</Section>
|
||||
|
||||
{/* Pagamento */}
|
||||
<Section title="Pagamento">
|
||||
<Row label="Valor" value={fmt(pedido.valor_centavos)} highlight />
|
||||
<Row label="Método" value={pedido.metodo_pagamento} />
|
||||
<Row label="ASAAS ID" value={pedido.asaas_payment_id} mono />
|
||||
<Row
|
||||
label="Criado em"
|
||||
value={new Date(pedido.created_at).toLocaleString("pt-BR")}
|
||||
/>
|
||||
{pedido.paid_at && (
|
||||
<Row
|
||||
label="Pago em"
|
||||
value={new Date(pedido.paid_at).toLocaleString("pt-BR")}
|
||||
/>
|
||||
)}
|
||||
</Section>
|
||||
|
||||
{/* Ações */}
|
||||
<div className="flex flex-wrap gap-2 pt-1">
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
onClick={syncStatus}
|
||||
disabled={syncing}
|
||||
>
|
||||
<RefreshCw className={`mr-2 h-3.5 w-3.5 ${syncing ? "animate-spin" : ""}`} />
|
||||
Sync ASAAS
|
||||
</Button>
|
||||
|
||||
{pedido.asaas_invoice_url && (
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
onClick={() => window.open(pedido.asaas_invoice_url!, "_blank")}
|
||||
>
|
||||
<ExternalLink className="mr-2 h-3.5 w-3.5" />
|
||||
Ver boleto
|
||||
</Button>
|
||||
)}
|
||||
|
||||
{pedido.pix_copia_cola && (
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
onClick={() => copiar(pedido.pix_copia_cola!, "PIX copia-e-cola")}
|
||||
>
|
||||
<Copy className="mr-2 h-3.5 w-3.5" />
|
||||
Copiar PIX
|
||||
</Button>
|
||||
)}
|
||||
|
||||
{pedido.asaas_payment_id && (
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
onClick={() =>
|
||||
window.open(
|
||||
`https://www.asaas.com/index?tab=payment&id=${pedido.asaas_payment_id}`,
|
||||
"_blank"
|
||||
)
|
||||
}
|
||||
>
|
||||
<ExternalLink className="mr-2 h-3.5 w-3.5" />
|
||||
Ver no ASAAS
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
)
|
||||
}
|
||||
|
||||
function Section({ title, children }: { title: string; children: React.ReactNode }) {
|
||||
return (
|
||||
<div>
|
||||
<p className="text-xs font-semibold text-muted-foreground uppercase tracking-wider mb-2">
|
||||
{title}
|
||||
</p>
|
||||
<div className="rounded-lg border divide-y text-sm">{children}</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function Row({
|
||||
label,
|
||||
value,
|
||||
mono,
|
||||
highlight,
|
||||
}: {
|
||||
label: string
|
||||
value?: string | null
|
||||
mono?: boolean
|
||||
highlight?: boolean
|
||||
}) {
|
||||
return (
|
||||
<div className="flex justify-between items-center px-3 py-2 gap-4">
|
||||
<span className="text-muted-foreground shrink-0">{label}</span>
|
||||
<span
|
||||
className={`text-right truncate ${mono ? "font-mono text-xs" : ""} ${highlight ? "font-semibold text-primary" : ""}`}
|
||||
>
|
||||
{value ?? "—"}
|
||||
</span>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
263
components/pedido-status.tsx
Normal file
263
components/pedido-status.tsx
Normal file
@@ -0,0 +1,263 @@
|
||||
"use client"
|
||||
|
||||
import { useEffect, useState, useCallback } from "react"
|
||||
import { useRouter } from "next/navigation"
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"
|
||||
import { Button } from "@/components/ui/button"
|
||||
import { useToast } from "@/components/ui/use-toast"
|
||||
import {
|
||||
CheckCircle2,
|
||||
Clock,
|
||||
XCircle,
|
||||
Copy,
|
||||
ExternalLink,
|
||||
Calendar,
|
||||
Loader2,
|
||||
} from "lucide-react"
|
||||
|
||||
type Pedido = {
|
||||
id: string
|
||||
status: string
|
||||
valor_centavos: number
|
||||
metodo_pagamento: string
|
||||
pix_copia_cola: string | null
|
||||
pix_qrcode_url: string | null
|
||||
asaas_invoice_url: string | null
|
||||
paid_at: string | null
|
||||
clientes: { nome: string; email: string } | null
|
||||
produtos: { nome: string; validade: string; midia: string | null } | null
|
||||
}
|
||||
|
||||
const STATUS_PAGO = ["PAID", "RECEIVED", "CONFIRMED"]
|
||||
const STATUS_CANCELADO = ["CANCELED", "CANCELLED", "OVERDUE", "REFUNDED"]
|
||||
const LINK_AGENDAMENTO = process.env.NEXT_PUBLIC_AFTER_PAYMENT_REDIRECT ?? "/"
|
||||
|
||||
export default function PedidoStatus({ pedidoInicial }: { pedidoInicial: Pedido }) {
|
||||
const router = useRouter()
|
||||
const { toast } = useToast()
|
||||
const [pedido, setPedido] = useState(pedidoInicial)
|
||||
const [polling, setPolling] = useState(true)
|
||||
|
||||
const isPago = STATUS_PAGO.includes(pedido.status)
|
||||
const isCancelado = STATUS_CANCELADO.includes(pedido.status)
|
||||
|
||||
const valorFormatado = (pedido.valor_centavos / 100).toLocaleString("pt-BR", {
|
||||
style: "currency",
|
||||
currency: "BRL",
|
||||
})
|
||||
|
||||
const fetchStatus = useCallback(async () => {
|
||||
try {
|
||||
const res = await fetch(`/api/pedido/${pedido.id}`)
|
||||
if (!res.ok) return
|
||||
const data: Pedido = await res.json()
|
||||
setPedido(data)
|
||||
if (STATUS_PAGO.includes(data.status) || STATUS_CANCELADO.includes(data.status)) {
|
||||
setPolling(false)
|
||||
}
|
||||
} catch {
|
||||
// silencioso — tenta de novo no próximo ciclo
|
||||
}
|
||||
}, [pedido.id])
|
||||
|
||||
useEffect(() => {
|
||||
if (!polling || isPago || isCancelado) return
|
||||
const interval = setInterval(fetchStatus, 5000)
|
||||
return () => clearInterval(interval)
|
||||
}, [polling, isPago, isCancelado, fetchStatus])
|
||||
|
||||
function copiarPix() {
|
||||
if (!pedido.pix_copia_cola) return
|
||||
navigator.clipboard.writeText(pedido.pix_copia_cola)
|
||||
toast({ title: "Código PIX copiado!" })
|
||||
}
|
||||
|
||||
// ── Pago ──────────────────────────────────────────────────────────────
|
||||
if (isPago) {
|
||||
return (
|
||||
<Card className="max-w-2xl mx-auto border-green-200 bg-green-50">
|
||||
<CardHeader className="text-center pb-2">
|
||||
<CheckCircle2 className="h-14 w-14 text-green-500 mx-auto mb-3" />
|
||||
<CardTitle className="text-2xl text-green-800">Pagamento confirmado!</CardTitle>
|
||||
<p className="text-green-700 text-sm mt-1">
|
||||
{pedido.clientes?.nome}, seu certificado está sendo processado.
|
||||
</p>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-5">
|
||||
<ResumoCompra pedido={pedido} valorFormatado={valorFormatado} />
|
||||
|
||||
<div className="bg-white border border-green-200 rounded-xl p-5 text-center space-y-3">
|
||||
<Calendar className="h-8 w-8 text-primary mx-auto" />
|
||||
<p className="font-semibold text-lg">Próximo passo: agende sua validação</p>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Para emitir o certificado, é obrigatório realizar uma videoconferência de
|
||||
Clique no botão abaixo para continuar para o próximo passo.
|
||||
</p>
|
||||
<Button
|
||||
size="lg"
|
||||
className="w-full mt-2"
|
||||
onClick={() => router.push(LINK_AGENDAMENTO)}
|
||||
>
|
||||
<Calendar className="mr-2 h-4 w-4" />
|
||||
Continuar
|
||||
</Button>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
O link também foi enviado para {pedido.clientes?.email}
|
||||
</p>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)
|
||||
}
|
||||
|
||||
// ── Cancelado / Vencido ───────────────────────────────────────────────
|
||||
if (isCancelado) {
|
||||
return (
|
||||
<Card className="max-w-2xl mx-auto border-red-200 bg-red-50">
|
||||
<CardHeader className="text-center pb-2">
|
||||
<XCircle className="h-14 w-14 text-red-400 mx-auto mb-3" />
|
||||
<CardTitle className="text-2xl text-red-800">Pagamento não realizado</CardTitle>
|
||||
<p className="text-red-700 text-sm mt-1">
|
||||
Este pedido foi cancelado ou expirou.
|
||||
</p>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
<ResumoCompra pedido={pedido} valorFormatado={valorFormatado} />
|
||||
<Button variant="outline" className="w-full" onClick={() => router.push("/comprar")}>
|
||||
Fazer novo pedido
|
||||
</Button>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)
|
||||
}
|
||||
|
||||
// ── PIX ───────────────────────────────────────────────────────────────
|
||||
if (pedido.metodo_pagamento === "PIX") {
|
||||
return (
|
||||
<Card className="max-w-2xl mx-auto">
|
||||
<CardHeader className="text-center pb-2">
|
||||
<Clock className="h-10 w-10 text-yellow-500 mx-auto mb-2" />
|
||||
<CardTitle className="text-xl">Aguardando pagamento PIX</CardTitle>
|
||||
<p className="text-sm text-muted-foreground flex items-center justify-center gap-1">
|
||||
<Loader2 className="h-3 w-3 animate-spin" />
|
||||
Confirmação automática em segundos após o pagamento
|
||||
</p>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-5">
|
||||
<ResumoCompra pedido={pedido} valorFormatado={valorFormatado} />
|
||||
|
||||
{pedido.pix_qrcode_url && (
|
||||
<div className="flex justify-center">
|
||||
<img
|
||||
src={`data:image/png;base64,${pedido.pix_qrcode_url}`}
|
||||
alt="QR Code PIX"
|
||||
className="w-52 h-52 rounded-xl border"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{pedido.pix_copia_cola && (
|
||||
<div className="space-y-2">
|
||||
<p className="text-xs font-medium text-muted-foreground uppercase tracking-wide">
|
||||
PIX Copia e Cola
|
||||
</p>
|
||||
<div className="bg-muted rounded-lg p-3 text-xs break-all select-all font-mono leading-relaxed">
|
||||
{pedido.pix_copia_cola}
|
||||
</div>
|
||||
<Button variant="outline" className="w-full" onClick={copiarPix}>
|
||||
<Copy className="mr-2 h-4 w-4" />
|
||||
Copiar código PIX
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<p className="text-xs text-center text-muted-foreground">
|
||||
Valor: <strong>{valorFormatado}</strong>
|
||||
</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)
|
||||
}
|
||||
|
||||
// ── BOLETO ────────────────────────────────────────────────────────────
|
||||
if (pedido.metodo_pagamento === "BOLETO") {
|
||||
return (
|
||||
<Card className="max-w-2xl mx-auto">
|
||||
<CardHeader className="text-center pb-2">
|
||||
<Clock className="h-10 w-10 text-yellow-500 mx-auto mb-2" />
|
||||
<CardTitle className="text-xl">Boleto gerado</CardTitle>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Após o pagamento, a confirmação pode levar até 2 dias úteis.
|
||||
</p>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-5">
|
||||
<ResumoCompra pedido={pedido} valorFormatado={valorFormatado} />
|
||||
|
||||
{pedido.asaas_invoice_url && (
|
||||
<Button
|
||||
className="w-full"
|
||||
onClick={() => window.open(pedido.asaas_invoice_url!, "_blank")}
|
||||
>
|
||||
<ExternalLink className="mr-2 h-4 w-4" />
|
||||
Abrir boleto
|
||||
</Button>
|
||||
)}
|
||||
|
||||
<p className="text-xs text-center text-muted-foreground">
|
||||
O link do boleto também foi enviado para{" "}
|
||||
<strong>{pedido.clientes?.email}</strong>
|
||||
</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)
|
||||
}
|
||||
|
||||
// ── CARTÃO (pendente/processando) ────────────────────────────────────
|
||||
return (
|
||||
<Card className="max-w-2xl mx-auto">
|
||||
<CardHeader className="text-center pb-2">
|
||||
<Loader2 className="h-10 w-10 text-blue-500 mx-auto mb-2 animate-spin" />
|
||||
<CardTitle className="text-xl">Processando pagamento</CardTitle>
|
||||
<p className="text-sm text-muted-foreground">Aguarde enquanto confirmamos seu cartão.</p>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<ResumoCompra pedido={pedido} valorFormatado={valorFormatado} />
|
||||
</CardContent>
|
||||
</Card>
|
||||
)
|
||||
}
|
||||
|
||||
function ResumoCompra({
|
||||
pedido,
|
||||
valorFormatado,
|
||||
}: {
|
||||
pedido: Pedido
|
||||
valorFormatado: string
|
||||
}) {
|
||||
return (
|
||||
<div className="bg-muted rounded-xl p-4 space-y-1 text-sm">
|
||||
<div className="flex justify-between">
|
||||
<span className="text-muted-foreground">Produto</span>
|
||||
<span className="font-medium">{pedido.produtos?.nome ?? "—"}</span>
|
||||
</div>
|
||||
<div className="flex justify-between">
|
||||
<span className="text-muted-foreground">Validade</span>
|
||||
<span>{pedido.produtos?.validade ?? "—"}</span>
|
||||
</div>
|
||||
{pedido.produtos?.midia && (
|
||||
<div className="flex justify-between">
|
||||
<span className="text-muted-foreground">Mídia</span>
|
||||
<span>{pedido.produtos.midia}</span>
|
||||
</div>
|
||||
)}
|
||||
<div className="flex justify-between">
|
||||
<span className="text-muted-foreground">Valor</span>
|
||||
<span className="font-semibold text-primary">{valorFormatado}</span>
|
||||
</div>
|
||||
<div className="flex justify-between">
|
||||
<span className="text-muted-foreground">Pedido</span>
|
||||
<span className="font-mono text-xs">{pedido.id.slice(0, 8).toUpperCase()}</span>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
122
components/product-buy-form.tsx
Normal file
122
components/product-buy-form.tsx
Normal file
@@ -0,0 +1,122 @@
|
||||
"use client"
|
||||
|
||||
import { useState } from "react"
|
||||
import { useRouter } from "next/navigation"
|
||||
import { Button } from "@/components/ui/button"
|
||||
import { Input } from "@/components/ui/input"
|
||||
import { Label } from "@/components/ui/label"
|
||||
import { useToast } from "@/components/ui/use-toast"
|
||||
import { criarCheckout } from "@/lib/actions"
|
||||
|
||||
type Metodo = "PIX" | "BOLETO" | "CREDIT_CARD"
|
||||
|
||||
export function ProductBuyForm({
|
||||
produtoId,
|
||||
}: {
|
||||
produtoId: string
|
||||
}) {
|
||||
const router = useRouter()
|
||||
const { toast } = useToast()
|
||||
const [isLoading, setIsLoading] = useState(false)
|
||||
const [metodo, setMetodo] = useState<Metodo>("PIX")
|
||||
|
||||
const [form, setForm] = useState({
|
||||
nome: "",
|
||||
email: "",
|
||||
cpfCnpj: "",
|
||||
telefone: "",
|
||||
})
|
||||
|
||||
const handleSubmit = async (e: React.FormEvent) => {
|
||||
e.preventDefault()
|
||||
setIsLoading(true)
|
||||
try {
|
||||
const res = await criarCheckout({
|
||||
produtoId,
|
||||
cliente: {
|
||||
nome: form.nome,
|
||||
email: form.email,
|
||||
cpfCnpj: form.cpfCnpj.replace(/\D/g, ""),
|
||||
telefone: form.telefone,
|
||||
},
|
||||
metodo,
|
||||
})
|
||||
|
||||
router.push(`/pedido/${res.pedidoId}`)
|
||||
} catch (error) {
|
||||
toast({
|
||||
title: "Erro ao processar pagamento",
|
||||
description: error instanceof Error ? error.message : "Tente novamente.",
|
||||
variant: "destructive",
|
||||
})
|
||||
setIsLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<form onSubmit={handleSubmit} className="space-y-4">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="nome">Nome completo</Label>
|
||||
<Input
|
||||
id="nome"
|
||||
required
|
||||
value={form.nome}
|
||||
onChange={(e) => setForm({ ...form, nome: e.target.value })}
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="email">E-mail</Label>
|
||||
<Input
|
||||
id="email"
|
||||
type="email"
|
||||
required
|
||||
value={form.email}
|
||||
onChange={(e) => setForm({ ...form, email: e.target.value })}
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="cpfCnpj">CPF / CNPJ</Label>
|
||||
<Input
|
||||
id="cpfCnpj"
|
||||
required
|
||||
placeholder="000.000.000-00"
|
||||
value={form.cpfCnpj}
|
||||
onChange={(e) => setForm({ ...form, cpfCnpj: e.target.value })}
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="telefone">Telefone</Label>
|
||||
<Input
|
||||
id="telefone"
|
||||
placeholder="(11) 99999-9999"
|
||||
value={form.telefone}
|
||||
onChange={(e) => setForm({ ...form, telefone: e.target.value })}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label>Forma de pagamento</Label>
|
||||
<div className="flex gap-2">
|
||||
{(["PIX", "BOLETO", "CREDIT_CARD"] as Metodo[]).map((m) => (
|
||||
<button
|
||||
key={m}
|
||||
type="button"
|
||||
onClick={() => setMetodo(m)}
|
||||
className={`flex-1 py-2 rounded-lg border text-sm font-medium transition-colors ${
|
||||
metodo === m
|
||||
? "border-primary bg-primary text-white"
|
||||
: "border-gray-300 hover:border-primary"
|
||||
}`}
|
||||
>
|
||||
{m === "PIX" ? "PIX" : m === "BOLETO" ? "Boleto" : "Cartão"}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Button type="submit" className="w-full" disabled={isLoading}>
|
||||
{isLoading ? "Processando..." : `Pagar com ${metodo === "PIX" ? "PIX" : metodo === "BOLETO" ? "Boleto" : "Cartão"}`}
|
||||
</Button>
|
||||
</form>
|
||||
)
|
||||
}
|
||||
171
components/product-form-dialog.tsx
Normal file
171
components/product-form-dialog.tsx
Normal file
@@ -0,0 +1,171 @@
|
||||
"use client"
|
||||
|
||||
import type React from "react"
|
||||
import { useState, useEffect } from "react"
|
||||
import { useRouter } from "next/navigation"
|
||||
import { Button } from "@/components/ui/button"
|
||||
import { Input } from "@/components/ui/input"
|
||||
import { Label } from "@/components/ui/label"
|
||||
import { Textarea } from "@/components/ui/textarea"
|
||||
import { createProduto, updateProduto } from "@/lib/actions"
|
||||
import { toast } from "@/components/ui/use-toast"
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
DialogTrigger,
|
||||
DialogDescription,
|
||||
} from "@/components/ui/dialog"
|
||||
import type { Produto } from "@/lib/supabase"
|
||||
|
||||
type TipoOptions = "PF" | "PJ" | "SSL" | "NFe"
|
||||
|
||||
interface ProductFormDialogProps {
|
||||
product?: Produto | null
|
||||
}
|
||||
|
||||
const parseCurrency = (value: string) => Number(value.replace(/\D/g, ""))
|
||||
const formatCurrency = (value: string) => {
|
||||
const amount = Number(value.replace(/\D/g, "")) / 100
|
||||
return new Intl.NumberFormat("pt-BR", { style: "currency", currency: "BRL" }).format(amount)
|
||||
}
|
||||
|
||||
export function ProductFormDialog({ product }: ProductFormDialogProps) {
|
||||
const [open, setOpen] = useState(false)
|
||||
const router = useRouter()
|
||||
const [formData, setFormData] = useState({
|
||||
nome: "",
|
||||
descricao: "",
|
||||
price: "0",
|
||||
tipo: "PF" as TipoOptions,
|
||||
validade: "",
|
||||
midia: "",
|
||||
})
|
||||
|
||||
useEffect(() => {
|
||||
if (product) {
|
||||
setFormData({
|
||||
nome: product.nome,
|
||||
descricao: product.descricao ?? "",
|
||||
price: formatCurrency((product.preco_centavos).toString()),
|
||||
tipo: product.tipo,
|
||||
validade: product.validade,
|
||||
midia: product.midia ?? "",
|
||||
})
|
||||
}
|
||||
}, [product])
|
||||
|
||||
const handleSubmit = async (e: React.FormEvent<HTMLFormElement>) => {
|
||||
e.preventDefault()
|
||||
try {
|
||||
const preco_centavos = parseCurrency(formData.price)
|
||||
if (isNaN(preco_centavos) || preco_centavos <= 0) throw new Error("Preço inválido")
|
||||
|
||||
const payload = {
|
||||
nome: formData.nome,
|
||||
descricao: formData.descricao,
|
||||
tipo: formData.tipo,
|
||||
validade: formData.validade,
|
||||
midia: formData.midia || undefined,
|
||||
preco_centavos,
|
||||
}
|
||||
|
||||
if (product) {
|
||||
await updateProduto(product.id, payload)
|
||||
toast({ title: "Produto atualizado", description: "Alterações salvas com sucesso." })
|
||||
} else {
|
||||
await createProduto(payload)
|
||||
toast({ title: "Produto criado", description: "Adicionado com sucesso." })
|
||||
}
|
||||
|
||||
setOpen(false)
|
||||
router.refresh()
|
||||
} catch (error) {
|
||||
toast({
|
||||
title: "Erro",
|
||||
description: error instanceof Error ? error.message : "Tente novamente.",
|
||||
variant: "destructive",
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
const handleChange = (e: React.ChangeEvent<HTMLInputElement | HTMLTextAreaElement | HTMLSelectElement>) => {
|
||||
const { name, value } = e.target
|
||||
setFormData((prev) => ({ ...prev, [name]: value }))
|
||||
}
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={setOpen}>
|
||||
<DialogTrigger asChild>
|
||||
<Button onClick={() => setOpen(true)}>{product ? "Editar Produto" : "Novo Produto"}</Button>
|
||||
</DialogTrigger>
|
||||
<DialogContent className="sm:max-w-[425px]">
|
||||
<DialogHeader>
|
||||
<DialogTitle>{product ? "Editar Produto" : "Criar Novo Produto"}</DialogTitle>
|
||||
<DialogDescription>Preencha os dados do certificado.</DialogDescription>
|
||||
</DialogHeader>
|
||||
<form onSubmit={handleSubmit} className="space-y-4">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="nome">Nome</Label>
|
||||
<Input id="nome" name="nome" value={formData.nome} onChange={handleChange} required />
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="descricao">Descrição</Label>
|
||||
<Textarea id="descricao" name="descricao" value={formData.descricao} onChange={handleChange} />
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="price">Preço</Label>
|
||||
<Input
|
||||
id="price"
|
||||
name="price"
|
||||
value={formData.price}
|
||||
onChange={(e) => setFormData((prev) => ({ ...prev, price: formatCurrency(e.target.value) }))}
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="tipo">Tipo</Label>
|
||||
<select
|
||||
id="tipo"
|
||||
name="tipo"
|
||||
value={formData.tipo}
|
||||
onChange={handleChange}
|
||||
className="w-full rounded-md border border-input bg-background px-3 py-2 text-sm"
|
||||
required
|
||||
>
|
||||
<option value="PF">PF — Pessoa Física</option>
|
||||
<option value="PJ">PJ — Pessoa Jurídica</option>
|
||||
<option value="SSL">SSL</option>
|
||||
<option value="NFe">NFe</option>
|
||||
</select>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="validade">Validade</Label>
|
||||
<Input id="validade" name="validade" placeholder="ex: 1 ano" value={formData.validade} onChange={handleChange} required />
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="midia">Mídia</Label>
|
||||
<select
|
||||
id="midia"
|
||||
name="midia"
|
||||
value={formData.midia}
|
||||
onChange={handleChange}
|
||||
className="w-full rounded-md border border-input bg-background px-3 py-2 text-sm"
|
||||
>
|
||||
<option value="">Selecione...</option>
|
||||
<option value="Token">Token</option>
|
||||
<option value="Cartão">Cartão</option>
|
||||
<option value="Nuvem">Nuvem</option>
|
||||
<option value="Sem mídia">Sem mídia</option>
|
||||
</select>
|
||||
</div>
|
||||
<div className="flex justify-end gap-2">
|
||||
<Button type="button" variant="outline" onClick={() => setOpen(false)}>Cancelar</Button>
|
||||
<Button type="submit">{product ? "Salvar" : "Criar"}</Button>
|
||||
</div>
|
||||
</form>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
)
|
||||
}
|
||||
139
components/product-table.tsx
Normal file
139
components/product-table.tsx
Normal file
@@ -0,0 +1,139 @@
|
||||
"use client"
|
||||
|
||||
import { useState } from "react"
|
||||
import Link from "next/link"
|
||||
import { useRouter } from "next/navigation"
|
||||
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table"
|
||||
import { Button } from "@/components/ui/button"
|
||||
import { Badge } from "@/components/ui/badge"
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
} from "@/components/ui/dialog"
|
||||
import { deleteProduto } from "@/lib/actions"
|
||||
import { toast } from "@/components/ui/use-toast"
|
||||
import type { Produto } from "@/lib/supabase"
|
||||
|
||||
interface ProductTableProps {
|
||||
products: Produto[]
|
||||
}
|
||||
|
||||
export function ProductTable({ products }: ProductTableProps) {
|
||||
const router = useRouter()
|
||||
const [selectedProduct, setSelectedProduct] = useState<Produto | null>(null)
|
||||
const [isDeleteDialogOpen, setIsDeleteDialogOpen] = useState(false)
|
||||
|
||||
const handleDelete = async () => {
|
||||
if (!selectedProduct) return
|
||||
const result = await deleteProduto(selectedProduct.id)
|
||||
if (result.success) {
|
||||
toast({ title: "Produto excluído com sucesso." })
|
||||
setIsDeleteDialogOpen(false)
|
||||
setSelectedProduct(null)
|
||||
router.refresh()
|
||||
} else {
|
||||
toast({ title: "Erro ao excluir produto.", variant: "destructive" })
|
||||
}
|
||||
}
|
||||
|
||||
const precoFormatado = (centavos: number) =>
|
||||
(centavos / 100).toLocaleString("pt-BR", { style: "currency", currency: "BRL" })
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="rounded-md border">
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead>Nome</TableHead>
|
||||
<TableHead>Tipo</TableHead>
|
||||
<TableHead>Validade</TableHead>
|
||||
<TableHead>Mídia</TableHead>
|
||||
<TableHead>Preço</TableHead>
|
||||
<TableHead>Status</TableHead>
|
||||
<TableHead></TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{products.length === 0 ? (
|
||||
<TableRow>
|
||||
<TableCell colSpan={7} className="h-24 text-center text-muted-foreground">
|
||||
Nenhum produto cadastrado.
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
) : (
|
||||
products.map((product) => (
|
||||
<TableRow key={product.id} className="cursor-pointer hover:bg-muted/50" onClick={() => setSelectedProduct(product)}>
|
||||
<TableCell className="font-medium">{product.nome}</TableCell>
|
||||
<TableCell><Badge variant="outline">{product.tipo}</Badge></TableCell>
|
||||
<TableCell>{product.validade}</TableCell>
|
||||
<TableCell>{product.midia ?? "—"}</TableCell>
|
||||
<TableCell>{precoFormatado(product.preco_centavos)}</TableCell>
|
||||
<TableCell>
|
||||
<Badge variant={product.ativo ? "default" : "secondary"}>
|
||||
{product.ativo ? "Ativo" : "Inativo"}
|
||||
</Badge>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<Button variant="ghost" size="sm" onClick={(e) => { e.stopPropagation(); setSelectedProduct(product) }}>
|
||||
Detalhes
|
||||
</Button>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))
|
||||
)}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
|
||||
{/* Modal de detalhes */}
|
||||
<Dialog open={!!selectedProduct && !isDeleteDialogOpen} onOpenChange={(open) => !open && setSelectedProduct(null)}>
|
||||
<DialogContent className="sm:max-w-[600px]">
|
||||
{selectedProduct && (
|
||||
<>
|
||||
<DialogHeader>
|
||||
<DialogTitle>{selectedProduct.nome}</DialogTitle>
|
||||
<DialogDescription>{selectedProduct.descricao ?? "Sem descrição"}</DialogDescription>
|
||||
</DialogHeader>
|
||||
<div className="grid grid-cols-2 gap-4 mt-4 text-sm">
|
||||
<div><span className="font-medium">Tipo:</span> {selectedProduct.tipo}</div>
|
||||
<div><span className="font-medium">Validade:</span> {selectedProduct.validade}</div>
|
||||
<div><span className="font-medium">Mídia:</span> {selectedProduct.midia ?? "—"}</div>
|
||||
<div><span className="font-medium">Preço:</span> {precoFormatado(selectedProduct.preco_centavos)}</div>
|
||||
<div><span className="font-medium">Status:</span> {selectedProduct.ativo ? "Ativo" : "Inativo"}</div>
|
||||
</div>
|
||||
<div className="flex justify-end gap-2 mt-6">
|
||||
<Button asChild variant="outline">
|
||||
<Link href={`/produtos/${selectedProduct.id}`}>Ver página</Link>
|
||||
</Button>
|
||||
<Button variant="destructive" onClick={() => setIsDeleteDialogOpen(true)}>
|
||||
Excluir
|
||||
</Button>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
|
||||
{/* Confirm delete */}
|
||||
<Dialog open={isDeleteDialogOpen} onOpenChange={setIsDeleteDialogOpen}>
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
<DialogTitle>Confirmar Exclusão</DialogTitle>
|
||||
<DialogDescription>
|
||||
Tem certeza que deseja excluir "{selectedProduct?.nome}"? Esta ação não pode ser desfeita.
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<DialogFooter>
|
||||
<Button variant="outline" onClick={() => setIsDeleteDialogOpen(false)}>Cancelar</Button>
|
||||
<Button variant="destructive" onClick={handleDelete}>Excluir</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</>
|
||||
)
|
||||
}
|
||||
67
components/products-table-skeleton.tsx
Normal file
67
components/products-table-skeleton.tsx
Normal file
@@ -0,0 +1,67 @@
|
||||
import { Skeleton } from "@/components/ui/skeleton"
|
||||
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table"
|
||||
|
||||
export function ProductsTableSkeleton() {
|
||||
return (
|
||||
<div className="w-full">
|
||||
<div className="flex items-center py-4">
|
||||
<Skeleton className="h-8 w-[250px]" />
|
||||
<Skeleton className="ml-auto h-8 w-[100px]" />
|
||||
</div>
|
||||
<div className="rounded-md border">
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead className="w-[50px]">
|
||||
<Skeleton className="h-4 w-4" />
|
||||
</TableHead>
|
||||
<TableHead>
|
||||
<Skeleton className="h-4 w-[100px]" />
|
||||
</TableHead>
|
||||
<TableHead>
|
||||
<Skeleton className="h-4 w-[100px]" />
|
||||
</TableHead>
|
||||
<TableHead>
|
||||
<Skeleton className="h-4 w-[100px]" />
|
||||
</TableHead>
|
||||
<TableHead>
|
||||
<Skeleton className="h-4 w-[100px]" />
|
||||
</TableHead>
|
||||
<TableHead className="text-right">
|
||||
<Skeleton className="h-4 w-[100px] ml-auto" />
|
||||
</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{Array.from({ length: 5 }).map((_, i) => (
|
||||
<TableRow key={i}>
|
||||
<TableCell>
|
||||
<Skeleton className="h-4 w-4" />
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<Skeleton className="h-4 w-[200px]" />
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<Skeleton className="h-4 w-[150px]" />
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<Skeleton className="h-4 w-[100px]" />
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<Skeleton className="h-4 w-[100px]" />
|
||||
</TableCell>
|
||||
<TableCell className="text-right">
|
||||
<Skeleton className="h-8 w-8 ml-auto" />
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
<div className="flex items-center justify-end space-x-2 py-4">
|
||||
<Skeleton className="h-8 w-[100px]" />
|
||||
<Skeleton className="h-8 w-[70px]" />
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
60
components/recent-sales.tsx
Normal file
60
components/recent-sales.tsx
Normal file
@@ -0,0 +1,60 @@
|
||||
import { Avatar, AvatarFallback } from "@/components/ui/avatar"
|
||||
|
||||
interface VendaRecente {
|
||||
id: string
|
||||
valor_centavos: number
|
||||
created_at: string
|
||||
metodo_pagamento: string | null
|
||||
clientes: { nome: string; email: string } | null
|
||||
produtos: { nome: string } | null
|
||||
}
|
||||
|
||||
export function RecentSales({ vendas }: { vendas: VendaRecente[] }) {
|
||||
if (vendas.length === 0) {
|
||||
return (
|
||||
<p className="text-sm text-muted-foreground text-center py-4">
|
||||
Nenhuma venda confirmada ainda.
|
||||
</p>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
{vendas.map((v) => {
|
||||
const initials = (v.clientes?.nome ?? "?")
|
||||
.split(" ")
|
||||
.slice(0, 2)
|
||||
.map((n) => n[0])
|
||||
.join("")
|
||||
.toUpperCase()
|
||||
|
||||
return (
|
||||
<div key={v.id} className="flex items-center gap-4">
|
||||
<Avatar className="h-9 w-9">
|
||||
<AvatarFallback className="text-xs">{initials}</AvatarFallback>
|
||||
</Avatar>
|
||||
<div className="flex-1 min-w-0">
|
||||
<p className="text-sm font-medium leading-none truncate">
|
||||
{v.clientes?.nome ?? "—"}
|
||||
</p>
|
||||
<p className="text-xs text-muted-foreground truncate mt-0.5">
|
||||
{v.produtos?.nome ?? "—"}
|
||||
</p>
|
||||
</div>
|
||||
<div className="text-right shrink-0">
|
||||
<p className="text-sm font-semibold">
|
||||
{(v.valor_centavos / 100).toLocaleString("pt-BR", {
|
||||
style: "currency",
|
||||
currency: "BRL",
|
||||
})}
|
||||
</p>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{new Date(v.created_at).toLocaleDateString("pt-BR")}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
69
components/site-header.tsx
Normal file
69
components/site-header.tsx
Normal file
@@ -0,0 +1,69 @@
|
||||
"use client"
|
||||
|
||||
import { useState } from "react"
|
||||
import Link from "next/link"
|
||||
import { useRouter } from "next/navigation"
|
||||
import { Button } from "@/components/ui/button"
|
||||
import { Sheet, SheetContent, SheetTitle, SheetTrigger } from "@/components/ui/sheet"
|
||||
import { useAuth } from "@/lib/auth-context"
|
||||
import { User, Menu, X, ShoppingBag } from "lucide-react"
|
||||
import { appConfig } from "@/lib/config"
|
||||
|
||||
export function SiteHeader() {
|
||||
const { isLoggedIn } = useAuth()
|
||||
const router = useRouter()
|
||||
const [isMenuOpen, setIsMenuOpen] = useState(false)
|
||||
|
||||
return (
|
||||
<header className="sticky top-0 z-50 w-full bg-white border-b">
|
||||
<div className="container flex h-16 items-center justify-between">
|
||||
<Link href="/" className="flex items-center space-x-2">
|
||||
{appConfig.logoUrl ? (
|
||||
<img src={appConfig.logoUrl} alt={appConfig.name} className="h-8 w-auto" />
|
||||
) : (
|
||||
<span className="text-xl font-bold text-primary">{appConfig.name}</span>
|
||||
)}
|
||||
</Link>
|
||||
|
||||
<nav className="hidden md:flex items-center space-x-6">
|
||||
<Button asChild>
|
||||
<Link href="/comprar">
|
||||
<ShoppingBag className="mr-2 h-4 w-4" />
|
||||
Ver produtos
|
||||
</Link>
|
||||
</Button>
|
||||
</nav>
|
||||
|
||||
<Sheet open={isMenuOpen} onOpenChange={setIsMenuOpen}>
|
||||
<SheetTrigger asChild>
|
||||
<Button variant="ghost" size="icon" className="md:hidden">
|
||||
{isMenuOpen ? <X className="h-5 w-5" /> : <Menu className="h-5 w-5" />}
|
||||
</Button>
|
||||
</SheetTrigger>
|
||||
<SheetContent side="right" className="w-[300px]">
|
||||
<SheetTitle className="sr-only">Menu</SheetTitle>
|
||||
<nav className="flex flex-col space-y-4 mt-4">
|
||||
<Button
|
||||
className="w-full"
|
||||
onClick={() => { setIsMenuOpen(false); router.push("/comprar") }}
|
||||
>
|
||||
<ShoppingBag className="mr-2 h-4 w-4" />
|
||||
Ver produtos
|
||||
</Button>
|
||||
{isLoggedIn && (
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={() => { setIsMenuOpen(false); router.push("/admin") }}
|
||||
className="w-full"
|
||||
>
|
||||
<User className="h-4 w-4 mr-2" />
|
||||
Dashboard
|
||||
</Button>
|
||||
)}
|
||||
</nav>
|
||||
</SheetContent>
|
||||
</Sheet>
|
||||
</div>
|
||||
</header>
|
||||
)
|
||||
}
|
||||
7
components/theme-provider.tsx
Normal file
7
components/theme-provider.tsx
Normal file
@@ -0,0 +1,7 @@
|
||||
"use client"
|
||||
import { ThemeProvider as NextThemesProvider } from "next-themes"
|
||||
import type { ThemeProviderProps } from "next-themes"
|
||||
|
||||
export function ThemeProvider({ children, ...props }: ThemeProviderProps) {
|
||||
return <NextThemesProvider {...props}>{children}</NextThemesProvider>
|
||||
}
|
||||
197
components/transaction-table.tsx
Normal file
197
components/transaction-table.tsx
Normal file
@@ -0,0 +1,197 @@
|
||||
"use client"
|
||||
|
||||
import { useState, useMemo } from "react"
|
||||
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table"
|
||||
import { Input } from "@/components/ui/input"
|
||||
import { Button } from "@/components/ui/button"
|
||||
import { PedidoDetailModal } from "@/components/pedido-detail-modal"
|
||||
import { Search, Download } from "lucide-react"
|
||||
import type { Pedido } from "@/lib/supabase"
|
||||
|
||||
const statusColors: Record<string, string> = {
|
||||
PENDING: "bg-yellow-100 text-yellow-800",
|
||||
RECEIVED: "bg-green-100 text-green-800",
|
||||
CONFIRMED: "bg-green-100 text-green-800",
|
||||
OVERDUE: "bg-red-100 text-red-800",
|
||||
REFUNDED: "bg-gray-100 text-gray-800",
|
||||
CANCELLED: "bg-red-100 text-red-800",
|
||||
}
|
||||
|
||||
const statusLabel: Record<string, string> = {
|
||||
PENDING: "Pendente",
|
||||
RECEIVED: "Pago",
|
||||
CONFIRMED: "Confirmado",
|
||||
OVERDUE: "Vencido",
|
||||
REFUNDED: "Estornado",
|
||||
CANCELLED: "Cancelado",
|
||||
}
|
||||
|
||||
const ALL_STATUSES = ["PENDING", "RECEIVED", "CONFIRMED", "OVERDUE", "REFUNDED", "CANCELLED"]
|
||||
|
||||
type PedidoRich = Pedido & {
|
||||
clientes?: { nome: string; email: string; telefone?: string | null; cpf_cnpj?: string | null } | null
|
||||
produtos?: { nome: string; tipo: string; validade?: string; midia?: string | null } | null
|
||||
asaas_invoice_url?: string | null
|
||||
pix_copia_cola?: string | null
|
||||
pix_qrcode_url?: string | null
|
||||
paid_at?: string | null
|
||||
}
|
||||
|
||||
export function TransactionTable({ transactions }: { transactions: PedidoRich[] }) {
|
||||
const [busca, setBusca] = useState("")
|
||||
const [filtroStatus, setFiltroStatus] = useState<string | null>(null)
|
||||
const [pedidoSelecionado, setPedidoSelecionado] = useState<PedidoRich | null>(null)
|
||||
|
||||
const fmt = (v: number) =>
|
||||
(v / 100).toLocaleString("pt-BR", { style: "currency", currency: "BRL" })
|
||||
|
||||
const filtrados = useMemo(() => {
|
||||
return transactions.filter((t) => {
|
||||
const matchStatus = !filtroStatus || t.status === filtroStatus
|
||||
const q = busca.toLowerCase()
|
||||
const matchBusca =
|
||||
!q ||
|
||||
t.id.toLowerCase().includes(q) ||
|
||||
(t.clientes?.nome ?? "").toLowerCase().includes(q) ||
|
||||
(t.clientes?.email ?? "").toLowerCase().includes(q) ||
|
||||
(t.produtos?.nome ?? "").toLowerCase().includes(q)
|
||||
return matchStatus && matchBusca
|
||||
})
|
||||
}, [transactions, busca, filtroStatus])
|
||||
|
||||
function exportCSV() {
|
||||
const header = ["ID", "Cliente", "Email", "Produto", "Valor", "Método", "Status", "Data"]
|
||||
const rows = filtrados.map((t) => [
|
||||
t.id,
|
||||
t.clientes?.nome ?? "",
|
||||
t.clientes?.email ?? "",
|
||||
t.produtos?.nome ?? "",
|
||||
(t.valor_centavos / 100).toFixed(2),
|
||||
t.metodo_pagamento ?? "",
|
||||
t.status,
|
||||
new Date(t.created_at).toLocaleDateString("pt-BR"),
|
||||
])
|
||||
const csv = [header, ...rows].map((r) => r.map((c) => `"${c}"`).join(",")).join("\n")
|
||||
const blob = new Blob([csv], { type: "text/csv;charset=utf-8;" })
|
||||
const url = URL.createObjectURL(blob)
|
||||
const a = document.createElement("a")
|
||||
a.href = url
|
||||
a.download = `pedidos-${new Date().toISOString().slice(0, 10)}.csv`
|
||||
a.click()
|
||||
URL.revokeObjectURL(url)
|
||||
}
|
||||
|
||||
if (transactions.length === 0) {
|
||||
return (
|
||||
<div className="rounded-md border p-8 text-center text-muted-foreground">
|
||||
Nenhum pedido encontrado.
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-3">
|
||||
{/* Toolbar */}
|
||||
<div className="flex flex-wrap gap-2 items-center justify-between">
|
||||
<div className="flex gap-2 flex-wrap">
|
||||
<div className="relative">
|
||||
<Search className="absolute left-2.5 top-2.5 h-4 w-4 text-muted-foreground" />
|
||||
<Input
|
||||
placeholder="Buscar cliente, produto, ID..."
|
||||
className="pl-8 h-9 w-64"
|
||||
value={busca}
|
||||
onChange={(e) => setBusca(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex gap-1 flex-wrap">
|
||||
<Button
|
||||
size="sm"
|
||||
variant={filtroStatus === null ? "default" : "outline"}
|
||||
className="h-9 text-xs"
|
||||
onClick={() => setFiltroStatus(null)}
|
||||
>
|
||||
Todos
|
||||
</Button>
|
||||
{ALL_STATUSES.map((s) => (
|
||||
<Button
|
||||
key={s}
|
||||
size="sm"
|
||||
variant={filtroStatus === s ? "default" : "outline"}
|
||||
className="h-9 text-xs"
|
||||
onClick={() => setFiltroStatus(filtroStatus === s ? null : s)}
|
||||
>
|
||||
{statusLabel[s]}
|
||||
</Button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
<Button size="sm" variant="outline" className="h-9" onClick={exportCSV}>
|
||||
<Download className="mr-2 h-4 w-4" />
|
||||
Exportar CSV
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{/* Contagem */}
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{filtrados.length} pedido{filtrados.length !== 1 ? "s" : ""} encontrado{filtrados.length !== 1 ? "s" : ""}
|
||||
</p>
|
||||
|
||||
{/* Tabela */}
|
||||
<div className="rounded-md border">
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead>ID</TableHead>
|
||||
<TableHead>Cliente</TableHead>
|
||||
<TableHead>Produto</TableHead>
|
||||
<TableHead>Valor</TableHead>
|
||||
<TableHead>Método</TableHead>
|
||||
<TableHead>Status</TableHead>
|
||||
<TableHead>Data</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{filtrados.length === 0 ? (
|
||||
<TableRow>
|
||||
<TableCell colSpan={7} className="text-center text-muted-foreground py-8">
|
||||
Nenhum pedido encontrado com esses filtros.
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
) : (
|
||||
filtrados.map((t) => (
|
||||
<TableRow
|
||||
key={t.id}
|
||||
className="cursor-pointer hover:bg-muted/50"
|
||||
onClick={() => setPedidoSelecionado(t)}
|
||||
>
|
||||
<TableCell className="font-mono text-xs">{t.id.slice(0, 8).toUpperCase()}</TableCell>
|
||||
<TableCell className="max-w-[120px] truncate">{t.clientes?.nome ?? "—"}</TableCell>
|
||||
<TableCell className="max-w-[140px] truncate">{t.produtos?.nome ?? "—"}</TableCell>
|
||||
<TableCell className="font-medium">{fmt(t.valor_centavos)}</TableCell>
|
||||
<TableCell className="text-xs">{t.metodo_pagamento ?? "—"}</TableCell>
|
||||
<TableCell>
|
||||
<span className={`px-2 py-0.5 rounded-full text-xs font-medium ${statusColors[t.status] ?? ""}`}>
|
||||
{statusLabel[t.status] ?? t.status}
|
||||
</span>
|
||||
</TableCell>
|
||||
<TableCell className="text-sm text-muted-foreground whitespace-nowrap">
|
||||
{new Date(t.created_at).toLocaleDateString("pt-BR")}
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))
|
||||
)}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
|
||||
{/* Modal detalhe */}
|
||||
{pedidoSelecionado && (
|
||||
<PedidoDetailModal
|
||||
pedido={pedidoSelecionado as Parameters<typeof PedidoDetailModal>[0]["pedido"]}
|
||||
open={!!pedidoSelecionado}
|
||||
onClose={() => setPedidoSelecionado(null)}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
58
components/ui/accordion.tsx
Normal file
58
components/ui/accordion.tsx
Normal file
@@ -0,0 +1,58 @@
|
||||
"use client"
|
||||
|
||||
import * as React from "react"
|
||||
import * as AccordionPrimitive from "@radix-ui/react-accordion"
|
||||
import { ChevronDown } from "lucide-react"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
const Accordion = AccordionPrimitive.Root
|
||||
|
||||
const AccordionItem = React.forwardRef<
|
||||
React.ElementRef<typeof AccordionPrimitive.Item>,
|
||||
React.ComponentPropsWithoutRef<typeof AccordionPrimitive.Item>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<AccordionPrimitive.Item
|
||||
ref={ref}
|
||||
className={cn("border-b", className)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
AccordionItem.displayName = "AccordionItem"
|
||||
|
||||
const AccordionTrigger = React.forwardRef<
|
||||
React.ElementRef<typeof AccordionPrimitive.Trigger>,
|
||||
React.ComponentPropsWithoutRef<typeof AccordionPrimitive.Trigger>
|
||||
>(({ className, children, ...props }, ref) => (
|
||||
<AccordionPrimitive.Header className="flex">
|
||||
<AccordionPrimitive.Trigger
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"flex flex-1 items-center justify-between py-4 font-medium transition-all hover:underline [&[data-state=open]>svg]:rotate-180",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
<ChevronDown className="h-4 w-4 shrink-0 transition-transform duration-200" />
|
||||
</AccordionPrimitive.Trigger>
|
||||
</AccordionPrimitive.Header>
|
||||
))
|
||||
AccordionTrigger.displayName = AccordionPrimitive.Trigger.displayName
|
||||
|
||||
const AccordionContent = React.forwardRef<
|
||||
React.ElementRef<typeof AccordionPrimitive.Content>,
|
||||
React.ComponentPropsWithoutRef<typeof AccordionPrimitive.Content>
|
||||
>(({ className, children, ...props }, ref) => (
|
||||
<AccordionPrimitive.Content
|
||||
ref={ref}
|
||||
className="overflow-hidden text-sm transition-all data-[state=closed]:animate-accordion-up data-[state=open]:animate-accordion-down"
|
||||
{...props}
|
||||
>
|
||||
<div className={cn("pb-4 pt-0", className)}>{children}</div>
|
||||
</AccordionPrimitive.Content>
|
||||
))
|
||||
|
||||
AccordionContent.displayName = AccordionPrimitive.Content.displayName
|
||||
|
||||
export { Accordion, AccordionItem, AccordionTrigger, AccordionContent }
|
||||
50
components/ui/avatar.tsx
Normal file
50
components/ui/avatar.tsx
Normal file
@@ -0,0 +1,50 @@
|
||||
"use client"
|
||||
|
||||
import * as React from "react"
|
||||
import * as AvatarPrimitive from "@radix-ui/react-avatar"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
const Avatar = React.forwardRef<
|
||||
React.ElementRef<typeof AvatarPrimitive.Root>,
|
||||
React.ComponentPropsWithoutRef<typeof AvatarPrimitive.Root>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<AvatarPrimitive.Root
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"relative flex h-10 w-10 shrink-0 overflow-hidden rounded-full",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
Avatar.displayName = AvatarPrimitive.Root.displayName
|
||||
|
||||
const AvatarImage = React.forwardRef<
|
||||
React.ElementRef<typeof AvatarPrimitive.Image>,
|
||||
React.ComponentPropsWithoutRef<typeof AvatarPrimitive.Image>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<AvatarPrimitive.Image
|
||||
ref={ref}
|
||||
className={cn("aspect-square h-full w-full", className)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
AvatarImage.displayName = AvatarPrimitive.Image.displayName
|
||||
|
||||
const AvatarFallback = React.forwardRef<
|
||||
React.ElementRef<typeof AvatarPrimitive.Fallback>,
|
||||
React.ComponentPropsWithoutRef<typeof AvatarPrimitive.Fallback>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<AvatarPrimitive.Fallback
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"flex h-full w-full items-center justify-center rounded-full bg-muted",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
AvatarFallback.displayName = AvatarPrimitive.Fallback.displayName
|
||||
|
||||
export { Avatar, AvatarImage, AvatarFallback }
|
||||
36
components/ui/badge.tsx
Normal file
36
components/ui/badge.tsx
Normal file
@@ -0,0 +1,36 @@
|
||||
import * as React from "react"
|
||||
import { cva, type VariantProps } from "class-variance-authority"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
const badgeVariants = cva(
|
||||
"inline-flex items-center rounded-full border px-2.5 py-0.5 text-xs font-semibold transition-colors focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2",
|
||||
{
|
||||
variants: {
|
||||
variant: {
|
||||
default:
|
||||
"border-transparent bg-primary text-primary-foreground hover:bg-primary/80",
|
||||
secondary:
|
||||
"border-transparent bg-secondary text-secondary-foreground hover:bg-secondary/80",
|
||||
destructive:
|
||||
"border-transparent bg-destructive text-destructive-foreground hover:bg-destructive/80",
|
||||
outline: "text-foreground",
|
||||
},
|
||||
},
|
||||
defaultVariants: {
|
||||
variant: "default",
|
||||
},
|
||||
}
|
||||
)
|
||||
|
||||
export interface BadgeProps
|
||||
extends React.HTMLAttributes<HTMLDivElement>,
|
||||
VariantProps<typeof badgeVariants> {}
|
||||
|
||||
function Badge({ className, variant, ...props }: BadgeProps) {
|
||||
return (
|
||||
<div className={cn(badgeVariants({ variant }), className)} {...props} />
|
||||
)
|
||||
}
|
||||
|
||||
export { Badge, badgeVariants }
|
||||
47
components/ui/button.tsx
Normal file
47
components/ui/button.tsx
Normal file
@@ -0,0 +1,47 @@
|
||||
import * as React from "react"
|
||||
import { Slot } from "@radix-ui/react-slot"
|
||||
import { cva, type VariantProps } from "class-variance-authority"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
const buttonVariants = cva(
|
||||
"inline-flex items-center justify-center whitespace-nowrap rounded-md text-sm font-medium ring-offset-background transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50",
|
||||
{
|
||||
variants: {
|
||||
variant: {
|
||||
default: "bg-primary text-primary-foreground hover:bg-primary/90",
|
||||
destructive: "bg-destructive text-destructive-foreground hover:bg-destructive/90",
|
||||
outline: "border border-input bg-background hover:bg-accent hover:text-accent-foreground",
|
||||
secondary: "bg-secondary text-secondary-foreground hover:bg-secondary/80",
|
||||
ghost: "hover:bg-accent hover:text-accent-foreground",
|
||||
link: "text-primary underline-offset-4 hover:underline",
|
||||
},
|
||||
size: {
|
||||
default: "h-10 px-4 py-2",
|
||||
sm: "h-9 rounded-md px-3",
|
||||
lg: "h-11 rounded-md px-8",
|
||||
icon: "h-10 w-10",
|
||||
},
|
||||
},
|
||||
defaultVariants: {
|
||||
variant: "default",
|
||||
size: "default",
|
||||
},
|
||||
},
|
||||
)
|
||||
|
||||
export interface ButtonProps
|
||||
extends React.ButtonHTMLAttributes<HTMLButtonElement>,
|
||||
VariantProps<typeof buttonVariants> {
|
||||
asChild?: boolean
|
||||
}
|
||||
|
||||
const Button = React.forwardRef<HTMLButtonElement, ButtonProps>(
|
||||
({ className, variant, size, asChild = false, ...props }, ref) => {
|
||||
const Comp = asChild ? Slot : "button"
|
||||
return <Comp className={cn(buttonVariants({ variant, size, className }))} ref={ref} {...props} />
|
||||
},
|
||||
)
|
||||
Button.displayName = "Button"
|
||||
|
||||
export { Button, buttonVariants }
|
||||
21
components/ui/calendar.tsx
Normal file
21
components/ui/calendar.tsx
Normal file
@@ -0,0 +1,21 @@
|
||||
"use client"
|
||||
|
||||
import * as React from "react"
|
||||
import { DayPicker } from "react-day-picker"
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
export type CalendarProps = React.ComponentProps<typeof DayPicker>
|
||||
|
||||
function Calendar({ className, classNames, showOutsideDays = true, ...props }: CalendarProps) {
|
||||
return (
|
||||
<DayPicker
|
||||
showOutsideDays={showOutsideDays}
|
||||
className={cn("p-3", className)}
|
||||
classNames={classNames}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
Calendar.displayName = "Calendar"
|
||||
|
||||
export { Calendar }
|
||||
79
components/ui/card.tsx
Normal file
79
components/ui/card.tsx
Normal file
@@ -0,0 +1,79 @@
|
||||
import * as React from "react"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
const Card = React.forwardRef<
|
||||
HTMLDivElement,
|
||||
React.HTMLAttributes<HTMLDivElement>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<div
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"rounded-lg border bg-card text-card-foreground shadow-sm",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
Card.displayName = "Card"
|
||||
|
||||
const CardHeader = React.forwardRef<
|
||||
HTMLDivElement,
|
||||
React.HTMLAttributes<HTMLDivElement>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<div
|
||||
ref={ref}
|
||||
className={cn("flex flex-col space-y-1.5 p-6", className)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
CardHeader.displayName = "CardHeader"
|
||||
|
||||
const CardTitle = React.forwardRef<
|
||||
HTMLDivElement,
|
||||
React.HTMLAttributes<HTMLDivElement>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<div
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"text-2xl font-semibold leading-none tracking-tight",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
CardTitle.displayName = "CardTitle"
|
||||
|
||||
const CardDescription = React.forwardRef<
|
||||
HTMLDivElement,
|
||||
React.HTMLAttributes<HTMLDivElement>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<div
|
||||
ref={ref}
|
||||
className={cn("text-sm text-muted-foreground", className)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
CardDescription.displayName = "CardDescription"
|
||||
|
||||
const CardContent = React.forwardRef<
|
||||
HTMLDivElement,
|
||||
React.HTMLAttributes<HTMLDivElement>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<div ref={ref} className={cn("p-6 pt-0", className)} {...props} />
|
||||
))
|
||||
CardContent.displayName = "CardContent"
|
||||
|
||||
const CardFooter = React.forwardRef<
|
||||
HTMLDivElement,
|
||||
React.HTMLAttributes<HTMLDivElement>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<div
|
||||
ref={ref}
|
||||
className={cn("flex items-center p-6 pt-0", className)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
CardFooter.displayName = "CardFooter"
|
||||
|
||||
export { Card, CardHeader, CardFooter, CardTitle, CardDescription, CardContent }
|
||||
30
components/ui/checkbox.tsx
Normal file
30
components/ui/checkbox.tsx
Normal file
@@ -0,0 +1,30 @@
|
||||
"use client"
|
||||
|
||||
import * as React from "react"
|
||||
import * as CheckboxPrimitive from "@radix-ui/react-checkbox"
|
||||
import { Check } from "lucide-react"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
const Checkbox = React.forwardRef<
|
||||
React.ElementRef<typeof CheckboxPrimitive.Root>,
|
||||
React.ComponentPropsWithoutRef<typeof CheckboxPrimitive.Root>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<CheckboxPrimitive.Root
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"peer h-4 w-4 shrink-0 rounded-sm border border-primary ring-offset-background focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50 data-[state=checked]:bg-primary data-[state=checked]:text-primary-foreground",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<CheckboxPrimitive.Indicator
|
||||
className={cn("flex items-center justify-center text-current")}
|
||||
>
|
||||
<Check className="h-4 w-4" />
|
||||
</CheckboxPrimitive.Indicator>
|
||||
</CheckboxPrimitive.Root>
|
||||
))
|
||||
Checkbox.displayName = CheckboxPrimitive.Root.displayName
|
||||
|
||||
export { Checkbox }
|
||||
69
components/ui/command.tsx
Normal file
69
components/ui/command.tsx
Normal file
@@ -0,0 +1,69 @@
|
||||
"use client"
|
||||
|
||||
import * as React from "react"
|
||||
import { Command as CommandPrimitive } from "cmdk"
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
const Command = React.forwardRef<
|
||||
React.ElementRef<typeof CommandPrimitive>,
|
||||
React.ComponentPropsWithoutRef<typeof CommandPrimitive>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<CommandPrimitive
|
||||
ref={ref}
|
||||
className={cn("flex h-full w-full flex-col overflow-hidden rounded-md bg-popover text-popover-foreground", className)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
Command.displayName = CommandPrimitive.displayName
|
||||
|
||||
const CommandInput = React.forwardRef<
|
||||
React.ElementRef<typeof CommandPrimitive.Input>,
|
||||
React.ComponentPropsWithoutRef<typeof CommandPrimitive.Input>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<CommandPrimitive.Input
|
||||
ref={ref}
|
||||
className={cn("flex h-10 w-full rounded-md bg-transparent py-3 text-sm outline-none placeholder:text-muted-foreground disabled:cursor-not-allowed disabled:opacity-50", className)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
CommandInput.displayName = CommandPrimitive.Input.displayName
|
||||
|
||||
const CommandList = React.forwardRef<
|
||||
React.ElementRef<typeof CommandPrimitive.List>,
|
||||
React.ComponentPropsWithoutRef<typeof CommandPrimitive.List>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<CommandPrimitive.List ref={ref} className={cn("max-h-[300px] overflow-y-auto overflow-x-hidden", className)} {...props} />
|
||||
))
|
||||
CommandList.displayName = CommandPrimitive.List.displayName
|
||||
|
||||
const CommandEmpty = React.forwardRef<
|
||||
React.ElementRef<typeof CommandPrimitive.Empty>,
|
||||
React.ComponentPropsWithoutRef<typeof CommandPrimitive.Empty>
|
||||
>((props, ref) => <CommandPrimitive.Empty ref={ref} className="py-6 text-center text-sm" {...props} />)
|
||||
CommandEmpty.displayName = CommandPrimitive.Empty.displayName
|
||||
|
||||
const CommandGroup = React.forwardRef<
|
||||
React.ElementRef<typeof CommandPrimitive.Group>,
|
||||
React.ComponentPropsWithoutRef<typeof CommandPrimitive.Group>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<CommandPrimitive.Group ref={ref} className={cn("overflow-hidden p-1 text-foreground [&_[cmdk-group-heading]]:px-2 [&_[cmdk-group-heading]]:py-1.5 [&_[cmdk-group-heading]]:text-xs [&_[cmdk-group-heading]]:font-medium [&_[cmdk-group-heading]]:text-muted-foreground", className)} {...props} />
|
||||
))
|
||||
CommandGroup.displayName = CommandPrimitive.Group.displayName
|
||||
|
||||
const CommandItem = React.forwardRef<
|
||||
React.ElementRef<typeof CommandPrimitive.Item>,
|
||||
React.ComponentPropsWithoutRef<typeof CommandPrimitive.Item>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<CommandPrimitive.Item ref={ref} className={cn("relative flex cursor-default select-none items-center rounded-sm px-2 py-1.5 text-sm outline-none aria-selected:bg-accent aria-selected:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50", className)} {...props} />
|
||||
))
|
||||
CommandItem.displayName = CommandPrimitive.Item.displayName
|
||||
|
||||
const CommandSeparator = React.forwardRef<
|
||||
React.ElementRef<typeof CommandPrimitive.Separator>,
|
||||
React.ComponentPropsWithoutRef<typeof CommandPrimitive.Separator>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<CommandPrimitive.Separator ref={ref} className={cn("-mx-1 h-px bg-border", className)} {...props} />
|
||||
))
|
||||
CommandSeparator.displayName = CommandPrimitive.Separator.displayName
|
||||
|
||||
export { Command, CommandInput, CommandList, CommandEmpty, CommandGroup, CommandItem, CommandSeparator }
|
||||
97
components/ui/dialog.tsx
Normal file
97
components/ui/dialog.tsx
Normal file
@@ -0,0 +1,97 @@
|
||||
"use client"
|
||||
|
||||
import * as React from "react"
|
||||
import * as DialogPrimitive from "@radix-ui/react-dialog"
|
||||
import { X } from "lucide-react"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
const Dialog = DialogPrimitive.Root
|
||||
|
||||
const DialogTrigger = DialogPrimitive.Trigger
|
||||
|
||||
const DialogPortal = DialogPrimitive.Portal
|
||||
|
||||
const DialogClose = DialogPrimitive.Close
|
||||
|
||||
const DialogOverlay = React.forwardRef<
|
||||
React.ElementRef<typeof DialogPrimitive.Overlay>,
|
||||
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Overlay>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<DialogPrimitive.Overlay
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"fixed inset-0 z-50 bg-background/80 backdrop-blur-sm data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
DialogOverlay.displayName = DialogPrimitive.Overlay.displayName
|
||||
|
||||
const DialogContent = React.forwardRef<
|
||||
React.ElementRef<typeof DialogPrimitive.Content>,
|
||||
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Content>
|
||||
>(({ className, children, ...props }, ref) => (
|
||||
<DialogPortal>
|
||||
<DialogOverlay />
|
||||
<DialogPrimitive.Content
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"fixed left-[50%] top-[50%] z-50 grid w-full max-w-lg translate-x-[-50%] translate-y-[-50%] gap-4 border bg-background p-6 shadow-lg duration-200 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[state=closed]:slide-out-to-left-1/2 data-[state=closed]:slide-out-to-top-[48%] data-[state=open]:slide-in-from-left-1/2 data-[state=open]:slide-in-from-top-[48%] sm:rounded-[20px]",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
<DialogPrimitive.Close className="absolute right-4 top-4 rounded-sm opacity-70 ring-offset-background transition-opacity hover:opacity-100 focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2 disabled:pointer-events-none data-[state=open]:bg-accent data-[state=open]:text-muted-foreground">
|
||||
<X className="h-4 w-4" />
|
||||
<span className="sr-only">Close</span>
|
||||
</DialogPrimitive.Close>
|
||||
</DialogPrimitive.Content>
|
||||
</DialogPortal>
|
||||
))
|
||||
DialogContent.displayName = DialogPrimitive.Content.displayName
|
||||
|
||||
const DialogHeader = ({ className, ...props }: React.HTMLAttributes<HTMLDivElement>) => (
|
||||
<div className={cn("flex flex-col space-y-1.5 text-center sm:text-left", className)} {...props} />
|
||||
)
|
||||
DialogHeader.displayName = "DialogHeader"
|
||||
|
||||
const DialogFooter = ({ className, ...props }: React.HTMLAttributes<HTMLDivElement>) => (
|
||||
<div className={cn("flex flex-col-reverse sm:flex-row sm:justify-end sm:space-x-2", className)} {...props} />
|
||||
)
|
||||
DialogFooter.displayName = "DialogFooter"
|
||||
|
||||
const DialogTitle = React.forwardRef<
|
||||
React.ElementRef<typeof DialogPrimitive.Title>,
|
||||
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Title>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<DialogPrimitive.Title
|
||||
ref={ref}
|
||||
className={cn("text-lg font-semibold leading-none tracking-tight", className)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
DialogTitle.displayName = DialogPrimitive.Title.displayName
|
||||
|
||||
const DialogDescription = React.forwardRef<
|
||||
React.ElementRef<typeof DialogPrimitive.Description>,
|
||||
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Description>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<DialogPrimitive.Description ref={ref} className={cn("text-sm text-muted-foreground", className)} {...props} />
|
||||
))
|
||||
DialogDescription.displayName = DialogPrimitive.Description.displayName
|
||||
|
||||
export {
|
||||
Dialog,
|
||||
DialogPortal,
|
||||
DialogOverlay,
|
||||
DialogClose,
|
||||
DialogTrigger,
|
||||
DialogContent,
|
||||
DialogHeader,
|
||||
DialogFooter,
|
||||
DialogTitle,
|
||||
DialogDescription,
|
||||
}
|
||||
100
components/ui/dropdown-menu.tsx
Normal file
100
components/ui/dropdown-menu.tsx
Normal file
@@ -0,0 +1,100 @@
|
||||
"use client"
|
||||
|
||||
import * as React from "react"
|
||||
import * as DropdownMenuPrimitive from "@radix-ui/react-dropdown-menu"
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
const DropdownMenu = DropdownMenuPrimitive.Root
|
||||
const DropdownMenuTrigger = DropdownMenuPrimitive.Trigger
|
||||
const DropdownMenuGroup = DropdownMenuPrimitive.Group
|
||||
const DropdownMenuSub = DropdownMenuPrimitive.Sub
|
||||
const DropdownMenuRadioGroup = DropdownMenuPrimitive.RadioGroup
|
||||
|
||||
const DropdownMenuContent = React.forwardRef<
|
||||
React.ElementRef<typeof DropdownMenuPrimitive.Content>,
|
||||
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.Content>
|
||||
>(({ className, sideOffset = 4, ...props }, ref) => (
|
||||
<DropdownMenuPrimitive.Portal>
|
||||
<DropdownMenuPrimitive.Content
|
||||
ref={ref}
|
||||
sideOffset={sideOffset}
|
||||
className={cn(
|
||||
"z-50 min-w-[8rem] overflow-hidden rounded-md border bg-popover p-1 text-popover-foreground shadow-md data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
</DropdownMenuPrimitive.Portal>
|
||||
))
|
||||
DropdownMenuContent.displayName = DropdownMenuPrimitive.Content.displayName
|
||||
|
||||
const DropdownMenuItem = React.forwardRef<
|
||||
React.ElementRef<typeof DropdownMenuPrimitive.Item>,
|
||||
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.Item> & { inset?: boolean }
|
||||
>(({ className, inset, ...props }, ref) => (
|
||||
<DropdownMenuPrimitive.Item
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"relative flex cursor-default select-none items-center rounded-sm px-2 py-1.5 text-sm outline-none transition-colors focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50",
|
||||
inset && "pl-8",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
DropdownMenuItem.displayName = DropdownMenuPrimitive.Item.displayName
|
||||
|
||||
const DropdownMenuCheckboxItem = React.forwardRef<
|
||||
React.ElementRef<typeof DropdownMenuPrimitive.CheckboxItem>,
|
||||
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.CheckboxItem>
|
||||
>(({ className, children, checked, ...props }, ref) => (
|
||||
<DropdownMenuPrimitive.CheckboxItem
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"relative flex cursor-default select-none items-center rounded-sm py-1.5 pl-8 pr-2 text-sm outline-none transition-colors focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50",
|
||||
className
|
||||
)}
|
||||
checked={checked}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
</DropdownMenuPrimitive.CheckboxItem>
|
||||
))
|
||||
DropdownMenuCheckboxItem.displayName = DropdownMenuPrimitive.CheckboxItem.displayName
|
||||
|
||||
const DropdownMenuLabel = React.forwardRef<
|
||||
React.ElementRef<typeof DropdownMenuPrimitive.Label>,
|
||||
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.Label> & { inset?: boolean }
|
||||
>(({ className, inset, ...props }, ref) => (
|
||||
<DropdownMenuPrimitive.Label
|
||||
ref={ref}
|
||||
className={cn("px-2 py-1.5 text-sm font-semibold", inset && "pl-8", className)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
DropdownMenuLabel.displayName = DropdownMenuPrimitive.Label.displayName
|
||||
|
||||
const DropdownMenuSeparator = React.forwardRef<
|
||||
React.ElementRef<typeof DropdownMenuPrimitive.Separator>,
|
||||
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.Separator>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<DropdownMenuPrimitive.Separator
|
||||
ref={ref}
|
||||
className={cn("-mx-1 my-1 h-px bg-muted", className)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
DropdownMenuSeparator.displayName = DropdownMenuPrimitive.Separator.displayName
|
||||
|
||||
export {
|
||||
DropdownMenu,
|
||||
DropdownMenuTrigger,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuCheckboxItem,
|
||||
DropdownMenuLabel,
|
||||
DropdownMenuSeparator,
|
||||
DropdownMenuGroup,
|
||||
DropdownMenuSub,
|
||||
DropdownMenuRadioGroup,
|
||||
}
|
||||
22
components/ui/input.tsx
Normal file
22
components/ui/input.tsx
Normal file
@@ -0,0 +1,22 @@
|
||||
import * as React from "react"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
const Input = React.forwardRef<HTMLInputElement, React.ComponentProps<"input">>(
|
||||
({ className, type, ...props }, ref) => {
|
||||
return (
|
||||
<input
|
||||
type={type}
|
||||
className={cn(
|
||||
"flex h-10 w-full rounded-md border border-input bg-background px-3 py-2 text-base ring-offset-background file:border-0 file:bg-transparent file:text-sm file:font-medium file:text-foreground placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50 md:text-sm",
|
||||
className
|
||||
)}
|
||||
ref={ref}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
)
|
||||
Input.displayName = "Input"
|
||||
|
||||
export { Input }
|
||||
26
components/ui/label.tsx
Normal file
26
components/ui/label.tsx
Normal file
@@ -0,0 +1,26 @@
|
||||
"use client"
|
||||
|
||||
import * as React from "react"
|
||||
import * as LabelPrimitive from "@radix-ui/react-label"
|
||||
import { cva, type VariantProps } from "class-variance-authority"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
const labelVariants = cva(
|
||||
"text-sm font-medium leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70"
|
||||
)
|
||||
|
||||
const Label = React.forwardRef<
|
||||
React.ElementRef<typeof LabelPrimitive.Root>,
|
||||
React.ComponentPropsWithoutRef<typeof LabelPrimitive.Root> &
|
||||
VariantProps<typeof labelVariants>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<LabelPrimitive.Root
|
||||
ref={ref}
|
||||
className={cn(labelVariants(), className)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
Label.displayName = LabelPrimitive.Root.displayName
|
||||
|
||||
export { Label }
|
||||
29
components/ui/popover.tsx
Normal file
29
components/ui/popover.tsx
Normal file
@@ -0,0 +1,29 @@
|
||||
"use client"
|
||||
|
||||
import * as React from "react"
|
||||
import * as PopoverPrimitive from "@radix-ui/react-popover"
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
const Popover = PopoverPrimitive.Root
|
||||
const PopoverTrigger = PopoverPrimitive.Trigger
|
||||
|
||||
const PopoverContent = React.forwardRef<
|
||||
React.ElementRef<typeof PopoverPrimitive.Content>,
|
||||
React.ComponentPropsWithoutRef<typeof PopoverPrimitive.Content>
|
||||
>(({ className, align = "center", sideOffset = 4, ...props }, ref) => (
|
||||
<PopoverPrimitive.Portal>
|
||||
<PopoverPrimitive.Content
|
||||
ref={ref}
|
||||
align={align}
|
||||
sideOffset={sideOffset}
|
||||
className={cn(
|
||||
"z-50 w-72 rounded-md border bg-popover p-4 text-popover-foreground shadow-md outline-none data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
</PopoverPrimitive.Portal>
|
||||
))
|
||||
PopoverContent.displayName = PopoverPrimitive.Content.displayName
|
||||
|
||||
export { Popover, PopoverTrigger, PopoverContent }
|
||||
160
components/ui/select.tsx
Normal file
160
components/ui/select.tsx
Normal file
@@ -0,0 +1,160 @@
|
||||
"use client"
|
||||
|
||||
import * as React from "react"
|
||||
import * as SelectPrimitive from "@radix-ui/react-select"
|
||||
import { Check, ChevronDown, ChevronUp } from "lucide-react"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
const Select = SelectPrimitive.Root
|
||||
|
||||
const SelectGroup = SelectPrimitive.Group
|
||||
|
||||
const SelectValue = SelectPrimitive.Value
|
||||
|
||||
const SelectTrigger = React.forwardRef<
|
||||
React.ElementRef<typeof SelectPrimitive.Trigger>,
|
||||
React.ComponentPropsWithoutRef<typeof SelectPrimitive.Trigger>
|
||||
>(({ className, children, ...props }, ref) => (
|
||||
<SelectPrimitive.Trigger
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"flex h-10 w-full items-center justify-between rounded-md border border-input bg-background px-3 py-2 text-sm ring-offset-background placeholder:text-muted-foreground focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50 [&>span]:line-clamp-1",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
<SelectPrimitive.Icon asChild>
|
||||
<ChevronDown className="h-4 w-4 opacity-50" />
|
||||
</SelectPrimitive.Icon>
|
||||
</SelectPrimitive.Trigger>
|
||||
))
|
||||
SelectTrigger.displayName = SelectPrimitive.Trigger.displayName
|
||||
|
||||
const SelectScrollUpButton = React.forwardRef<
|
||||
React.ElementRef<typeof SelectPrimitive.ScrollUpButton>,
|
||||
React.ComponentPropsWithoutRef<typeof SelectPrimitive.ScrollUpButton>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<SelectPrimitive.ScrollUpButton
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"flex cursor-default items-center justify-center py-1",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<ChevronUp className="h-4 w-4" />
|
||||
</SelectPrimitive.ScrollUpButton>
|
||||
))
|
||||
SelectScrollUpButton.displayName = SelectPrimitive.ScrollUpButton.displayName
|
||||
|
||||
const SelectScrollDownButton = React.forwardRef<
|
||||
React.ElementRef<typeof SelectPrimitive.ScrollDownButton>,
|
||||
React.ComponentPropsWithoutRef<typeof SelectPrimitive.ScrollDownButton>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<SelectPrimitive.ScrollDownButton
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"flex cursor-default items-center justify-center py-1",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<ChevronDown className="h-4 w-4" />
|
||||
</SelectPrimitive.ScrollDownButton>
|
||||
))
|
||||
SelectScrollDownButton.displayName =
|
||||
SelectPrimitive.ScrollDownButton.displayName
|
||||
|
||||
const SelectContent = React.forwardRef<
|
||||
React.ElementRef<typeof SelectPrimitive.Content>,
|
||||
React.ComponentPropsWithoutRef<typeof SelectPrimitive.Content>
|
||||
>(({ className, children, position = "popper", ...props }, ref) => (
|
||||
<SelectPrimitive.Portal>
|
||||
<SelectPrimitive.Content
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"relative z-50 max-h-96 min-w-[8rem] overflow-hidden rounded-md border bg-popover text-popover-foreground shadow-md data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2",
|
||||
position === "popper" &&
|
||||
"data-[side=bottom]:translate-y-1 data-[side=left]:-translate-x-1 data-[side=right]:translate-x-1 data-[side=top]:-translate-y-1",
|
||||
className
|
||||
)}
|
||||
position={position}
|
||||
{...props}
|
||||
>
|
||||
<SelectScrollUpButton />
|
||||
<SelectPrimitive.Viewport
|
||||
className={cn(
|
||||
"p-1",
|
||||
position === "popper" &&
|
||||
"h-[var(--radix-select-trigger-height)] w-full min-w-[var(--radix-select-trigger-width)]"
|
||||
)}
|
||||
>
|
||||
{children}
|
||||
</SelectPrimitive.Viewport>
|
||||
<SelectScrollDownButton />
|
||||
</SelectPrimitive.Content>
|
||||
</SelectPrimitive.Portal>
|
||||
))
|
||||
SelectContent.displayName = SelectPrimitive.Content.displayName
|
||||
|
||||
const SelectLabel = React.forwardRef<
|
||||
React.ElementRef<typeof SelectPrimitive.Label>,
|
||||
React.ComponentPropsWithoutRef<typeof SelectPrimitive.Label>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<SelectPrimitive.Label
|
||||
ref={ref}
|
||||
className={cn("py-1.5 pl-8 pr-2 text-sm font-semibold", className)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
SelectLabel.displayName = SelectPrimitive.Label.displayName
|
||||
|
||||
const SelectItem = React.forwardRef<
|
||||
React.ElementRef<typeof SelectPrimitive.Item>,
|
||||
React.ComponentPropsWithoutRef<typeof SelectPrimitive.Item>
|
||||
>(({ className, children, ...props }, ref) => (
|
||||
<SelectPrimitive.Item
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"relative flex w-full cursor-default select-none items-center rounded-sm py-1.5 pl-8 pr-2 text-sm outline-none focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<span className="absolute left-2 flex h-3.5 w-3.5 items-center justify-center">
|
||||
<SelectPrimitive.ItemIndicator>
|
||||
<Check className="h-4 w-4" />
|
||||
</SelectPrimitive.ItemIndicator>
|
||||
</span>
|
||||
|
||||
<SelectPrimitive.ItemText>{children}</SelectPrimitive.ItemText>
|
||||
</SelectPrimitive.Item>
|
||||
))
|
||||
SelectItem.displayName = SelectPrimitive.Item.displayName
|
||||
|
||||
const SelectSeparator = React.forwardRef<
|
||||
React.ElementRef<typeof SelectPrimitive.Separator>,
|
||||
React.ComponentPropsWithoutRef<typeof SelectPrimitive.Separator>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<SelectPrimitive.Separator
|
||||
ref={ref}
|
||||
className={cn("-mx-1 my-1 h-px bg-muted", className)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
SelectSeparator.displayName = SelectPrimitive.Separator.displayName
|
||||
|
||||
export {
|
||||
Select,
|
||||
SelectGroup,
|
||||
SelectValue,
|
||||
SelectTrigger,
|
||||
SelectContent,
|
||||
SelectLabel,
|
||||
SelectItem,
|
||||
SelectSeparator,
|
||||
SelectScrollUpButton,
|
||||
SelectScrollDownButton,
|
||||
}
|
||||
14
components/ui/separator.tsx
Normal file
14
components/ui/separator.tsx
Normal file
@@ -0,0 +1,14 @@
|
||||
"use client"
|
||||
import * as React from "react"
|
||||
import * as SeparatorPrimitive from "@radix-ui/react-separator"
|
||||
import { cn } from "@/lib/utils"
|
||||
const Separator = React.forwardRef<
|
||||
React.ElementRef<typeof SeparatorPrimitive.Root>,
|
||||
React.ComponentPropsWithoutRef<typeof SeparatorPrimitive.Root>
|
||||
>(({ className, orientation = "horizontal", decorative = true, ...props }, ref) => (
|
||||
<SeparatorPrimitive.Root ref={ref} decorative={decorative} orientation={orientation}
|
||||
className={cn("shrink-0 bg-border", orientation === "horizontal" ? "h-[1px] w-full" : "h-full w-[1px]", className)}
|
||||
{...props} />
|
||||
))
|
||||
Separator.displayName = SeparatorPrimitive.Root.displayName
|
||||
export { Separator }
|
||||
109
components/ui/sheet.tsx
Normal file
109
components/ui/sheet.tsx
Normal file
@@ -0,0 +1,109 @@
|
||||
"use client"
|
||||
|
||||
import * as React from "react"
|
||||
import * as SheetPrimitive from "@radix-ui/react-dialog"
|
||||
import { cva, type VariantProps } from "class-variance-authority"
|
||||
import { X } from "lucide-react"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
const Sheet = SheetPrimitive.Root
|
||||
|
||||
const SheetTrigger = SheetPrimitive.Trigger
|
||||
|
||||
const SheetClose = SheetPrimitive.Close
|
||||
|
||||
const SheetPortal = SheetPrimitive.Portal
|
||||
|
||||
const SheetOverlay = React.forwardRef<
|
||||
React.ElementRef<typeof SheetPrimitive.Overlay>,
|
||||
React.ComponentPropsWithoutRef<typeof SheetPrimitive.Overlay>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<SheetPrimitive.Overlay
|
||||
className={cn(
|
||||
"fixed inset-0 z-50 bg-black/80 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
ref={ref}
|
||||
/>
|
||||
))
|
||||
SheetOverlay.displayName = SheetPrimitive.Overlay.displayName
|
||||
|
||||
const sheetVariants = cva(
|
||||
"fixed z-50 gap-4 bg-background p-6 shadow-lg transition ease-in-out data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:duration-300 data-[state=open]:duration-500",
|
||||
{
|
||||
variants: {
|
||||
side: {
|
||||
top: "inset-x-0 top-0 border-b data-[state=closed]:slide-out-to-top data-[state=open]:slide-in-from-top",
|
||||
bottom:
|
||||
"inset-x-0 bottom-0 border-t data-[state=closed]:slide-out-to-bottom data-[state=open]:slide-in-from-bottom",
|
||||
left: "inset-y-0 left-0 h-full w-3/4 border-r data-[state=closed]:slide-out-to-left data-[state=open]:slide-in-from-left sm:max-w-sm",
|
||||
right:
|
||||
"inset-y-0 right-0 h-full w-3/4 border-l data-[state=closed]:slide-out-to-right data-[state=open]:slide-in-from-right sm:max-w-sm",
|
||||
},
|
||||
},
|
||||
defaultVariants: {
|
||||
side: "right",
|
||||
},
|
||||
},
|
||||
)
|
||||
|
||||
interface SheetContentProps
|
||||
extends React.ComponentPropsWithoutRef<typeof SheetPrimitive.Content>,
|
||||
VariantProps<typeof sheetVariants> {}
|
||||
|
||||
const SheetContent = React.forwardRef<React.ElementRef<typeof SheetPrimitive.Content>, SheetContentProps>(
|
||||
({ side = "right", className, children, ...props }, ref) => (
|
||||
<SheetPortal>
|
||||
<SheetOverlay />
|
||||
<SheetPrimitive.Content ref={ref} className={cn(sheetVariants({ side }), className)} {...props}>
|
||||
{children}
|
||||
<SheetPrimitive.Close className="absolute right-4 top-4 rounded-sm opacity-70 ring-offset-background transition-opacity hover:opacity-100 focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2 disabled:pointer-events-none data-[state=open]:bg-secondary">
|
||||
<X className="h-4 w-4" />
|
||||
<span className="sr-only">Close</span>
|
||||
</SheetPrimitive.Close>
|
||||
</SheetPrimitive.Content>
|
||||
</SheetPortal>
|
||||
),
|
||||
)
|
||||
SheetContent.displayName = SheetPrimitive.Content.displayName
|
||||
|
||||
const SheetHeader = ({ className, ...props }: React.HTMLAttributes<HTMLDivElement>) => (
|
||||
<div className={cn("flex flex-col space-y-2 text-center sm:text-left", className)} {...props} />
|
||||
)
|
||||
SheetHeader.displayName = "SheetHeader"
|
||||
|
||||
const SheetFooter = ({ className, ...props }: React.HTMLAttributes<HTMLDivElement>) => (
|
||||
<div className={cn("flex flex-col-reverse sm:flex-row sm:justify-end sm:space-x-2", className)} {...props} />
|
||||
)
|
||||
SheetFooter.displayName = "SheetFooter"
|
||||
|
||||
const SheetTitle = React.forwardRef<
|
||||
React.ElementRef<typeof SheetPrimitive.Title>,
|
||||
React.ComponentPropsWithoutRef<typeof SheetPrimitive.Title>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<SheetPrimitive.Title ref={ref} className={cn("text-lg font-semibold text-foreground", className)} {...props} />
|
||||
))
|
||||
SheetTitle.displayName = SheetPrimitive.Title.displayName
|
||||
|
||||
const SheetDescription = React.forwardRef<
|
||||
React.ElementRef<typeof SheetPrimitive.Description>,
|
||||
React.ComponentPropsWithoutRef<typeof SheetPrimitive.Description>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<SheetPrimitive.Description ref={ref} className={cn("text-sm text-muted-foreground", className)} {...props} />
|
||||
))
|
||||
SheetDescription.displayName = SheetPrimitive.Description.displayName
|
||||
|
||||
export {
|
||||
Sheet,
|
||||
SheetPortal,
|
||||
SheetOverlay,
|
||||
SheetTrigger,
|
||||
SheetClose,
|
||||
SheetContent,
|
||||
SheetHeader,
|
||||
SheetFooter,
|
||||
SheetTitle,
|
||||
SheetDescription,
|
||||
}
|
||||
7
components/ui/skeleton.tsx
Normal file
7
components/ui/skeleton.tsx
Normal file
@@ -0,0 +1,7 @@
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
function Skeleton({ className, ...props }: React.HTMLAttributes<HTMLDivElement>) {
|
||||
return <div className={cn("animate-pulse rounded-md bg-muted", className)} {...props} />
|
||||
}
|
||||
|
||||
export { Skeleton }
|
||||
28
components/ui/switch.tsx
Normal file
28
components/ui/switch.tsx
Normal file
@@ -0,0 +1,28 @@
|
||||
"use client"
|
||||
|
||||
import * as React from "react"
|
||||
import * as SwitchPrimitives from "@radix-ui/react-switch"
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
const Switch = React.forwardRef<
|
||||
React.ElementRef<typeof SwitchPrimitives.Root>,
|
||||
React.ComponentPropsWithoutRef<typeof SwitchPrimitives.Root>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<SwitchPrimitives.Root
|
||||
className={cn(
|
||||
"peer inline-flex h-6 w-11 shrink-0 cursor-pointer items-center rounded-full border-2 border-transparent transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:ring-offset-background disabled:cursor-not-allowed disabled:opacity-50 data-[state=checked]:bg-primary data-[state=unchecked]:bg-input",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
ref={ref}
|
||||
>
|
||||
<SwitchPrimitives.Thumb
|
||||
className={cn(
|
||||
"pointer-events-none block h-5 w-5 rounded-full bg-background shadow-lg ring-0 transition-transform data-[state=checked]:translate-x-5 data-[state=unchecked]:translate-x-0"
|
||||
)}
|
||||
/>
|
||||
</SwitchPrimitives.Root>
|
||||
))
|
||||
Switch.displayName = SwitchPrimitives.Root.displayName
|
||||
|
||||
export { Switch }
|
||||
117
components/ui/table.tsx
Normal file
117
components/ui/table.tsx
Normal file
@@ -0,0 +1,117 @@
|
||||
import * as React from "react"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
const Table = React.forwardRef<
|
||||
HTMLTableElement,
|
||||
React.HTMLAttributes<HTMLTableElement>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<div className="relative w-full overflow-auto">
|
||||
<table
|
||||
ref={ref}
|
||||
className={cn("w-full caption-bottom text-sm", className)}
|
||||
{...props}
|
||||
/>
|
||||
</div>
|
||||
))
|
||||
Table.displayName = "Table"
|
||||
|
||||
const TableHeader = React.forwardRef<
|
||||
HTMLTableSectionElement,
|
||||
React.HTMLAttributes<HTMLTableSectionElement>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<thead ref={ref} className={cn("[&_tr]:border-b", className)} {...props} />
|
||||
))
|
||||
TableHeader.displayName = "TableHeader"
|
||||
|
||||
const TableBody = React.forwardRef<
|
||||
HTMLTableSectionElement,
|
||||
React.HTMLAttributes<HTMLTableSectionElement>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<tbody
|
||||
ref={ref}
|
||||
className={cn("[&_tr:last-child]:border-0", className)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
TableBody.displayName = "TableBody"
|
||||
|
||||
const TableFooter = React.forwardRef<
|
||||
HTMLTableSectionElement,
|
||||
React.HTMLAttributes<HTMLTableSectionElement>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<tfoot
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"border-t bg-muted/50 font-medium [&>tr]:last:border-b-0",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
TableFooter.displayName = "TableFooter"
|
||||
|
||||
const TableRow = React.forwardRef<
|
||||
HTMLTableRowElement,
|
||||
React.HTMLAttributes<HTMLTableRowElement>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<tr
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"border-b transition-colors hover:bg-muted/50 data-[state=selected]:bg-muted",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
TableRow.displayName = "TableRow"
|
||||
|
||||
const TableHead = React.forwardRef<
|
||||
HTMLTableCellElement,
|
||||
React.ThHTMLAttributes<HTMLTableCellElement>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<th
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"h-12 px-4 text-left align-middle font-medium text-muted-foreground [&:has([role=checkbox])]:pr-0",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
TableHead.displayName = "TableHead"
|
||||
|
||||
const TableCell = React.forwardRef<
|
||||
HTMLTableCellElement,
|
||||
React.TdHTMLAttributes<HTMLTableCellElement>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<td
|
||||
ref={ref}
|
||||
className={cn("p-4 align-middle [&:has([role=checkbox])]:pr-0", className)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
TableCell.displayName = "TableCell"
|
||||
|
||||
const TableCaption = React.forwardRef<
|
||||
HTMLTableCaptionElement,
|
||||
React.HTMLAttributes<HTMLTableCaptionElement>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<caption
|
||||
ref={ref}
|
||||
className={cn("mt-4 text-sm text-muted-foreground", className)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
TableCaption.displayName = "TableCaption"
|
||||
|
||||
export {
|
||||
Table,
|
||||
TableHeader,
|
||||
TableBody,
|
||||
TableFooter,
|
||||
TableHead,
|
||||
TableRow,
|
||||
TableCell,
|
||||
TableCaption,
|
||||
}
|
||||
55
components/ui/tabs.tsx
Normal file
55
components/ui/tabs.tsx
Normal file
@@ -0,0 +1,55 @@
|
||||
"use client"
|
||||
|
||||
import * as React from "react"
|
||||
import * as TabsPrimitive from "@radix-ui/react-tabs"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
const Tabs = TabsPrimitive.Root
|
||||
|
||||
const TabsList = React.forwardRef<
|
||||
React.ElementRef<typeof TabsPrimitive.List>,
|
||||
React.ComponentPropsWithoutRef<typeof TabsPrimitive.List>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<TabsPrimitive.List
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"inline-flex h-10 items-center justify-center rounded-md bg-muted p-1 text-muted-foreground",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
TabsList.displayName = TabsPrimitive.List.displayName
|
||||
|
||||
const TabsTrigger = React.forwardRef<
|
||||
React.ElementRef<typeof TabsPrimitive.Trigger>,
|
||||
React.ComponentPropsWithoutRef<typeof TabsPrimitive.Trigger>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<TabsPrimitive.Trigger
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"inline-flex items-center justify-center whitespace-nowrap rounded-sm px-3 py-1.5 text-sm font-medium ring-offset-background transition-all focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50 data-[state=active]:bg-background data-[state=active]:text-foreground data-[state=active]:shadow-sm",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
TabsTrigger.displayName = TabsPrimitive.Trigger.displayName
|
||||
|
||||
const TabsContent = React.forwardRef<
|
||||
React.ElementRef<typeof TabsPrimitive.Content>,
|
||||
React.ComponentPropsWithoutRef<typeof TabsPrimitive.Content>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<TabsPrimitive.Content
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"mt-2 ring-offset-background focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
TabsContent.displayName = TabsPrimitive.Content.displayName
|
||||
|
||||
export { Tabs, TabsList, TabsTrigger, TabsContent }
|
||||
22
components/ui/textarea.tsx
Normal file
22
components/ui/textarea.tsx
Normal file
@@ -0,0 +1,22 @@
|
||||
import * as React from "react"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
const Textarea = React.forwardRef<
|
||||
HTMLTextAreaElement,
|
||||
React.ComponentProps<"textarea">
|
||||
>(({ className, ...props }, ref) => {
|
||||
return (
|
||||
<textarea
|
||||
className={cn(
|
||||
"flex min-h-[80px] w-full rounded-md border border-input bg-background px-3 py-2 text-base ring-offset-background placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50 md:text-sm",
|
||||
className
|
||||
)}
|
||||
ref={ref}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
})
|
||||
Textarea.displayName = "Textarea"
|
||||
|
||||
export { Textarea }
|
||||
113
components/ui/toast.tsx
Normal file
113
components/ui/toast.tsx
Normal file
@@ -0,0 +1,113 @@
|
||||
"use client"
|
||||
|
||||
import * as React from "react"
|
||||
import * as ToastPrimitives from "@radix-ui/react-toast"
|
||||
import { cva, type VariantProps } from "class-variance-authority"
|
||||
import { X } from "lucide-react"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
const ToastProvider = ToastPrimitives.Provider
|
||||
|
||||
const ToastViewport = React.forwardRef<
|
||||
React.ElementRef<typeof ToastPrimitives.Viewport>,
|
||||
React.ComponentPropsWithoutRef<typeof ToastPrimitives.Viewport>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<ToastPrimitives.Viewport
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"fixed top-0 z-[100] flex max-h-screen w-full flex-col-reverse p-4 sm:bottom-0 sm:right-0 sm:top-auto sm:flex-col md:max-w-[420px]",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
ToastViewport.displayName = ToastPrimitives.Viewport.displayName
|
||||
|
||||
const toastVariants = cva(
|
||||
"group pointer-events-auto relative flex w-full items-center justify-between space-x-4 overflow-hidden rounded-md border p-6 pr-8 shadow-lg transition-all data-[swipe=cancel]:translate-x-0 data-[swipe=end]:translate-x-[var(--radix-toast-swipe-end-x)] data-[swipe=move]:translate-x-[var(--radix-toast-swipe-move-x)] data-[swipe=move]:transition-none data-[state=open]:animate-in data-[state=closed]:animate-out data-[swipe=end]:animate-out data-[state=closed]:fade-out-80 data-[state=closed]:slide-out-to-right-full data-[state=open]:slide-in-from-top-full data-[state=open]:sm:slide-in-from-bottom-full",
|
||||
{
|
||||
variants: {
|
||||
variant: {
|
||||
default: "border bg-background text-foreground",
|
||||
destructive: "destructive group border-destructive bg-destructive text-destructive-foreground",
|
||||
},
|
||||
},
|
||||
defaultVariants: {
|
||||
variant: "default",
|
||||
},
|
||||
},
|
||||
)
|
||||
|
||||
const Toast = React.forwardRef<
|
||||
React.ElementRef<typeof ToastPrimitives.Root>,
|
||||
React.ComponentPropsWithoutRef<typeof ToastPrimitives.Root> & VariantProps<typeof toastVariants>
|
||||
>(({ className, variant, ...props }, ref) => {
|
||||
return <ToastPrimitives.Root ref={ref} className={cn(toastVariants({ variant }), className)} {...props} />
|
||||
})
|
||||
Toast.displayName = ToastPrimitives.Root.displayName
|
||||
|
||||
const ToastAction = React.forwardRef<
|
||||
React.ElementRef<typeof ToastPrimitives.Action>,
|
||||
React.ComponentPropsWithoutRef<typeof ToastPrimitives.Action>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<ToastPrimitives.Action
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"inline-flex h-8 shrink-0 items-center justify-center rounded-md border bg-transparent px-3 text-sm font-medium ring-offset-background transition-colors hover:bg-secondary focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2 disabled:pointer-events-none disabled:opacity-50 group-[.destructive]:border-muted/40 group-[.destructive]:hover:border-destructive/30 group-[.destructive]:hover:bg-destructive group-[.destructive]:hover:text-destructive-foreground group-[.destructive]:focus:ring-destructive",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
ToastAction.displayName = ToastPrimitives.Action.displayName
|
||||
|
||||
const ToastClose = React.forwardRef<
|
||||
React.ElementRef<typeof ToastPrimitives.Close>,
|
||||
React.ComponentPropsWithoutRef<typeof ToastPrimitives.Close>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<ToastPrimitives.Close
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"absolute right-2 top-2 rounded-md p-1 text-foreground/50 opacity-0 transition-opacity hover:text-foreground focus:opacity-100 focus:outline-none focus:ring-2 group-hover:opacity-100 group-[.destructive]:text-red-300 group-[.destructive]:hover:text-red-50 group-[.destructive]:focus:ring-red-400 group-[.destructive]:focus:ring-offset-red-600",
|
||||
className,
|
||||
)}
|
||||
toast-close=""
|
||||
{...props}
|
||||
>
|
||||
<X className="h-4 w-4" />
|
||||
</ToastPrimitives.Close>
|
||||
))
|
||||
ToastClose.displayName = ToastPrimitives.Close.displayName
|
||||
|
||||
const ToastTitle = React.forwardRef<
|
||||
React.ElementRef<typeof ToastPrimitives.Title>,
|
||||
React.ComponentPropsWithoutRef<typeof ToastPrimitives.Title>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<ToastPrimitives.Title ref={ref} className={cn("text-sm font-semibold", className)} {...props} />
|
||||
))
|
||||
ToastTitle.displayName = ToastPrimitives.Title.displayName
|
||||
|
||||
const ToastDescription = React.forwardRef<
|
||||
React.ElementRef<typeof ToastPrimitives.Description>,
|
||||
React.ComponentPropsWithoutRef<typeof ToastPrimitives.Description>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<ToastPrimitives.Description ref={ref} className={cn("text-sm opacity-90", className)} {...props} />
|
||||
))
|
||||
ToastDescription.displayName = ToastPrimitives.Description.displayName
|
||||
|
||||
type ToastProps = React.ComponentPropsWithoutRef<typeof Toast>
|
||||
|
||||
type ToastActionElement = React.ReactElement<typeof ToastAction>
|
||||
|
||||
export {
|
||||
type ToastProps,
|
||||
type ToastActionElement,
|
||||
ToastProvider,
|
||||
ToastViewport,
|
||||
Toast,
|
||||
ToastTitle,
|
||||
ToastDescription,
|
||||
ToastClose,
|
||||
ToastAction,
|
||||
}
|
||||
24
components/ui/toaster.tsx
Normal file
24
components/ui/toaster.tsx
Normal file
@@ -0,0 +1,24 @@
|
||||
"use client"
|
||||
|
||||
import { Toast, ToastClose, ToastDescription, ToastProvider, ToastTitle, ToastViewport } from "@/components/ui/toast"
|
||||
import { useToast } from "@/hooks/use-toast"
|
||||
|
||||
export function Toaster() {
|
||||
const { toasts } = useToast()
|
||||
|
||||
return (
|
||||
<ToastProvider>
|
||||
{toasts.map(({ id, title, description, action, ...props }) => (
|
||||
<Toast key={id} {...props}>
|
||||
<div className="grid gap-1">
|
||||
{title && <ToastTitle>{title}</ToastTitle>}
|
||||
{description && <ToastDescription>{description}</ToastDescription>}
|
||||
</div>
|
||||
{action}
|
||||
<ToastClose />
|
||||
</Toast>
|
||||
))}
|
||||
<ToastViewport />
|
||||
</ToastProvider>
|
||||
)
|
||||
}
|
||||
194
components/ui/use-toast.ts
Normal file
194
components/ui/use-toast.ts
Normal file
@@ -0,0 +1,194 @@
|
||||
"use client"
|
||||
|
||||
// Inspired by react-hot-toast library
|
||||
import * as React from "react"
|
||||
|
||||
import type {
|
||||
ToastActionElement,
|
||||
ToastProps,
|
||||
} from "@/components/ui/toast"
|
||||
|
||||
const TOAST_LIMIT = 1
|
||||
const TOAST_REMOVE_DELAY = 1000000
|
||||
|
||||
type ToasterToast = ToastProps & {
|
||||
id: string
|
||||
title?: React.ReactNode
|
||||
description?: React.ReactNode
|
||||
action?: ToastActionElement
|
||||
}
|
||||
|
||||
const actionTypes = {
|
||||
ADD_TOAST: "ADD_TOAST",
|
||||
UPDATE_TOAST: "UPDATE_TOAST",
|
||||
DISMISS_TOAST: "DISMISS_TOAST",
|
||||
REMOVE_TOAST: "REMOVE_TOAST",
|
||||
} as const
|
||||
|
||||
let count = 0
|
||||
|
||||
function genId() {
|
||||
count = (count + 1) % Number.MAX_SAFE_INTEGER
|
||||
return count.toString()
|
||||
}
|
||||
|
||||
type ActionType = typeof actionTypes
|
||||
|
||||
type Action =
|
||||
| {
|
||||
type: ActionType["ADD_TOAST"]
|
||||
toast: ToasterToast
|
||||
}
|
||||
| {
|
||||
type: ActionType["UPDATE_TOAST"]
|
||||
toast: Partial<ToasterToast>
|
||||
}
|
||||
| {
|
||||
type: ActionType["DISMISS_TOAST"]
|
||||
toastId?: ToasterToast["id"]
|
||||
}
|
||||
| {
|
||||
type: ActionType["REMOVE_TOAST"]
|
||||
toastId?: ToasterToast["id"]
|
||||
}
|
||||
|
||||
interface State {
|
||||
toasts: ToasterToast[]
|
||||
}
|
||||
|
||||
const toastTimeouts = new Map<string, ReturnType<typeof setTimeout>>()
|
||||
|
||||
const addToRemoveQueue = (toastId: string) => {
|
||||
if (toastTimeouts.has(toastId)) {
|
||||
return
|
||||
}
|
||||
|
||||
const timeout = setTimeout(() => {
|
||||
toastTimeouts.delete(toastId)
|
||||
dispatch({
|
||||
type: "REMOVE_TOAST",
|
||||
toastId: toastId,
|
||||
})
|
||||
}, TOAST_REMOVE_DELAY)
|
||||
|
||||
toastTimeouts.set(toastId, timeout)
|
||||
}
|
||||
|
||||
export const reducer = (state: State, action: Action): State => {
|
||||
switch (action.type) {
|
||||
case "ADD_TOAST":
|
||||
return {
|
||||
...state,
|
||||
toasts: [action.toast, ...state.toasts].slice(0, TOAST_LIMIT),
|
||||
}
|
||||
|
||||
case "UPDATE_TOAST":
|
||||
return {
|
||||
...state,
|
||||
toasts: state.toasts.map((t) =>
|
||||
t.id === action.toast.id ? { ...t, ...action.toast } : t
|
||||
),
|
||||
}
|
||||
|
||||
case "DISMISS_TOAST": {
|
||||
const { toastId } = action
|
||||
|
||||
// ! Side effects ! - This could be extracted into a dismissToast() action,
|
||||
// but I'll keep it here for simplicity
|
||||
if (toastId) {
|
||||
addToRemoveQueue(toastId)
|
||||
} else {
|
||||
state.toasts.forEach((toast) => {
|
||||
addToRemoveQueue(toast.id)
|
||||
})
|
||||
}
|
||||
|
||||
return {
|
||||
...state,
|
||||
toasts: state.toasts.map((t) =>
|
||||
t.id === toastId || toastId === undefined
|
||||
? {
|
||||
...t,
|
||||
open: false,
|
||||
}
|
||||
: t
|
||||
),
|
||||
}
|
||||
}
|
||||
case "REMOVE_TOAST":
|
||||
if (action.toastId === undefined) {
|
||||
return {
|
||||
...state,
|
||||
toasts: [],
|
||||
}
|
||||
}
|
||||
return {
|
||||
...state,
|
||||
toasts: state.toasts.filter((t) => t.id !== action.toastId),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const listeners: Array<(state: State) => void> = []
|
||||
|
||||
let memoryState: State = { toasts: [] }
|
||||
|
||||
function dispatch(action: Action) {
|
||||
memoryState = reducer(memoryState, action)
|
||||
listeners.forEach((listener) => {
|
||||
listener(memoryState)
|
||||
})
|
||||
}
|
||||
|
||||
type Toast = Omit<ToasterToast, "id">
|
||||
|
||||
function toast({ ...props }: Toast) {
|
||||
const id = genId()
|
||||
|
||||
const update = (props: ToasterToast) =>
|
||||
dispatch({
|
||||
type: "UPDATE_TOAST",
|
||||
toast: { ...props, id },
|
||||
})
|
||||
const dismiss = () => dispatch({ type: "DISMISS_TOAST", toastId: id })
|
||||
|
||||
dispatch({
|
||||
type: "ADD_TOAST",
|
||||
toast: {
|
||||
...props,
|
||||
id,
|
||||
open: true,
|
||||
onOpenChange: (open) => {
|
||||
if (!open) dismiss()
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
return {
|
||||
id: id,
|
||||
dismiss,
|
||||
update,
|
||||
}
|
||||
}
|
||||
|
||||
function useToast() {
|
||||
const [state, setState] = React.useState<State>(memoryState)
|
||||
|
||||
React.useEffect(() => {
|
||||
listeners.push(setState)
|
||||
return () => {
|
||||
const index = listeners.indexOf(setState)
|
||||
if (index > -1) {
|
||||
listeners.splice(index, 1)
|
||||
}
|
||||
}
|
||||
}, [state])
|
||||
|
||||
return {
|
||||
...state,
|
||||
toast,
|
||||
dismiss: (toastId?: string) => dispatch({ type: "DISMISS_TOAST", toastId }),
|
||||
}
|
||||
}
|
||||
|
||||
export { useToast, toast }
|
||||
Reference in New Issue
Block a user