feat: adiciona horario e icone de relogio na pagina de confirmacao

This commit is contained in:
Erik Silva
2026-02-04 19:38:51 -03:00
parent 2424fa9bb6
commit 4e6926f7a6
39 changed files with 4743 additions and 802 deletions

View File

@@ -1,8 +1,8 @@
'use client'
import { useState, useTransition } from 'react'
import { createArena, deleteArena } from '@/actions/arena'
import { MapPin, Plus, Trash2, Loader2, Navigation } from 'lucide-react'
import { createArena, deleteArena, updateArena } from '@/actions/arena'
import { MapPin, Plus, Trash2, Loader2, Navigation, Pencil, X } from 'lucide-react'
import type { Arena } from '@prisma/client'
import { DeleteConfirmationModal } from '@/components/DeleteConfirmationModal'
@@ -12,6 +12,7 @@ interface ArenasManagerProps {
export function ArenasManager({ arenas }: ArenasManagerProps) {
const [isPending, startTransition] = useTransition()
const [editingArena, setEditingArena] = useState<Arena | null>(null)
const [deleteModal, setDeleteModal] = useState<{
isOpen: boolean
arenaId: string | null
@@ -46,6 +47,17 @@ export function ArenasManager({ arenas }: ArenasManagerProps) {
})
}
const handleEdit = (arena: Arena) => {
setEditingArena(arena)
document.getElementById('arena-form')?.scrollIntoView({ behavior: 'smooth' })
}
const cancelEdit = () => {
setEditingArena(null)
const form = document.getElementById('arena-form') as HTMLFormElement
form?.reset()
}
return (
<div className="ui-card p-8 space-y-8">
<header>
@@ -70,14 +82,23 @@ export function ArenasManager({ arenas }: ArenasManagerProps) {
{arena.address && <p className="text-sm text-muted">{arena.address}</p>}
</div>
</div>
<button
onClick={() => handleDelete(arena.id)}
disabled={isPending}
className="p-2 text-muted hover:text-destructive hover:bg-destructive/10 rounded-lg transition-colors opacity-0 group-hover:opacity-100 focus:opacity-100"
title="Excluir local"
>
<Trash2 className="w-4 h-4" />
</button>
<div className="flex items-center gap-1 opacity-0 group-hover:opacity-100 focus-within:opacity-100 transition-opacity">
<button
onClick={() => handleEdit(arena)}
className="p-2 text-muted hover:text-primary hover:bg-primary/10 rounded-lg transition-colors"
title="Editar local"
>
<Pencil className="w-4 h-4" />
</button>
<button
onClick={() => handleDelete(arena.id)}
disabled={isPending}
className="p-2 text-muted hover:text-destructive hover:bg-destructive/10 rounded-lg transition-colors"
title="Excluir local"
>
<Trash2 className="w-4 h-4" />
</button>
</div>
</div>
))}
@@ -91,16 +112,36 @@ export function ArenasManager({ arenas }: ArenasManagerProps) {
<form action={(formData) => {
startTransition(async () => {
await createArena(formData)
const form = document.getElementById('arena-form') as HTMLFormElement
form?.reset()
if (editingArena) {
await updateArena(editingArena.id, formData)
} else {
await createArena(formData)
}
cancelEdit()
})
}} id="arena-form" className="pt-6 mt-6 border-t border-border">
}} id="arena-form" className="pt-6 mt-6 border-t border-border space-y-4">
<div className="flex items-center justify-between">
<h4 className="text-sm font-bold uppercase tracking-widest text-primary">
{editingArena ? 'Editando Local' : 'Adicionar Novo Local'}
</h4>
{editingArena && (
<button
type="button"
onClick={cancelEdit}
className="text-[10px] font-bold uppercase text-muted hover:text-foreground flex items-center gap-1"
>
<X className="w-3 h-3" /> Cancelar Edição
</button>
)}
</div>
<div className="flex flex-col md:flex-row gap-4 items-end">
<div className="ui-form-field flex-1">
<label className="text-label ml-1">Nome do Local</label>
<input
name="name"
defaultValue={editingArena?.name || ''}
key={editingArena?.id || 'new'}
placeholder="Ex: Arena do Zé"
className="ui-input w-full"
required
@@ -110,6 +151,8 @@ export function ArenasManager({ arenas }: ArenasManagerProps) {
<label className="text-label ml-1">Endereço (Opcional)</label>
<input
name="address"
defaultValue={editingArena?.address || ''}
key={editingArena?.id ? `addr-${editingArena.id}` : 'new-addr'}
placeholder="Rua das Flores, 123"
className="ui-input w-full"
/>
@@ -117,14 +160,14 @@ export function ArenasManager({ arenas }: ArenasManagerProps) {
<button
type="submit"
disabled={isPending}
className="ui-button h-[42px] px-6 whitespace-nowrap"
className="ui-button h-[42px] px-6 whitespace-nowrap min-w-[140px]"
>
{isPending ? (
<Loader2 className="w-4 h-4 animate-spin mr-2" />
) : (
<Plus className="w-4 h-4 mr-2" />
editingArena ? <Pencil className="w-4 h-4 mr-2" /> : <Plus className="w-4 h-4 mr-2" />
)}
Adicionar
{editingArena ? 'Salvar' : 'Adicionar'}
</button>
</div>
</form>

View File

@@ -74,8 +74,8 @@ export function CreateFinanceEventModal({ isOpen, onClose, players }: CreateFina
const selectNone = () => setSelectedPlayers([])
return (
<div className="fixed inset-0 z-50 flex items-center justify-center p-4 bg-black/80 backdrop-blur-sm animate-in fade-in duration-200">
<div className="bg-surface border border-border rounded-xl w-full max-w-lg shadow-2xl overflow-hidden flex flex-col max-h-[90vh]">
<div className="fixed inset-0 z-[100] flex items-start justify-center p-4 bg-black/80 backdrop-blur-sm animate-in fade-in duration-200 overflow-y-auto custom-scrollbar">
<div className="bg-surface border border-border rounded-xl w-full max-w-lg shadow-2xl overflow-visible flex flex-col my-8">
<div className="p-6 border-b border-border">
<h3 className="text-lg font-bold">Novo Evento Financeiro</h3>
<p className="text-sm text-muted">Crie mensalidades, churrascos ou arrecadações.</p>
@@ -119,12 +119,14 @@ export function CreateFinanceEventModal({ isOpen, onClose, players }: CreateFina
/>
</div>
<DateTimePicker
label="Vencimento"
value={dueDate}
onChange={setDueDate}
mode="date"
/>
<div className="relative z-[100]">
<DateTimePicker
label="Vencimento"
value={dueDate}
onChange={setDueDate}
mode="date"
/>
</div>
<div className="bg-surface/50 border border-border p-4 rounded-xl space-y-4">
<div className="flex items-center justify-between">

View File

@@ -0,0 +1,214 @@
'use client'
import { useState } from 'react'
import { createTransaction } from '@/actions/finance'
import { Loader2, Plus, ArrowUpCircle, ArrowDownCircle, Calendar, User, Tag } from 'lucide-react'
import { useRouter } from 'next/navigation'
import { DateTimePicker } from '@/components/DateTimePicker'
import { clsx } from 'clsx'
interface CreateTransactionModalProps {
isOpen: boolean
onClose: () => void
players: any[]
}
export function CreateTransactionModal({ isOpen, onClose, players }: CreateTransactionModalProps) {
const router = useRouter()
const [isPending, setIsPending] = useState(false)
// Form State
const [type, setType] = useState<'INCOME' | 'EXPENSE'>('INCOME')
const [description, setDescription] = useState('')
const [amount, setAmount] = useState('')
const [category, setCategory] = useState('')
const [date, setDate] = useState(() => {
const d = new Date()
const y = d.getFullYear()
const m = (d.getMonth() + 1).toString().padStart(2, '0')
const day = d.getDate().toString().padStart(2, '0')
return `${y}-${m}-${day}`
})
const [playerId, setPlayerId] = useState('')
if (!isOpen) return null
const handleSubmit = async () => {
if (!description || !amount || !date) {
alert('Por favor, preencha a descrição, valor e data.')
return
}
setIsPending(true)
try {
const numAmount = parseFloat(amount.replace(',', '.'))
const result = await createTransaction({
description,
amount: numAmount,
type,
category,
date,
playerId: playerId || undefined
})
if (!result.success) {
alert(result.error)
return
}
// Reset form
setDescription('')
setAmount('')
setCategory('')
setPlayerId('')
onClose()
router.refresh()
} catch (error) {
console.error(error)
} finally {
setIsPending(false)
}
}
const categories = type === 'INCOME'
? ['Mensalidade', 'Avulso', 'Sobra', 'Patrocínio', 'Outros']
: ['Aluguel Quadra', 'Material', 'Churrasco', 'Arbitragem', 'Outros']
return (
<div className="fixed inset-0 z-[100] flex items-start justify-center p-4 bg-black/80 backdrop-blur-sm animate-in fade-in duration-200 overflow-y-auto custom-scrollbar">
<div className="bg-surface border border-border rounded-[2rem] w-full max-w-lg shadow-2xl overflow-visible flex flex-col my-8">
<div className="p-8 border-b border-border bg-gradient-to-br from-surface to-background">
<h3 className="text-xl font-black uppercase italic tracking-tighter">Nova Movimentação</h3>
<p className="text-xs text-muted font-bold uppercase tracking-widest mt-1">Registre entradas ou saídas do caixa.</p>
</div>
<div className="p-8 space-y-6">
{/* Type Selector */}
<div className="grid grid-cols-2 gap-3 p-1.5 bg-surface-raised rounded-2xl border border-border">
<button
onClick={() => setType('INCOME')}
className={clsx(
"flex items-center justify-center gap-2 py-3 text-xs font-black uppercase tracking-widest rounded-xl transition-all",
type === 'INCOME' ? "bg-primary text-background shadow-lg shadow-primary/20" : "text-muted hover:text-white"
)}
>
<ArrowUpCircle className="w-4 h-4" /> Entrada
</button>
<button
onClick={() => setType('EXPENSE')}
className={clsx(
"flex items-center justify-center gap-2 py-3 text-xs font-black uppercase tracking-widest rounded-xl transition-all",
type === 'EXPENSE' ? "bg-red-500 text-white shadow-lg" : "text-muted hover:text-white"
)}
>
<ArrowDownCircle className="w-4 h-4" /> Saída
</button>
</div>
<div className="space-y-4">
<div className="ui-form-field">
<label className="text-[10px] font-black uppercase tracking-[0.2em] text-muted ml-1 mb-1.5 block">Descrição</label>
<div className="relative group">
<Tag className="absolute left-4 top-1/2 -translate-y-1/2 w-4 h-4 text-muted group-focus-within:text-primary transition-colors" />
<input
value={description}
onChange={e => setDescription(e.target.value)}
placeholder="Ex: Pagamento Juiz ou Sobra Mensalidade"
className="ui-input w-full pl-11 h-12 bg-surface-raised/50 border-border/50 text-sm font-bold"
/>
</div>
</div>
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
<div className="ui-form-field">
<label className="text-[10px] font-black uppercase tracking-[0.2em] text-muted ml-1 mb-1.5 block">Valor (R$)</label>
<input
type="number"
value={amount}
onChange={e => setAmount(e.target.value)}
placeholder="0,00"
className="ui-input w-full h-12 bg-surface-raised/50 border-border/50 text-base font-black px-4"
/>
</div>
<div className="ui-form-field">
<div className="relative z-[150]">
<DateTimePicker
label="Data"
value={date}
onChange={setDate}
mode="date"
className="h-12"
/>
</div>
</div>
</div>
<div className="ui-form-field">
<label className="text-[10px] font-black uppercase tracking-[0.2em] text-muted ml-1 mb-1.5 block">Categoria</label>
<div className="flex flex-wrap gap-2">
{categories.map(cat => (
<button
key={cat}
onClick={() => setCategory(cat)}
className={clsx(
"px-4 py-2 rounded-xl text-[10px] font-black uppercase tracking-widest border transition-all",
category === cat
? "bg-primary/20 border-primary text-primary"
: "bg-surface-raised border-border text-muted hover:border-white/20"
)}
>
{cat}
</button>
))}
<input
value={!categories.includes(category) ? category : ''}
onChange={e => setCategory(e.target.value)}
placeholder="Outra..."
className="px-4 py-2 rounded-xl text-[10px] font-black uppercase tracking-widest border bg-surface-raised border-border text-muted hover:border-white/20 outline-none focus:border-primary/50 w-32"
/>
</div>
</div>
{type === 'INCOME' && (
<div className="ui-form-field">
<label className="text-[10px] font-black uppercase tracking-[0.2em] text-muted ml-1 mb-1.5 block">Atleta Relacionado (Opcional)</label>
<div className="relative group">
<User className="absolute left-4 top-1/2 -translate-y-1/2 w-4 h-4 text-muted group-focus-within:text-primary transition-colors" />
<select
value={playerId}
onChange={e => setPlayerId(e.target.value)}
className="ui-input w-full pl-11 h-12 bg-surface-raised/50 border-border/50 text-sm font-bold appearance-none"
>
<option value="">Nenhum atleta específico</option>
{players.map(p => (
<option key={p.id} value={p.id}>{p.name}</option>
))}
</select>
</div>
</div>
)}
</div>
</div>
<div className="p-8 border-t border-border flex justify-between gap-4 bg-black/40">
<button
onClick={onClose}
className="text-[10px] font-black uppercase tracking-[0.2em] text-muted hover:text-foreground px-6 py-2 transition-colors"
>
Cancelar
</button>
<button
onClick={handleSubmit}
disabled={isPending}
className="ui-button px-10 h-14 shadow-xl shadow-primary/20 font-black"
>
{isPending ? <Loader2 className="w-5 h-5 animate-spin" /> : <Plus className="w-5 h-5 mr-3" />}
REGISTRAR
</button>
</div>
</div>
</div>
)
}

View File

@@ -0,0 +1,280 @@
'use client'
import React, { useState, useMemo, useEffect, useRef } from 'react'
import { Calendar as CalendarIcon, ChevronLeft, ChevronRight, X, Check } from 'lucide-react'
import { motion, AnimatePresence } from 'framer-motion'
import { clsx } from 'clsx'
import { format, isWithinInterval, startOfDay, endOfDay, isSameDay } from 'date-fns'
import { ptBR } from 'date-fns/locale'
interface DateRangePickerProps {
startDate: string
endDate: string
onChange: (start: string, end: string) => void
label?: string
placeholder?: string
className?: string
}
export function DateRangePicker({
startDate,
endDate,
onChange,
label,
placeholder,
className
}: DateRangePickerProps) {
const [isOpen, setIsOpen] = useState(false)
const [mounted, setMounted] = useState(false)
const containerRef = useRef<HTMLDivElement>(null)
// Internal view date for calendar navigation
const [viewDate, setViewDate] = useState(() => {
if (startDate) return new Date(startDate)
return new Date()
})
useEffect(() => {
setMounted(true)
}, [])
// Close when clicking outside
useEffect(() => {
function handleClickOutside(event: MouseEvent) {
if (containerRef.current && !containerRef.current.contains(event.target as Node)) {
setIsOpen(false)
}
}
document.addEventListener('mousedown', handleClickOutside)
return () => document.removeEventListener('mousedown', handleClickOutside)
}, [])
const parseLocalDate = (dateStr: string) => {
if (!dateStr) return null
const [y, m, d] = dateStr.split('-').map(Number)
return new Date(y, m - 1, d)
}
const start = parseLocalDate(startDate)
const end = parseLocalDate(endDate)
const daysInMonth = (year: number, month: number) => new Date(year, month + 1, 0).getDate()
const firstDayOfMonth = (year: number, month: number) => new Date(year, month, 1).getDay()
const calendarDays = useMemo(() => {
const year = viewDate.getFullYear()
const month = viewDate.getMonth()
const days = []
const prevMonthDays = daysInMonth(year, month - 1)
const startDay = firstDayOfMonth(year, month)
// Previous month days
for (let i = startDay - 1; i >= 0; i--) {
days.push({
date: new Date(month === 0 ? year - 1 : year, month === 0 ? 11 : month - 1, prevMonthDays - i),
currentMonth: false
})
}
// Current month days
const currentMonthDays = daysInMonth(year, month)
for (let i = 1; i <= currentMonthDays; i++) {
days.push({
date: new Date(year, month, i),
currentMonth: true
})
}
// Next month days
const remaining = 42 - days.length
for (let i = 1; i <= remaining; i++) {
days.push({
date: new Date(month === 11 ? year + 1 : year, month === 11 ? 0 : month + 1, i),
currentMonth: false
})
}
return days
}, [viewDate])
const formatDateToLocal = (date: Date) => {
const y = date.getFullYear()
const m = (date.getMonth() + 1).toString().padStart(2, '0')
const d = date.getDate().toString().padStart(2, '0')
return `${y}-${m}-${d}`
}
const handleDateSelect = (date: Date) => {
const dateStr = formatDateToLocal(date)
// If no selection or range already complete, start fresh with both dates the same
if (!start || (start && end && !isSameDay(start, end))) {
onChange(dateStr, dateStr)
} else if (start && end && isSameDay(start, end)) {
// If already have one date (start=end), second click defines the range
if (date < start) {
onChange(dateStr, formatDateToLocal(start))
} else {
onChange(formatDateToLocal(start), dateStr)
}
} else {
// Fallback for any other state
onChange(dateStr, dateStr)
}
}
const formatDisplay = () => {
if (!startDate && !endDate) return placeholder || "Selecionar período"
const startObj = parseLocalDate(startDate)
const endObj = parseLocalDate(endDate)
const startStr = startObj ? format(startObj, 'dd/MM/yy') : '--/--/--'
const endStr = endObj ? format(endObj, 'dd/MM/yy') : '--/--/--'
return `${startStr} - ${endStr}`
}
const months = [
'Janeiro', 'Fevereiro', 'Março', 'Abril', 'Maio', 'Junho',
'Julho', 'Agosto', 'Setembro', 'Outubro', 'Novembro', 'Dezembro'
]
const nextMonth = () => {
setViewDate(new Date(viewDate.getFullYear(), viewDate.getMonth() + 1, 1))
}
const prevMonth = () => {
setViewDate(new Date(viewDate.getFullYear(), viewDate.getMonth() - 1, 1))
}
return (
<div className={clsx("ui-form-field", className)} ref={containerRef}>
{label && <label className="text-[10px] font-bold text-muted/60 uppercase ml-1 mb-1 block">{label}</label>}
<div className="relative">
<div
onClick={() => setIsOpen(!isOpen)}
className={clsx(
"ui-input w-full h-9 flex items-center justify-between cursor-pointer transition-all bg-background border-border/40",
isOpen ? "border-primary ring-1 ring-primary/20 shadow-lg shadow-primary/5" : ""
)}
>
<div className="flex items-center gap-2">
<CalendarIcon className={clsx("w-3.5 h-3.5 transition-colors", isOpen ? "text-primary" : "text-muted")} />
<span className={clsx("text-xs transition-colors", !startDate && "text-muted/60")}>
{!mounted ? (placeholder || "Selecionar período") : formatDisplay()}
</span>
</div>
{(startDate || endDate) && (
<button
onClick={(e) => {
e.stopPropagation()
onChange('', '')
}}
className="p-1 hover:bg-white/10 rounded-md text-muted hover:text-foreground transition-colors"
>
<X className="w-3 h-3" />
</button>
)}
</div>
<AnimatePresence>
{isOpen && (
<motion.div
initial={{ opacity: 0, y: 10, scale: 0.95 }}
animate={{ opacity: 1, y: 0, scale: 1 }}
exit={{ opacity: 0, scale: 0.95 }}
className="absolute top-full left-0 mt-2 z-[100] bg-surface-raised border border-border shadow-2xl rounded-2xl overflow-hidden w-[310px]"
>
<div className="p-6 bg-surface shadow-[0_0_50px_rgba(0,0,0,0.5)]">
<div className="flex items-center justify-between mb-6">
<div className="space-y-0.5">
<p className="text-base font-black uppercase italic tracking-tighter leading-none text-white">
{months[viewDate.getMonth()]} <span className="text-primary/60">{viewDate.getFullYear()}</span>
</p>
</div>
<div className="flex gap-1">
<button
type="button"
onClick={(e) => { e.stopPropagation(); prevMonth(); }}
className="p-1.5 bg-white/5 hover:bg-white/10 border border-white/5 rounded-lg transition-all text-muted hover:text-primary active:scale-90"
>
<ChevronLeft className="w-3.5 h-3.5" />
</button>
<button
type="button"
onClick={(e) => { e.stopPropagation(); nextMonth(); }}
className="p-1.5 bg-white/5 hover:bg-white/10 border border-white/5 rounded-lg transition-all text-muted hover:text-primary active:scale-90"
>
<ChevronRight className="w-3.5 h-3.5" />
</button>
</div>
</div>
<div className="grid grid-cols-7 gap-1 mb-1">
{['D', 'S', 'T', 'Q', 'Q', 'S', 'S'].map((d, i) => (
<div key={i} className="text-[8px] font-black text-muted/30 text-center uppercase tracking-widest py-1">
{d}
</div>
))}
</div>
<div className="grid grid-cols-7 gap-1">
{calendarDays.map((d, i) => {
const isSelected = (start && isSameDay(d.date, start)) || (end && isSameDay(d.date, end))
const isInRange = start && end && isWithinInterval(d.date, { start, end })
const isToday = isSameDay(new Date(), d.date)
return (
<button
key={i}
type="button"
onClick={() => handleDateSelect(d.date)}
className={clsx(
"w-full aspect-square text-[10px] font-bold rounded-lg transition-all flex items-center justify-center relative border overflow-hidden",
d.currentMonth ? "text-foreground" : "text-muted/10",
isSelected
? "bg-primary text-background border-primary shadow-[0_0_15px_rgba(16,185,129,0.4)] z-10"
: isInRange
? "bg-primary/20 border-primary/20 text-primary rounded-none shadow-inner"
: "bg-surface-raised border-white/5 hover:border-primary/40 hover:bg-primary/5 hover:text-primary",
!isSelected && isToday && "border-primary/40 ring-1 ring-primary/20",
isInRange && isSameDay(d.date, start!) && "rounded-l-lg",
isInRange && isSameDay(d.date, end!) && "rounded-r-lg"
)}
>
<span className="relative z-10">{d.date.getDate()}</span>
</button>
)
})}
</div>
<div className="mt-4 flex gap-2">
<button
type="button"
onClick={() => {
const today = new Date()
const todayStr = formatDateToLocal(today)
onChange(todayStr, todayStr)
setIsOpen(false)
}}
className="flex-1 py-1.5 bg-white/5 hover:bg-white/10 rounded-lg text-[9px] font-black uppercase tracking-widest transition-all"
>
Hoje
</button>
<button
type="button"
onClick={() => setIsOpen(false)}
className="flex-1 py-1.5 bg-primary text-background rounded-lg text-[9px] font-black uppercase tracking-widest transition-all"
>
Pronto
</button>
</div>
</div>
</motion.div>
)}
</AnimatePresence>
</div>
</div>
)
}

View File

@@ -12,6 +12,7 @@ interface DateTimePickerProps {
placeholder?: string
required?: boolean
mode?: 'date' | 'datetime'
className?: string
}
export function DateTimePicker({
@@ -20,7 +21,8 @@ export function DateTimePicker({
label,
placeholder,
required,
mode = 'datetime'
mode = 'datetime',
className
}: DateTimePickerProps) {
const [isOpen, setIsOpen] = useState(false)
const [mounted, setMounted] = useState(false)
@@ -112,15 +114,18 @@ export function DateTimePicker({
}, [viewDate])
const handleDateSelect = (day: number, month: number, year: number) => {
const newDate = parseValue(value)
newDate.setFullYear(year)
newDate.setMonth(month)
newDate.setDate(day)
if (mode === 'date') {
onChange(newDate.toISOString().split('T')[0])
const y = year
const m = (month + 1).toString().padStart(2, '0')
const d = day.toString().padStart(2, '0')
onChange(`${y}-${m}-${d}`)
setIsOpen(false)
} else {
const newDate = parseValue(value)
newDate.setFullYear(year)
newDate.setMonth(month)
newDate.setDate(day)
// If it's the first time selecting in datetime mode, set a default time
if (!value) {
newDate.setHours(19, 0, 0, 0)
@@ -169,7 +174,7 @@ export function DateTimePicker({
const minutes = Array.from({ length: 12 }, (_, i) => i * 5)
return (
<div className="ui-form-field" ref={containerRef}>
<div className={clsx("ui-form-field", className)} ref={containerRef}>
{label && <label className="text-label ml-1">{label}</label>}
<div className="relative">
<div
@@ -258,7 +263,7 @@ export function DateTimePicker({
"w-full aspect-square text-xs font-black rounded-xl transition-all flex items-center justify-center relative border overflow-hidden group",
d.currentMonth ? "text-foreground" : "text-muted/10",
isSelected
? "bg-primary text-black border-primary shadow-[0_0_15px_rgba(var(--primary-rgb),0.3)] scale-105 z-10"
? "bg-primary text-background border-primary shadow-[0_0_15px_rgba(var(--primary-rgb),0.3)] scale-105 z-10"
: "bg-white/[0.02] border-white/5 hover:border-primary/40 hover:bg-primary/5 hover:text-primary",
!isSelected && isToday && "border-primary/20 after:content-[''] after:absolute after:bottom-1.5 after:w-1 after:h-1 after:bg-primary after:rounded-full"
)}
@@ -334,7 +339,7 @@ export function DateTimePicker({
<button
type="button"
onClick={() => setIsOpen(false)}
className="mt-6 w-full h-11 bg-white text-black font-black uppercase text-[10px] tracking-widest rounded-xl hover:scale-[1.02] active:scale-[0.98] transition-all shadow-xl shadow-black/20"
className="mt-6 w-full h-11 bg-foreground text-background font-black uppercase text-[10px] tracking-widest rounded-xl hover:scale-[1.02] active:scale-[0.98] transition-all shadow-xl shadow-black/20"
>
Confirmar
</button>

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@@ -1,7 +1,7 @@
'use client'
import React, { useState, useMemo, useEffect } from 'react'
import { Calendar, Users, Trophy, ChevronRight, X, Clock, ExternalLink, Star, Link as LinkIcon, MapPin, Share2, Shuffle, Trash2, MessageCircle, Repeat, Search, LayoutGrid, List, Check } from 'lucide-react'
import { Calendar, Users, Trophy, ChevronRight, X, Clock, ExternalLink, Star, Link as LinkIcon, MapPin, Share2, Shuffle, Trash2, MessageCircle, Repeat, Search, LayoutGrid, List, Check, Pencil, Zap } from 'lucide-react'
import { motion, AnimatePresence } from 'framer-motion'
import { clsx } from 'clsx'
import Link from 'next/link'
@@ -150,10 +150,36 @@ export function MatchHistory({ matches, players = [], groupName = 'Pelada' }: {
const getStatusInfo = (status: string) => {
switch (status) {
case 'SCHEDULED': return { label: 'Agendado', color: 'bg-blue-500/10 text-blue-500 border-blue-500/20' }
case 'IN_PROGRESS': return { label: 'Em Andamento', color: 'bg-primary/10 text-primary border-primary/20' }
case 'COMPLETED': return { label: 'Concluído', color: 'bg-white/5 text-muted border-white/10' }
default: return { label: status, color: 'bg-white/5 text-muted border-white/10' }
case 'CONVOCACAO': return {
label: 'Convocação',
color: 'bg-blue-500/10 text-blue-500 border-blue-500/20',
actionLabel: 'Clique para Sortear',
icon: Shuffle
}
case 'SORTEIO': return {
label: 'Times Definidos',
color: 'bg-amber-500/10 text-amber-500 border-amber-500/20',
actionLabel: 'Gerar Capas / Iniciar Resenha',
icon: Zap
}
case 'IN_PROGRESS': return {
label: 'Votação Aberta',
color: 'bg-orange-500/10 text-orange-500 border-orange-500/20',
actionLabel: 'Ver Acompanhamento',
icon: Users
}
case 'ENCERRAMENTO': return {
label: 'Resenha Finalizada',
color: 'bg-emerald-500/10 text-emerald-500 border-emerald-500/20',
actionLabel: 'Ver Resultados',
icon: Trophy
}
default: return {
label: status,
color: 'bg-white/5 text-muted border-white/10',
actionLabel: 'Gerenciar',
icon: Pencil
}
}
}
@@ -179,32 +205,44 @@ export function MatchHistory({ matches, players = [], groupName = 'Pelada' }: {
}
const shareWhatsAppList = (match: any) => {
const confirmed = (match.attendances || []).filter((a: any) => a.status === 'CONFIRMED')
const confirmedIds = new Set(confirmed.map((a: any) => a.playerId))
const pending = players.filter(p => !confirmedIds.has(p.id))
const url = `${window.location.origin}/match/${match.id}/confirmacao`
const dateStr = new Date(match.date).toLocaleDateString('pt-BR', { weekday: 'long', day: 'numeric', month: 'short' })
const timeStr = new Date(match.date).toLocaleTimeString('pt-BR', { hour: '2-digit', minute: '2-digit' })
const url = `${window.location.origin}/match/${match.id}/confirmacao`
const finalGroupName = (match.group?.name || groupName).toUpperCase()
const finalGroupName = match.group?.name || groupName
let text = ''
const text = `⚽ *LISTA DE PRESENÇA: ${finalGroupName.toUpperCase()}* ⚽\n\n` +
`📅 *JOGO:* ${dateStr} às ${timeStr}\n` +
`📍 *LOCAL:* ${match.location || 'A definir'}\n\n` +
`✅ *CONFIRMADOS (${confirmed.length}/${match.maxPlayers || '∞'}):*\n` +
(confirmed.length > 0
? confirmed.map((a: any) => `${a.player.name}`).join('\n')
: "_Nenhuma confirmação ainda_") +
`\n\n⏳ *AGUARDANDO:* \n` +
(pending.length > 0
? pending.map((p: any) => `▫️ ${p.name}`).join('\n')
: "_Todos confirmados!_") +
`\n\n🔗 *Confirme sua presença aqui:* ${url}`
if (match.status === 'CONVOCACAO') {
const confirmed = (match.attendances || []).filter((a: any) => a.status === 'CONFIRMED')
const confirmedIds = new Set(confirmed.map((a: any) => a.playerId))
const pending = players.filter(p => !confirmedIds.has(p.id))
text = `⚽ *LISTA DE PRESENÇA: ${finalGroupName}* ⚽\n\n` +
`📅 *JOGO:* ${dateStr} às ${timeStr}\n` +
`📍 *LOCAL:* ${match.location || 'A definir'}\n\n` +
`✅ *CONFIRMADOS (${confirmed.length}/${match.maxPlayers || '∞'}):*\n` +
(confirmed.length > 0
? confirmed.map((a: any) => `${a.player.name}`).join('\n')
: "_Nenhuma confirmação ainda_") +
`\n\n⏳ *AGUARDANDO:* \n` +
(pending.length > 0
? pending.map((p: any) => `▫️ ${p.name}`).join('\n')
: "_Todos confirmados!_") +
`\n\n🔗 *Confirme sua presença aqui:* ${url}`
} else {
// SORTEIO or ENCERRAMENTO
text = `⚽ *SORTEIO REALIZADO: ${finalGroupName}* ⚽\n\n` +
`📅 *JOGO:* ${dateStr} às ${timeStr}\n` +
`📍 *LOCAL:* ${match.location || 'A definir'}\n\n` +
(match.teams || []).map((team: any) => {
const playersList = (team.players || []).map((p: any) => `▫️ ${p.player.name}`).join('\n')
return `👕 *${team.name.toUpperCase()}:*\n${playersList}`
}).join('\n\n') +
`\n\n🔗 *Veja os detalhes e vote no craque:* ${url}`
}
navigator.clipboard.writeText(text)
setCopySuccess('Lista formatada copiada!')
setCopySuccess('Texto para WhatsApp copiado!')
setTimeout(() => setCopySuccess(null), 2000)
window.open(`https://api.whatsapp.com/send?text=${encodeURIComponent(text)}`, '_blank')
@@ -359,7 +397,7 @@ export function MatchHistory({ matches, players = [], groupName = 'Pelada' }: {
<div className="space-y-1 min-w-0 flex-1">
<div className="flex items-center gap-2 flex-wrap">
<p className="text-sm font-semibold truncate">
{match.status === 'SCHEDULED' ? `Evento: ${match.location || 'Sem local'}` : `Sorteio de ${match.teams.length} times`}
{match.status === 'CONVOCACAO' ? `Evento: ${match.location || 'Sem local'}` : `Sorteio de ${match.teams.length} times`}
</p>
{match.isRecurring && (
<span className="badge bg-purple-500/10 text-purple-500 border-purple-500/20 px-1.5 py-0.5" title="Recorrente">
@@ -367,18 +405,43 @@ export function MatchHistory({ matches, players = [], groupName = 'Pelada' }: {
</span>
)}
</div>
<div className="flex gap-1.5 flex-wrap">
<span className={clsx("badge", s.color)}>{s.label}</span>
</div>
</div>
</div>
<div className={clsx("flex flex-wrap items-center gap-3 text-[11px] text-muted", viewMode === 'list' ? "" : "border-t border-border/50 pt-4 mt-auto")}>
<div className="flex items-center gap-1">
<Users className="w-3 h-3" />
{match.status === 'SCHEDULED'
? `${(match.attendances || []).filter((a: any) => a.status === 'CONFIRMED').length} confirmados`
: `${(match.teams || []).reduce((acc: number, t: any) => acc + t.players.length, 0)} jogadores`}
{viewMode === 'grid' && (
<div className="w-full mb-3">
<Link
href={`/dashboard/matches/new?id=${match.id}`}
onClick={(e) => e.stopPropagation()}
className={clsx(
"w-full flex items-center justify-center gap-2 py-2.5 rounded-xl border text-[10px] font-black uppercase tracking-widest transition-all hover:shadow-lg shadow-sm border-white/5",
s.color
)}
>
<s.icon className="w-4 h-4" />
{s.actionLabel}
</Link>
</div>
)}
<div className="flex items-center gap-3">
<div className="flex items-center gap-2">
<div className="flex items-center gap-1">
<Users className="w-3 h-3" />
<span className="font-bold">
{match.status === 'CONVOCACAO'
? `${(match.attendances || []).filter((a: any) => a.status === 'CONFIRMED').length} confirmados`
: `${(match.teams || []).reduce((acc: number, t: any) => acc + t.players.length, 0)} jogadores`}
</span>
</div>
<div className={clsx(
"px-2 py-0.5 rounded-full text-[8px] font-black uppercase tracking-widest border",
s.color
)}>
{s.label}
</div>
</div>
</div>
<div className="w-1 h-1 rounded-full bg-border" />
<div className="flex items-center gap-1">
@@ -388,20 +451,33 @@ export function MatchHistory({ matches, players = [], groupName = 'Pelada' }: {
{/* Actions only in List Mode here, else in modal */}
{viewMode === 'list' && (
<div className="ml-auto flex items-center gap-2 opacity-0 group-hover:opacity-100 transition-opacity">
{match.status === 'SCHEDULED' && (
<div className="ml-auto flex items-center gap-4 opacity-0 group-hover:opacity-100 transition-all duration-300">
<Link
href={`/dashboard/matches/new?id=${match.id}`}
onClick={(e) => e.stopPropagation()}
className={clsx(
"flex items-center gap-2 px-4 py-2 rounded-xl border text-[9px] font-black uppercase tracking-[0.15em] transition-all hover:scale-105 active:scale-95 shadow-lg",
s.color
)}
title={s.actionLabel}
>
<s.icon className="w-3.5 h-3.5" />
<span>{s.actionLabel}</span>
</Link>
{match.status === 'CONVOCACAO' && (
<button
onClick={(e) => {
e.stopPropagation()
copyMatchLink(match)
}}
className="p-1.5 text-primary hover:bg-primary/10 rounded transition-colors"
className="p-2 text-muted hover:text-primary hover:bg-primary/10 rounded-lg transition-colors border border-transparent hover:border-primary/20"
title="Copiar Link de Confirmação"
>
<LinkIcon className="w-4 h-4" />
</button>
)}
<ChevronRight className="w-4 h-4" />
<ChevronRight className="w-4 h-4 text-muted/40" />
</div>
)}
</div>
@@ -478,6 +554,17 @@ export function MatchHistory({ matches, players = [], groupName = 'Pelada' }: {
</p>
</div>
<div className="flex items-center gap-2">
<Link
href={`/dashboard/matches/new?id=${selectedMatch.id}`}
className={clsx(
"flex items-center gap-2 px-4 py-2 rounded-xl border text-[10px] font-black uppercase tracking-widest transition-all hover:scale-105 active:scale-95 shadow-md",
getStatusInfo(selectedMatch.status).color
)}
title={getStatusInfo(selectedMatch.status).actionLabel}
>
{React.createElement(getStatusInfo(selectedMatch.status).icon, { className: "w-4 h-4" })}
<span>{getStatusInfo(selectedMatch.status).actionLabel}</span>
</Link>
<button
onClick={() => handleDeleteMatch(selectedMatch.id)}
className="p-2.5 text-muted hover:text-red-500 transition-colors rounded-lg"
@@ -495,7 +582,7 @@ export function MatchHistory({ matches, players = [], groupName = 'Pelada' }: {
</div>
<div className="p-6 overflow-y-auto custom-scrollbar">
{selectedMatch.status === 'SCHEDULED' ? (
{selectedMatch.status === 'CONVOCACAO' ? (
<div className="space-y-6">
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
<div className="ui-card p-4 bg-surface-raised/30">
@@ -631,6 +718,17 @@ export function MatchHistory({ matches, players = [], groupName = 'Pelada' }: {
</div>
))}
</div>
{selectedMatch.status === ('SORTEIO' as any) && (
<div className="pt-4">
<Link
href={`/dashboard/matches/new?id=${selectedMatch.id}`}
className="ui-button w-full h-12 text-sm font-bold bg-primary text-background shadow-lg shadow-primary/20"
>
<Zap className="w-4 h-4 mr-2" /> Iniciar Gamificação & Votação
</Link>
</div>
)}
</div>
)}
</div>

View File

@@ -0,0 +1,199 @@
'use client'
import React from 'react'
import { motion } from 'framer-motion'
import { Trophy, Medal, Star, Crown, Zap, Shield, Sparkles } from 'lucide-react'
import { clsx } from 'clsx'
interface PlayerResult {
player: {
id: string
name: string
number?: number | string
position?: string
level?: number
}
craque: number
pereba: number
fairPlay: number
}
interface MatchPodiumProps {
results: PlayerResult[]
context?: 'dashboard' | 'public'
}
export function MatchPodium({ results, context = 'public' }: MatchPodiumProps) {
// Calculando saldo e ordenando (caso não venha ordenado)
// Critério: (Craque - Pereba) DESC, depois Craque DESC, depois FairPlay DESC
const sortedResults = [...results].sort((a, b) => {
const scoreA = a.craque - a.pereba
const scoreB = b.craque - b.pereba
if (scoreB !== scoreA) return scoreB - scoreA
if (b.craque !== a.craque) return b.craque - a.craque
return b.fairPlay - a.fairPlay
})
const top3 = sortedResults.slice(0, 3)
// Reorganizar para ordem visual: 2º, 1º, 3º
const podiumOrder = [
top3[1], // 2nd Place (Left)
top3[0], // 1st Place (Center)
top3[2] // 3rd Place (Right)
].filter(Boolean) // Remove undefined if less than 3 players
const getInitials = (name: string) => name.split(' ').slice(0, 2).map(n => n[0]).join('').toUpperCase()
const PodiumItem = ({ result, place, index }: { result: PlayerResult, place: 1 | 2 | 3, index: number }) => {
if (!result) return <div className="w-full" />
const isWinner = place === 1
return (
<motion.div
initial={{ opacity: 0, y: 50 }}
animate={{ opacity: 1, y: 0 }}
transition={{
delay: index * 0.2 + 0.5,
type: "spring",
stiffness: 100
}}
className={clsx(
"flex flex-col items-center relative z-10",
isWinner ? "-mt-6 mb-6 md:-mt-10 md:mb-10 order-1 md:order-2 w-full md:w-1/3" : "mt-0 order-2 md:order-none w-1/2 md:w-1/4",
place === 3 && "order-3"
)}
>
{/* Crown for Winner */}
{isWinner && (
<motion.div
initial={{ opacity: 0, scale: 0, rotate: -45 }}
animate={{ opacity: 1, scale: 1, rotate: 0 }}
transition={{ delay: 1.2, type: "spring" }}
className="mb-2 md:mb-4 relative"
>
<Crown className="w-8 h-8 md:w-12 md:h-12 text-yellow-400 fill-yellow-400 drop-shadow-[0_0_15px_rgba(250,204,21,0.6)]" />
<motion.div
animate={{ rotate: 360 }}
transition={{ duration: 10, repeat: Infinity, ease: "linear" }}
className="absolute -inset-10 bg-gradient-to-t from-yellow-500/20 to-transparent blur-xl rounded-full"
/>
</motion.div>
)}
{/* Avatar / Card */}
<div className={clsx(
"relative rounded-2xl md:rounded-3xl border flex flex-col items-center justify-center shadow-2xl backdrop-blur-md transition-all",
isWinner
? "w-28 h-28 md:w-48 md:h-48 bg-gradient-to-b from-yellow-500/10 to-transparent border-yellow-500/30"
: place === 2
? "w-20 h-20 md:w-36 md:h-36 bg-gradient-to-b from-slate-300/10 to-transparent border-slate-300/20"
: "w-20 h-20 md:w-36 md:h-36 bg-gradient-to-b from-orange-700/10 to-transparent border-orange-700/20"
)}>
{isWinner && <div className="absolute inset-0 bg-yellow-400/5 blur-2xl rounded-full animate-pulse" />}
<div className="text-2xl md:text-5xl font-black italic relative z-10">
<span className={clsx(
"drop-shadow-lg",
isWinner ? "text-yellow-400" : place === 2 ? "text-slate-300" : "text-orange-400"
)}>
{result.player.number || getInitials(result.player.name)}
</span>
</div>
{/* Badge Place */}
<div className={clsx(
"absolute -bottom-3 px-3 py-1 md:px-4 md:py-1.5 rounded-full border text-[8px] md:text-xs font-black uppercase tracking-widest shadow-lg flex items-center gap-1 md:gap-2 whitespace-nowrap",
isWinner
? "bg-yellow-500 text-background border-yellow-400"
: place === 2
? "bg-slate-300 text-background border-slate-200"
: "bg-orange-600 text-white border-orange-500"
)}>
{place}º LUGAR
</div>
</div>
{/* Name & Stats */}
<div className="text-center mt-4 md:mt-8 space-y-1 md:space-y-2 w-full px-1">
<h3 className={clsx(
"font-black uppercase italic tracking-tighter truncate w-full",
isWinner ? "text-lg md:text-4xl text-white" : "text-xs md:text-xl text-muted"
)}>
{result.player.name}
{isWinner && <Sparkles className="inline-block w-3 h-3 md:w-6 md:h-6 text-yellow-400 ml-1 md:ml-2 animate-bounce" />}
</h3>
<div className="flex items-center justify-center gap-2 md:gap-4">
<div className="flex flex-col items-center">
<span className="text-[8px] md:text-[10px] uppercase font-bold text-muted">Saldo</span>
<span className={clsx("text-sm md:text-2xl font-black", (result.craque - result.pereba) > 0 ? "text-emerald-500" : "text-red-500")}>
{result.craque - result.pereba}
</span>
</div>
<div className="w-px h-6 md:h-8 bg-white/10" />
<div className="flex items-center gap-1 md:gap-2 text-[8px] md:text-xs">
<div className="flex flex-col items-center">
<Star className="w-2.5 h-2.5 md:w-4 md:h-4 text-emerald-500 mb-0.5 md:mb-1" />
<span className="font-bold">{result.craque}</span>
</div>
<div className="flex flex-col items-center">
<Zap className="w-2.5 h-2.5 md:w-4 md:h-4 text-red-500 mb-0.5 md:mb-1" />
<span className="font-bold">{result.pereba}</span>
</div>
</div>
</div>
</div>
</motion.div>
)
}
return (
<div className="w-full space-y-12">
{/* TOP 3 PODIUM */}
<div className="flex flex-wrap items-end justify-center gap-4 md:gap-8 pt-10 min-h-[300px] md:min-h-[400px]">
{/* 2nd Place */}
{podiumOrder[0] && <PodiumItem result={podiumOrder[0]} place={2} index={1} />}
{/* 1st Place */}
{podiumOrder[1] && <PodiumItem result={podiumOrder[1]} place={1} index={0} />}
{/* 3rd Place */}
{podiumOrder[2] && <PodiumItem result={podiumOrder[2]} place={3} index={2} />}
</div>
{/* OTHER PLAYERS LIST */}
{sortedResults.length > 3 && (
<motion.div
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
transition={{ delay: 2 }}
className="max-w-3xl mx-auto pt-8 border-t border-white/5"
>
<h4 className="text-center text-xs font-black uppercase tracking-[0.3em] text-muted mb-6">Demais Classificados</h4>
<div className="grid grid-cols-1 md:grid-cols-2 gap-3">
{sortedResults.slice(3).map((res, i) => (
<div key={res.player.id} className="flex items-center justify-between p-4 bg-white/5 rounded-xl border border-white/5 hover:bg-white/10 transition-colors">
<div className="flex items-center gap-4">
<span className="text-xs font-black text-muted w-6">#{i + 4}</span>
<div className="flex items-center gap-3">
<div className="w-8 h-8 rounded-lg bg-black/20 flex items-center justify-center font-bold text-[10px]">
{res.player.number || getInitials(res.player.name)}
</div>
<span className="text-xs font-bold uppercase">{res.player.name}</span>
</div>
</div>
<div className="flex items-center gap-4">
<div className="text-right">
<span className={clsx("text-sm font-black", (res.craque - res.pereba) > 0 ? "text-emerald-500" : "text-white/50")}>
{res.craque - res.pereba} pts
</span>
</div>
</div>
</div>
))}
</div>
</motion.div>
)}
</div>
)
}

View File

@@ -0,0 +1,301 @@
'use client'
import React, { useState, useMemo, useEffect } from 'react'
import { Calendar, MapPin, ArrowRight, Trophy, Repeat, Hash, ChevronRight } from 'lucide-react'
import { createScheduledMatch } from '@/actions/match'
import { useRouter } from 'next/navigation'
import type { Arena } from '@prisma/client'
import { DateTimePicker } from '@/components/DateTimePicker'
import { motion, AnimatePresence } from 'framer-motion'
import { clsx } from 'clsx'
interface MatchSchedulerProps {
arenas: Arena[]
}
export function MatchScheduler({ arenas }: MatchSchedulerProps) {
const router = useRouter()
const [date, setDate] = useState('')
const [location, setLocation] = useState('')
const [selectedArenaId, setSelectedArenaId] = useState('')
const [maxPlayers, setMaxPlayers] = useState('24')
const [isRecurring, setIsRecurring] = useState(false)
const [recurrenceInterval, setRecurrenceInterval] = useState<'WEEKLY' | 'MONTHLY' | 'YEARLY'>('WEEKLY')
const [recurrenceEndDate, setRecurrenceEndDate] = useState('')
const [isSubmitting, setIsSubmitting] = useState(false)
const previewDates = useMemo(() => {
if (!date || !isRecurring) return []
const dates: Date[] = []
const startDate = new Date(date)
let currentDate = new Date(startDate)
// Increment based on interval
const advanceDate = (d: Date) => {
const next = new Date(d)
if (recurrenceInterval === 'WEEKLY') next.setDate(next.getDate() + 7)
else if (recurrenceInterval === 'MONTHLY') next.setMonth(next.getMonth() + 1)
else if (recurrenceInterval === 'YEARLY') next.setFullYear(next.getFullYear() + 1)
return next
}
currentDate = advanceDate(currentDate)
let endDate: Date
if (recurrenceEndDate) {
endDate = new Date(`${recurrenceEndDate}T23:59:59`)
} else {
// Preview next 4 occurrences
let previewEnd = new Date(startDate)
for (let i = 0; i < 4; i++) previewEnd = advanceDate(previewEnd)
endDate = previewEnd
}
while (currentDate <= endDate) {
dates.push(new Date(currentDate))
currentDate = advanceDate(currentDate)
if (dates.length > 10) break
}
return dates
}, [date, isRecurring, recurrenceInterval, recurrenceEndDate])
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault()
if (!date) return
setIsSubmitting(true)
try {
await createScheduledMatch(
'',
date,
location,
parseInt(maxPlayers) || 0,
isRecurring,
recurrenceInterval,
recurrenceEndDate || undefined,
selectedArenaId
)
router.push('/dashboard/matches')
} catch (error) {
console.error(error)
alert('Erro ao agendar evento')
} finally {
setIsSubmitting(false)
}
}
const intervals = [
{ id: 'WEEKLY', label: 'Semanal', desc: 'Toda semana' },
{ id: 'MONTHLY', label: 'Mensal', desc: 'Todo mês' },
{ id: 'YEARLY', label: 'Anual', desc: 'Todo ano' },
] as const
return (
<div className="max-w-4xl mx-auto pb-20 animate-in fade-in slide-in-from-bottom-4 duration-700">
<header className="flex flex-col md:flex-row md:items-end justify-between gap-6 mb-12">
<div className="space-y-2">
<div className="inline-flex items-center gap-2 text-primary font-black uppercase tracking-widest text-[10px] mb-2 px-3 py-1 bg-primary/10 rounded-full border border-primary/20">
<Calendar className="w-3 h-3" />
Agendamento
</div>
<h1 className="text-4xl font-black tracking-tighter uppercase leading-none">
Criar <span className="text-primary text-outline-sm">Novo Evento</span>
</h1>
<p className="text-muted text-sm font-medium">Configure as datas e crie links de confirmação automáticos.</p>
</div>
</header>
<div className="grid grid-cols-1 lg:grid-cols-5 gap-8">
<form onSubmit={handleSubmit} className="lg:col-span-3 space-y-6">
<section className="ui-card p-6 space-y-6 bg-surface-raised/30 border-border/40">
<div className="flex items-center gap-3 mb-2">
<div className="w-8 h-8 rounded-lg bg-primary/10 flex items-center justify-center border border-primary/20">
<Trophy className="w-4 h-4 text-primary" />
</div>
<h2 className="text-sm font-black uppercase tracking-widest">Detalhes do Evento</h2>
</div>
<div className="relative z-[110]">
<DateTimePicker
label="Data e Horário de Início"
value={date}
onChange={setDate}
required
/>
</div>
<div className="ui-form-field">
<label className="text-label ml-1">Arena ou Local</label>
<div className="space-y-3">
<div className="relative group">
<MapPin className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-muted group-focus-within:text-primary transition-colors z-10" />
<select
value={selectedArenaId}
onChange={(e) => {
setSelectedArenaId(e.target.value)
const arena = arenas.find(a => a.id === e.target.value)
if (arena) setLocation(arena.name)
}}
className="ui-input w-full pl-10 h-12 bg-surface text-sm appearance-none"
>
<option value="">Selecione uma Arena Salva...</option>
{arenas.map(a => (
<option key={a.id} value={a.id}>{a.name}</option>
))}
</select>
<ChevronRight className="absolute right-3 top-1/2 -translate-y-1/2 w-4 h-4 text-muted rotate-90 pointer-events-none" />
</div>
<input
required={!selectedArenaId}
type="text"
placeholder={selectedArenaId ? "Complemento do local (opcional)" : "Ou digite um local personalizado..."}
value={location}
onChange={(e) => setLocation(e.target.value)}
className="ui-input w-full h-12 bg-surface/50 text-sm"
/>
</div>
</div>
<div className="ui-form-field">
<label className="text-label ml-1">Capacidade</label>
<div className="relative group">
<Hash className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-muted group-focus-within:text-primary transition-colors" />
<input
type="number"
placeholder="Ex: 24 (Deixe vazio para ilimitado)"
value={maxPlayers}
onChange={(e) => setMaxPlayers(e.target.value)}
className="ui-input w-full pl-10 h-12 bg-surface text-sm"
/>
</div>
</div>
</section>
<section className="ui-card p-6 space-y-6 bg-surface-raised/30 border-border/40 relative overflow-visible">
<div className="flex items-center justify-between">
<div className="flex items-center gap-3">
<div className="w-8 h-8 rounded-lg bg-purple-500/10 flex items-center justify-center border border-purple-500/20 text-purple-500">
<Repeat className="w-4 h-4" />
</div>
<h2 className="text-sm font-black uppercase tracking-widest">Recorrência</h2>
</div>
<button
type="button"
onClick={() => setIsRecurring(!isRecurring)}
className={clsx(
"relative inline-flex h-6 w-11 items-center rounded-full transition-colors focus:outline-none ring-offset-2 ring-primary/20",
isRecurring ? "bg-primary" : "bg-zinc-800"
)}
>
<span className={clsx(
"inline-block h-4 w-4 transform rounded-full bg-white transition-transform duration-200",
isRecurring ? "translate-x-6" : "translate-x-1"
)} />
</button>
</div>
<AnimatePresence>
{isRecurring && (
<motion.div
initial={{ height: 0, opacity: 0 }}
animate={{ height: 'auto', opacity: 1 }}
exit={{ height: 0, opacity: 0 }}
className="space-y-6 pt-2"
>
<div className="grid grid-cols-3 gap-3">
{intervals.map((int) => (
<button
key={int.id}
type="button"
onClick={() => setRecurrenceInterval(int.id as any)}
className={clsx(
"p-4 rounded-xl border flex flex-col items-center gap-1 transition-all",
recurrenceInterval === int.id
? "bg-primary/10 border-primary text-primary shadow-lg shadow-primary/5"
: "bg-surface border-border hover:border-zinc-700 text-muted"
)}
>
<span className="text-[10px] font-black uppercase tracking-widest">{int.label}</span>
<span className="text-[9px] opacity-60 font-medium">{int.desc}</span>
</button>
))}
</div>
<div className="space-y-2 relative z-[100]">
<DateTimePicker
label="Data Limite"
value={recurrenceEndDate}
onChange={setRecurrenceEndDate}
mode="date"
placeholder="Selecione a data limite"
/>
<p className="text-[10px] text-muted mt-2 px-1">
Deixe em branco para repetir indefinidamente.
</p>
</div>
</motion.div>
)}
</AnimatePresence>
</section>
<button
type="submit"
disabled={isSubmitting || !date}
className="ui-button w-full h-14 text-sm font-black uppercase tracking-[0.2em] shadow-xl shadow-primary/20 relative group overflow-hidden"
>
<span className="relative z-10 flex items-center justify-center gap-2">
{isSubmitting ? 'Agendando...' : 'Confirmar Agendamento'}
{!isSubmitting && <ArrowRight className="w-4 h-4 group-hover:translate-x-1 transition-transform" />}
</span>
</button>
</form>
<aside className="lg:col-span-2 space-y-6">
<div className="ui-card p-6 border-emerald-500/20 bg-emerald-500/5 sticky top-24">
<div className="flex items-center gap-3 mb-4">
<div className="w-10 h-10 rounded-xl bg-emerald-500/20 flex items-center justify-center text-emerald-500">
<Trophy className="w-5 h-5" />
</div>
<h3 className="font-bold text-sm uppercase tracking-tight">Próximos Passos</h3>
</div>
<div className="space-y-4">
{[
{ title: 'Link Gerado', desc: 'Será criada uma página de confirmação para compartilhar com a galera.' },
{ title: 'Gestão Automática', desc: 'O sistema controla quem confirmou e quem ainda está pendente.' },
].map((step, i) => (
<div key={i} className="flex gap-4">
<div className="w-6 h-6 rounded-full bg-emerald-500/10 border border-emerald-500/20 flex items-center justify-center text-[10px] font-black text-emerald-500 shrink-0">
{i + 1}
</div>
<div className="space-y-0.5">
<p className="text-[11px] font-black uppercase tracking-widest text-emerald-500/80">{step.title}</p>
<p className="text-[11px] text-muted leading-relaxed">{step.desc}</p>
</div>
</div>
))}
</div>
{isRecurring && previewDates.length > 0 && (
<div className="mt-8 pt-8 border-t border-emerald-500/10">
<h4 className="text-[10px] font-black uppercase tracking-[0.2em] text-muted mb-4">Prévia da Agenda:</h4>
<div className="space-y-2">
{previewDates.map((d, i) => (
<div key={i} className="flex items-center justify-between text-[11px] py-1 border-b border-white/5 last:border-0">
<span className="text-zinc-400 font-medium">#{i + 2}</span>
<span className="font-black text-white">{d.toLocaleDateString('pt-BR', { day: '2-digit', month: 'long' })}</span>
</div>
))}
</div>
</div>
)}
</div>
</aside>
</div>
</div>
)
}

View File

@@ -1,8 +1,8 @@
'use client'
import React, { useState, useMemo } from 'react'
import { Plus, Trash2, UserPlus, Star, Search, Filter, MoreHorizontal, User, Shield, Target, Zap, ChevronDown, LayoutGrid, List, ChevronRight, Check, X, AlertCircle } from 'lucide-react'
import { addPlayer, deletePlayer, deletePlayers } from '@/actions/player'
import { Plus, Trash2, UserPlus, Star, Search, Filter, MoreHorizontal, User, Shield, Target, Zap, ChevronDown, LayoutGrid, List, ChevronRight, Check, X, AlertCircle, Pencil } from 'lucide-react'
import { addPlayer, deletePlayer, deletePlayers, updatePlayer } from '@/actions/player'
import { motion, AnimatePresence } from 'framer-motion'
import { clsx } from 'clsx'
import { DeleteConfirmationModal } from '@/components/DeleteConfirmationModal'
@@ -16,8 +16,9 @@ export function PlayersList({ group }: { group: any }) {
const [activeTab, setActiveTab] = useState<'ALL' | 'DEF' | 'MEI' | 'ATA'>('ALL')
const [viewMode, setViewMode] = useState<'grid' | 'list'>('grid')
const [error, setError] = useState<string | null>(null)
const [isAdding, setIsAdding] = useState(false)
const [isSaving, setIsSaving] = useState(false)
const [isFormOpen, setIsFormOpen] = useState(false)
const [editingPlayer, setEditingPlayer] = useState<any | null>(null)
// Pagination & Selection
const [currentPage, setCurrentPage] = useState(1)
@@ -40,27 +41,53 @@ export function PlayersList({ group }: { group: any }) {
description: ''
})
const handleAddPlayer = async (e: React.FormEvent) => {
const handleSavePlayer = async (e: React.FormEvent) => {
e.preventDefault()
setError(null)
if (!newPlayerName) return
setIsAdding(true)
setIsSaving(true)
try {
const playerNumber = number.trim() === '' ? null : parseInt(number)
await addPlayer(group.id, newPlayerName, level, playerNumber, position)
setNewPlayerName('')
setLevel(3)
setNumber('')
setPosition('MEI')
setIsFormOpen(false)
if (editingPlayer) {
await updatePlayer(editingPlayer.id, {
name: newPlayerName,
level,
number: playerNumber,
position
})
} else {
await addPlayer(group.id, newPlayerName, level, playerNumber, position)
}
closeForm()
} catch (err: any) {
setError(err.message)
} finally {
setIsAdding(false)
setIsSaving(false)
}
}
const closeForm = () => {
setNewPlayerName('')
setLevel(3)
setNumber('')
setPosition('MEI')
setEditingPlayer(null)
setIsFormOpen(false)
setError(null)
}
const openEditForm = (player: any) => {
setEditingPlayer(player)
setNewPlayerName(player.name)
setLevel(player.level)
setNumber(player.number?.toString() || '')
setPosition(player.position as any)
setIsFormOpen(true)
}
const filteredPlayers = useMemo(() => {
return group.players.filter((p: any) => {
const matchesSearch = p.name.toLowerCase().includes(searchQuery.toLowerCase()) ||
@@ -174,7 +201,6 @@ export function PlayersList({ group }: { group: any }) {
>
<button
onClick={() => {
setError(null)
setIsFormOpen(true)
}}
className="ui-button w-full sm:w-auto shadow-lg shadow-primary/20"
@@ -193,7 +219,7 @@ export function PlayersList({ group }: { group: any }) {
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
exit={{ opacity: 0 }}
onClick={() => setIsFormOpen(false)}
onClick={closeForm}
className="absolute inset-0 bg-background/80 backdrop-blur-sm"
/>
<motion.div
@@ -205,22 +231,26 @@ export function PlayersList({ group }: { group: any }) {
<div className="p-6 bg-surface-raised/50 border-b border-border flex items-center justify-between">
<div className="flex items-center gap-4">
<div className="w-12 h-12 bg-primary/10 rounded-xl flex items-center justify-center border border-primary/20 shadow-inner">
<UserPlus className="w-6 h-6 text-primary" />
{editingPlayer ? <Pencil className="w-6 h-6 text-primary" /> : <UserPlus className="w-6 h-6 text-primary" />}
</div>
<div>
<h3 className="text-lg font-bold tracking-tight">Novo Atleta</h3>
<p className="text-xs text-muted font-medium uppercase tracking-wider">Adicionar ao elenco</p>
<h3 className="text-lg font-bold tracking-tight">
{editingPlayer ? 'Editar Atleta' : 'Novo Atleta'}
</h3>
<p className="text-xs text-muted font-medium uppercase tracking-wider">
{editingPlayer ? 'Atualizar informações' : 'Adicionar ao elenco'}
</p>
</div>
</div>
<button
onClick={() => setIsFormOpen(false)}
onClick={closeForm}
className="p-2 text-muted hover:text-foreground rounded-lg transition-colors"
>
<X className="w-5 h-5" />
</button>
</div>
<form onSubmit={handleAddPlayer} className="p-6 space-y-6">
<form onSubmit={handleSavePlayer} className="p-6 space-y-6">
<div className="space-y-4">
<div className="flex items-center gap-2">
<User className="w-4 h-4 text-primary" />
@@ -337,17 +367,17 @@ export function PlayersList({ group }: { group: any }) {
<div className="pt-4 flex gap-3">
<button
type="button"
onClick={() => setIsFormOpen(false)}
onClick={closeForm}
className="ui-button-ghost flex-1 h-12"
>
Cancelar
</button>
<button
type="submit"
disabled={isAdding || !newPlayerName}
disabled={isSaving || !newPlayerName}
className="ui-button flex-[2] h-12 shadow-xl shadow-primary/10"
>
{isAdding ? <div className="w-5 h-5 border-2 border-background/30 border-t-background rounded-full animate-spin" /> : 'Confirmar Cadastro'}
{isSaving ? <div className="w-5 h-5 border-2 border-background/30 border-t-background rounded-full animate-spin" /> : (editingPlayer ? 'Salvar Alterações' : 'Confirmar Cadastro')}
</button>
</div>
</form>
@@ -538,16 +568,28 @@ export function PlayersList({ group }: { group: any }) {
Status: {getLevelInfo(p.level).label}
</div>
)}
<button
onClick={(e) => {
e.stopPropagation()
handleSingleDelete(p.id, p.name)
}}
className="p-2 text-muted hover:text-red-500 hover:bg-red-500/10 rounded-xl transition-all opacity-0 lg:group-hover:opacity-100"
title="Excluir Atleta"
>
<Trash2 className="w-4 h-4" />
</button>
<div className="flex items-center gap-1">
<button
onClick={(e) => {
e.stopPropagation()
openEditForm(p)
}}
className="p-2 text-muted hover:text-primary hover:bg-primary/10 rounded-xl transition-all opacity-0 lg:group-hover:opacity-100"
title="Editar Atleta"
>
<Pencil className="w-4 h-4" />
</button>
<button
onClick={(e) => {
e.stopPropagation()
handleSingleDelete(p.id, p.name)
}}
className="p-2 text-muted hover:text-red-500 hover:bg-red-500/10 rounded-xl transition-all opacity-0 lg:group-hover:opacity-100"
title="Excluir Atleta"
>
<Trash2 className="w-4 h-4" />
</button>
</div>
</div>
</motion.div>
))}

View File

@@ -5,6 +5,7 @@ import { updateGroupSettings } from '@/app/actions'
import { Upload, Save, Loader2, Image as ImageIcon, AlertCircle, CheckCircle } from 'lucide-react'
import { useRouter } from 'next/navigation'
import { motion, AnimatePresence } from 'framer-motion'
import { clsx } from 'clsx'
interface SettingsFormProps {
initialData: {
@@ -186,6 +187,7 @@ export function SettingsForm({ initialData }: SettingsFormProps) {
</div>
</div>
</div>
</div>
{/* Status Messages */}

View File

@@ -1,34 +1,38 @@
'use client'
import { useState } from 'react'
import { MapPin, Palette, Briefcase } from 'lucide-react'
import { MapPin, Palette, Briefcase, Shirt, Zap } from 'lucide-react'
import { clsx } from 'clsx'
import { motion, AnimatePresence } from 'framer-motion'
interface SettingsTabsProps {
branding: React.ReactNode
teams: React.ReactNode
arenas: React.ReactNode
sponsors: React.ReactNode
voting: React.ReactNode
}
export function SettingsTabs({ branding, arenas, sponsors }: SettingsTabsProps) {
const [activeTab, setActiveTab] = useState<'branding' | 'arenas' | 'sponsors'>('branding')
export function SettingsTabs({ branding, teams, arenas, sponsors, voting }: SettingsTabsProps) {
const [activeTab, setActiveTab] = useState<'branding' | 'teams' | 'arenas' | 'sponsors' | 'voting'>('branding')
const tabs = [
{ id: 'branding', label: 'Identidade Visual', icon: Palette },
{ id: 'voting', label: 'Votação & Resenha', icon: Zap },
{ id: 'teams', label: 'Times', icon: Shirt },
{ id: 'arenas', label: 'Locais & Arenas', icon: MapPin },
{ id: 'sponsors', label: 'Patrocínios', icon: Briefcase },
] as const
return (
<div className="space-y-8">
<div className="flex p-1 bg-surface-raised rounded-xl border border-border w-full sm:w-fit">
<div className="flex p-1 bg-surface-raised rounded-xl border border-border w-full sm:w-fit overflow-x-auto">
{tabs.map((tab) => (
<button
key={tab.id}
onClick={() => setActiveTab(tab.id)}
className={clsx(
"flex items-center gap-2 px-6 py-2.5 rounded-lg text-sm font-bold transition-all flex-1 sm:flex-none justify-center",
"flex items-center gap-2 px-6 py-2.5 rounded-lg text-sm font-bold transition-all flex-1 sm:flex-none justify-center whitespace-nowrap",
activeTab === tab.id
? "bg-primary text-background shadow-lg shadow-emerald-500/10"
: "text-muted hover:text-foreground hover:bg-white/5"
@@ -53,6 +57,28 @@ export function SettingsTabs({ branding, arenas, sponsors }: SettingsTabsProps)
{branding}
</motion.div>
)}
{activeTab === 'voting' && (
<motion.div
key="voting"
initial={{ opacity: 0, y: 10 }}
animate={{ opacity: 1, y: 0 }}
exit={{ opacity: 0, y: -10 }}
transition={{ duration: 0.2 }}
>
{voting}
</motion.div>
)}
{activeTab === 'teams' && (
<motion.div
key="teams"
initial={{ opacity: 0, y: 10 }}
animate={{ opacity: 1, y: 0 }}
exit={{ opacity: 0, y: -10 }}
transition={{ duration: 0.2 }}
>
{teams}
</motion.div>
)}
{activeTab === 'arenas' && (
<motion.div
key="arenas"

View File

@@ -1,8 +1,8 @@
'use client'
import { useState, useTransition } from 'react'
import { createSponsor, deleteSponsor } from '@/actions/sponsor'
import { Briefcase, Plus, Trash2, Loader2, Image as ImageIcon } from 'lucide-react'
import { createSponsor, deleteSponsor, updateSponsor } from '@/actions/sponsor'
import { Briefcase, Plus, Trash2, Loader2, Image as ImageIcon, Pencil, X } from 'lucide-react'
import type { Sponsor } from '@prisma/client'
import { DeleteConfirmationModal } from '@/components/DeleteConfirmationModal'
@@ -14,6 +14,7 @@ interface SponsorsManagerProps {
export function SponsorsManager({ groupId, sponsors }: SponsorsManagerProps) {
const [isPending, startTransition] = useTransition()
const [filePreview, setFilePreview] = useState<string | null>(null)
const [editingSponsor, setEditingSponsor] = useState<Sponsor | null>(null)
const [deleteModal, setDeleteModal] = useState<{
isOpen: boolean
sponsorId: string | null
@@ -48,6 +49,19 @@ export function SponsorsManager({ groupId, sponsors }: SponsorsManagerProps) {
})
}
const handleEdit = (sponsor: Sponsor) => {
setEditingSponsor(sponsor)
setFilePreview(sponsor.logoUrl || null)
document.getElementById('sponsor-form')?.scrollIntoView({ behavior: 'smooth' })
}
const cancelEdit = () => {
setEditingSponsor(null)
setFilePreview(null)
const form = document.getElementById('sponsor-form') as HTMLFormElement
form?.reset()
}
return (
<div className="ui-card p-8 space-y-8">
<header>
@@ -76,14 +90,23 @@ export function SponsorsManager({ groupId, sponsors }: SponsorsManagerProps) {
<p className="text-[10px] text-muted font-medium mt-0.5">Patrocinador Ativo</p>
</div>
</div>
<button
onClick={() => handleDelete(sponsor.id)}
disabled={isPending}
className="p-2 text-muted hover:text-destructive hover:bg-destructive/10 rounded-lg transition-colors opacity-0 group-hover:opacity-100 focus:opacity-100"
title="Excluir patrocinador"
>
<Trash2 className="w-4 h-4" />
</button>
<div className="flex items-center gap-1 opacity-0 group-hover:opacity-100 focus-within:opacity-100 transition-opacity">
<button
onClick={() => handleEdit(sponsor)}
className="p-2 text-muted hover:text-primary hover:bg-primary/10 rounded-lg transition-colors"
title="Editar patrocinador"
>
<Pencil className="w-4 h-4" />
</button>
<button
onClick={() => handleDelete(sponsor.id)}
disabled={isPending}
className="p-2 text-muted hover:text-destructive hover:bg-destructive/10 rounded-lg transition-colors"
title="Excluir patrocinador"
>
<Trash2 className="w-4 h-4" />
</button>
</div>
</div>
))}
@@ -97,22 +120,41 @@ export function SponsorsManager({ groupId, sponsors }: SponsorsManagerProps) {
<form action={(formData) => {
const name = formData.get('name') as string
const logoFile = formData.get('logo') as File
if (!name) return
startTransition(async () => {
await createSponsor(formData)
const form = document.getElementById('sponsor-form') as HTMLFormElement
form?.reset()
setFilePreview(null)
if (editingSponsor) {
await updateSponsor(editingSponsor.id, formData)
} else {
await createSponsor(formData)
}
cancelEdit()
})
}} id="sponsor-form" className="pt-8 mt-8 border-t border-border">
}} id="sponsor-form" className="pt-8 mt-8 border-t border-border space-y-4">
<input type="hidden" name="groupId" value={groupId} />
<div className="flex items-center justify-between">
<h4 className="text-sm font-bold uppercase tracking-widest text-primary">
{editingSponsor ? 'Editando Patrocinador' : 'Adicionar Novo Patrocinador'}
</h4>
{editingSponsor && (
<button
type="button"
onClick={cancelEdit}
className="text-[10px] font-bold uppercase text-muted hover:text-foreground flex items-center gap-1"
>
<X className="w-3 h-3" /> Cancelar Edição
</button>
)}
</div>
<div className="grid grid-cols-1 md:grid-cols-12 gap-6 items-end">
<div className="ui-form-field md:col-span-5">
<label className="text-label ml-1">Nome da Empresa</label>
<input
name="name"
defaultValue={editingSponsor?.name || ''}
key={editingSponsor?.id || 'new'}
placeholder="Ex: Pizzaria do Vale"
className="ui-input w-full"
required
@@ -162,9 +204,9 @@ export function SponsorsManager({ groupId, sponsors }: SponsorsManagerProps) {
{isPending ? (
<Loader2 className="w-4 h-4 animate-spin" />
) : (
<Plus className="w-4 h-4 mr-2" />
editingSponsor ? <Pencil className="w-4 h-4 mr-2" /> : <Plus className="w-4 h-4 mr-2" />
)}
Adicionar
{editingSponsor ? 'Salvar' : 'Adicionar'}
</button>
</div>
</div>

View File

@@ -0,0 +1,262 @@
'use client'
import { useState, useTransition } from 'react'
import { createTeamConfig, deleteTeamConfig, updateTeamConfig } from '@/actions/team-config'
import { Shirt, Plus, Trash2, Loader2, Image as ImageIcon, Pencil, X } from 'lucide-react'
// @ts-ignore
import type { TeamConfig } from '@prisma/client'
import { DeleteConfirmationModal } from '@/components/DeleteConfirmationModal'
interface TeamsManagerProps {
groupId: string
teams: TeamConfig[]
}
export function TeamsManager({ groupId, teams }: TeamsManagerProps) {
const [isPending, startTransition] = useTransition()
const [filePreview, setFilePreview] = useState<string | null>(null)
const [selectedColor, setSelectedColor] = useState('#10b981')
const [editingTeam, setEditingTeam] = useState<TeamConfig | null>(null)
const [deleteModal, setDeleteModal] = useState<{
isOpen: boolean
teamId: string | null
isDeleting: boolean
}>({
isOpen: false,
teamId: null,
isDeleting: false
})
const handleDelete = (id: string) => {
setDeleteModal({
isOpen: true,
teamId: id,
isDeleting: false
})
}
const confirmDelete = () => {
if (!deleteModal.teamId) return
setDeleteModal(prev => ({ ...prev, isDeleting: true }))
startTransition(async () => {
try {
await deleteTeamConfig(deleteModal.teamId!)
setDeleteModal({ isOpen: false, teamId: null, isDeleting: false })
} catch (error) {
console.error(error)
alert('Erro ao excluir time.')
setDeleteModal(prev => ({ ...prev, isDeleting: false }))
}
})
}
const handleEdit = (team: TeamConfig) => {
setEditingTeam(team)
setSelectedColor(team.color)
setFilePreview(team.shirtUrl || null)
// Scroll to form
document.getElementById('team-form')?.scrollIntoView({ behavior: 'smooth' })
}
const cancelEdit = () => {
setEditingTeam(null)
setSelectedColor('#10b981')
setFilePreview(null)
const form = document.getElementById('team-form') as HTMLFormElement
form?.reset()
}
return (
<div className="ui-card p-8 space-y-8">
<header>
<h3 className="font-semibold text-lg flex items-center gap-2">
<Shirt className="w-5 h-5 text-primary" />
Times Padrão
</h3>
<p className="text-muted text-sm">
Configure os nomes, cores e camisas dos times do seu futebol.
</p>
</header>
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
{teams.map((team) => (
<div key={team.id} className="relative group p-6 rounded-2xl border border-border bg-surface-raised/50 hover:border-primary/50 transition-all duration-300">
<div className="flex flex-col items-center gap-4">
<div
className="w-20 h-20 rounded-full flex items-center justify-center border-4 relative overflow-hidden transition-transform group-hover:scale-105"
style={{
backgroundColor: team.color + '20',
borderColor: team.color
}}
>
{team.shirtUrl ? (
<img src={team.shirtUrl} alt={team.name} className="w-full h-full object-cover" />
) : (
<Shirt className="w-10 h-10 transition-colors" style={{ color: team.color }} />
)}
</div>
<div className="text-center">
<p className="font-bold text-foreground uppercase text-sm tracking-widest">{team.name}</p>
<div className="flex items-center justify-center gap-2 mt-2">
<div className="w-3 h-3 rounded-full" style={{ backgroundColor: team.color }} />
<span className="text-[10px] text-muted font-mono uppercase">{team.color}</span>
</div>
</div>
</div>
<div className="absolute top-4 right-4 flex items-center gap-1 opacity-0 group-hover:opacity-100 focus-within:opacity-100 transition-opacity">
<button
onClick={() => handleEdit(team)}
className="p-2 text-muted hover:text-primary hover:bg-primary/10 rounded-lg transition-colors"
title="Editar time"
>
<Pencil className="w-4 h-4" />
</button>
<button
onClick={() => handleDelete(team.id)}
disabled={isPending}
className="p-2 text-muted hover:text-destructive hover:bg-destructive/10 rounded-lg transition-colors"
title="Excluir time"
>
<Trash2 className="w-4 h-4" />
</button>
</div>
</div>
))}
{teams.length === 0 && (
<div className="col-span-full text-center py-12 px-4 border border-dashed border-border rounded-2xl bg-surface/50">
<Shirt className="w-12 h-12 text-muted mx-auto mb-4 opacity-30" />
<p className="text-muted text-sm uppercase font-bold tracking-widest">Nenhum time configurado</p>
<p className="text-xs text-muted/60 mt-2">Adicione os times que costumam jogar na sua pelada.</p>
</div>
)}
</div>
<form action={(formData) => {
const name = formData.get('name') as string
if (!name) return
startTransition(async () => {
if (editingTeam) {
await updateTeamConfig(editingTeam.id, formData)
} else {
await createTeamConfig(formData)
}
cancelEdit()
})
}} id="team-form" className="pt-8 mt-8 border-t border-border space-y-6">
<input type="hidden" name="groupId" value={groupId} />
<div className="flex items-center justify-between">
<h4 className="text-sm font-bold uppercase tracking-widest text-primary">
{editingTeam ? 'Editando Time' : 'Adicionar Novo Time'}
</h4>
{editingTeam && (
<button
type="button"
onClick={cancelEdit}
className="text-[10px] font-bold uppercase text-muted hover:text-foreground flex items-center gap-1"
>
<X className="w-3 h-3" /> Cancelar Edição
</button>
)}
</div>
<div className="grid grid-cols-1 md:grid-cols-12 gap-6 items-end">
<div className="ui-form-field md:col-span-4">
<label className="text-label ml-1">Nome do Time</label>
<input
name="name"
defaultValue={editingTeam?.name || ''}
key={editingTeam?.id || 'new'}
placeholder="Ex: Time Branco"
className="ui-input w-full"
required
/>
</div>
<div className="ui-form-field md:col-span-3">
<label className="text-label ml-1">Cor do Time</label>
<div className="flex items-center gap-2">
<input
type="color"
name="color"
value={selectedColor}
onChange={(e) => setSelectedColor(e.target.value)}
className="w-12 h-[42px] p-1 bg-surface-raised border border-border rounded-lg cursor-pointer"
/>
<div className="flex-1 px-3 h-[42px] flex items-center bg-surface border border-border rounded-lg text-xs font-mono text-muted uppercase">
{selectedColor}
</div>
</div>
</div>
<div className="ui-form-field md:col-span-3">
<label className="text-label ml-1">Camisa (Opcional)</label>
<div className="relative group">
<input
type="file"
name="shirt"
accept="image/*"
className="hidden"
id="team-shirt-upload"
onChange={(e) => {
const file = e.target.files?.[0]
if (file) {
const reader = new FileReader()
reader.onloadend = () => setFilePreview(reader.result as string)
reader.readAsDataURL(file)
}
}}
/>
<label
htmlFor="team-shirt-upload"
className="ui-input w-full flex items-center gap-3 cursor-pointer group-hover:border-primary/50 transition-all bg-surface"
>
<div className="w-8 h-8 rounded bg-surface-raised flex items-center justify-center border border-white/5 overflow-hidden">
{filePreview ? (
<img src={filePreview} className="w-full h-full object-cover" />
) : (
<ImageIcon className="w-4 h-4 text-muted" />
)}
</div>
<span className="text-[10px] text-muted truncate">
{filePreview ? 'Alterar imagem' : 'Selecionar camisa...'}
</span>
</label>
</div>
</div>
<div className="md:col-span-2">
<button
type="submit"
disabled={isPending}
className="ui-button h-[42px] w-full"
>
{isPending ? (
<Loader2 className="w-4 h-4 animate-spin" />
) : (
<>
{editingTeam ? <Pencil className="w-4 h-4 mr-2" /> : <Plus className="w-4 h-4 mr-2" />}
{editingTeam ? 'Salvar' : 'Adicionar'}
</>
)}
</button>
</div>
</div>
</form>
<DeleteConfirmationModal
isOpen={deleteModal.isOpen}
onClose={() => setDeleteModal({ isOpen: false, teamId: null, isDeleting: false })}
onConfirm={confirmDelete}
isDeleting={deleteModal.isDeleting}
title="Excluir Time?"
description="Tem certeza que deseja remover este time das configurações? Isso não afetará jogos passados."
confirmText="Sim, remover"
/>
</div>
)
}

View File

@@ -0,0 +1,467 @@
'use client'
import React, { useState } from 'react'
import { motion, AnimatePresence } from 'framer-motion'
import { Star, Shield, Zap, CheckCircle2, User, Users, Trophy, Check, ArrowRight, ArrowLeft, Search, X } from 'lucide-react'
import { clsx } from 'clsx'
import { submitReviews } from '@/actions/match'
import { useEffect } from 'react'
interface VotingFlowProps {
match: any
allPlayers: any[]
initialVoters: any[]
}
function CountdownTimer({ endTime }: { endTime: Date }) {
const [timeLeft, setTimeLeft] = useState<{ h: number, m: number, s: number } | null>(null)
useEffect(() => {
const interval = setInterval(() => {
const now = new Date().getTime()
const distance = endTime.getTime() - now
if (distance < 0) {
clearInterval(interval)
setTimeLeft(null)
window.location.reload() // Refresh to show expired screen
return
}
setTimeLeft({
h: Math.floor(distance / (1000 * 60 * 60)),
m: Math.floor((distance % (1000 * 60 * 60)) / (1000 * 60)),
s: Math.floor((distance % (1000 * 60)) / 1000)
})
}, 1000)
return () => clearInterval(interval)
}, [endTime])
if (!timeLeft) return null
return (
<div className="flex items-center gap-2 px-4 py-1.5 bg-orange-500/10 border border-orange-500/20 rounded-full">
<Zap className="w-3 h-3 text-orange-500 animate-pulse" />
<span className="text-[9px] font-black uppercase tracking-widest text-orange-500">
A votação fecha em: {String(timeLeft.h).padStart(2, '0')}:{String(timeLeft.m).padStart(2, '0')}:{String(timeLeft.s).padStart(2, '0')}
</span>
</div>
)
}
export function VotingFlow({ match, allPlayers, initialVoters }: VotingFlowProps) {
const [step, setStep] = useState<'identity' | 'voting' | 'success'>('identity')
const [selectedReviewerId, setSelectedReviewerId] = useState('')
const [currentPlayerIndex, setCurrentPlayerIndex] = useState(0)
const [votes, setVotes] = useState<Record<string, string>>({})
const [isSubmitting, setIsSubmitting] = useState(false)
const [voters, setVoters] = useState(initialVoters)
const [searchQuery, setSearchQuery] = useState('')
const filteredPlayers = allPlayers.filter(p => p.id !== selectedReviewerId)
const currentPlayer = filteredPlayers[currentPlayerIndex]
const progress = (Object.keys(votes).length / filteredPlayers.length) * 100
const handleSelectIdentity = (e: React.ChangeEvent<HTMLSelectElement>) => {
setSelectedReviewerId(e.target.value)
}
const startVoting = () => {
if (selectedReviewerId) {
if (filteredPlayers.length === 0) {
// If no one to vote for (e.g. only 1 player total), skip to success
finishVoting()
return
}
setStep('voting')
}
}
const handleVote = (type: string) => {
if (!currentPlayer) return
setVotes(prev => ({ ...prev, [currentPlayer.id]: type }))
if (currentPlayerIndex < filteredPlayers.length - 1) {
setCurrentPlayerIndex(prev => prev + 1)
} else {
// Last player voted, automatically finish or show finish button?
// Let's keep it on the last player so they can review if needed
}
}
const finishVoting = async () => {
setIsSubmitting(true)
try {
const reviewsArray = Object.entries(votes).map(([playerId, type]) => ({
playerId,
type
}))
await submitReviews(match.id, reviewsArray, selectedReviewerId)
// Add current player to voters list visually
const reviewer = allPlayers.find(p => p.id === selectedReviewerId)
if (reviewer && !voters.some(v => v.id === reviewer.id)) {
setVoters(prev => [...prev, reviewer])
}
setStep('success')
} catch (error) {
console.error('Error submitting votes:', error)
} finally {
setIsSubmitting(false)
}
}
const goBack = () => {
if (currentPlayerIndex > 0) {
setCurrentPlayerIndex(prev => prev - 1)
}
}
const skipPlayer = () => {
if (currentPlayerIndex < filteredPlayers.length - 1) {
setCurrentPlayerIndex(prev => prev + 1)
}
}
if (step === 'identity') {
const sortedPlayers = [...allPlayers].sort((a, b) => a.name.localeCompare(b.name))
const filteredSearchPlayers = sortedPlayers.filter(p =>
p.name.toLowerCase().includes(searchQuery.toLowerCase())
)
return (
<div className="max-w-2xl mx-auto space-y-8 py-8 md:py-12 px-4">
<div className="text-center space-y-4">
<div className="flex justify-center mb-4">
<CountdownTimer endTime={new Date(new Date(match.actualEndTime || match.date).getTime() + (match.votingDuration || 72) * 60 * 60 * 1000)} />
</div>
<h2 className="text-4xl md:text-5xl font-black uppercase italic tracking-tighter leading-none">
Quem está <br /><span className="text-primary italic">na resenha?</span>
</h2>
<p className="text-muted text-[10px] font-bold uppercase tracking-[0.4em]">Selecione seu nome para começar</p>
</div>
<div className="space-y-6">
{/* Search Bar */}
<div className="relative group">
<div className="absolute inset-y-0 left-5 flex items-center pointer-events-none">
<Search className="w-5 h-5 text-muted group-focus-within:text-primary transition-colors" />
</div>
<input
type="text"
placeholder="PROCURAR MEU NOME..."
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
className="w-full h-16 bg-zinc-900/60 border border-white/5 rounded-3xl pl-14 pr-14 text-sm font-black uppercase tracking-widest focus:border-primary/50 focus:ring-0 transition-all outline-none backdrop-blur-xl"
/>
{searchQuery && (
<button
onClick={() => setSearchQuery('')}
className="absolute inset-y-0 right-5 flex items-center text-muted hover:text-white transition-colors"
>
<X className="w-5 h-5" />
</button>
)}
</div>
{/* Players Grid */}
<div className="grid grid-cols-1 sm:grid-cols-2 gap-3 max-h-[400px] overflow-y-auto pr-2 custom-scrollbar">
<AnimatePresence mode="popLayout">
{filteredSearchPlayers.map((p) => {
const isSelected = selectedReviewerId === p.id
const hasAlreadyVoted = voters.some(v => v.id === p.id)
return (
<motion.button
key={p.id}
layout
initial={{ opacity: 0, scale: 0.9 }}
animate={{ opacity: 1, scale: 1 }}
exit={{ opacity: 0, scale: 0.9 }}
onClick={() => !hasAlreadyVoted && setSelectedReviewerId(p.id)}
disabled={hasAlreadyVoted}
className={clsx(
"flex items-center gap-4 p-4 rounded-2xl border transition-all text-left relative overflow-hidden group",
isSelected
? "bg-primary border-primary text-black shadow-[0_0_20px_rgba(16,185,129,0.2)]"
: hasAlreadyVoted
? "bg-zinc-900 border-white/5 opacity-50 grayscale cursor-not-allowed"
: "bg-white/5 border-white/5 text-white hover:border-white/20 hover:bg-white/[0.08]"
)}
>
<div className={clsx(
"w-10 h-10 rounded-xl flex items-center justify-center shrink-0 border transition-colors",
isSelected ? "bg-black/10 border-black/10" : "bg-black/40 border-white/5"
)}>
{isSelected ? <Check className="w-5 h-5" /> : hasAlreadyVoted ? <CheckCircle2 className="w-5 h-5 text-muted" /> : <User className="w-5 h-5 text-muted" />}
</div>
<div className="flex-1 min-w-0">
<p className="text-[11px] font-black uppercase tracking-tight truncate">{p.name}</p>
<p className={clsx(
"text-[8px] font-bold uppercase tracking-widest",
isSelected ? "text-black/60" : "text-muted"
)}>{p.position || 'Jogador'}</p>
</div>
{hasAlreadyVoted && (
<div className="absolute top-2 right-2 px-2 py-0.5 bg-black/40 rounded text-[7px] font-black uppercase tracking-widest text-muted border border-white/5">
VOTOU
</div>
)}
{/* Selection Glow */}
{isSelected && (
<motion.div
layoutId="selected-glow"
className="absolute inset-0 bg-white/10 pointer-events-none"
/>
)}
</motion.button>
)
})}
</AnimatePresence>
{filteredSearchPlayers.length === 0 && (
<div className="col-span-full py-12 text-center space-y-3 opacity-40">
<Search className="w-10 h-10 mx-auto text-muted" />
<p className="text-[10px] font-black uppercase tracking-widest">Nenhum jogador encontrado</p>
</div>
)}
</div>
{/* Action Button */}
<div className="pt-4">
<button
onClick={startVoting}
disabled={!selectedReviewerId}
className="ui-button w-full h-16 bg-white text-black font-black uppercase tracking-[0.4em] text-xs shadow-[0_0_50px_rgba(255,255,255,0.1)] disabled:opacity-20 hover:scale-[1.02] transition-all flex items-center justify-center gap-3 rounded-3xl"
>
INICIAR RESENHA <ArrowRight className="w-4 h-4 ml-2" />
</button>
</div>
</div>
<style jsx global>{`
.custom-scrollbar::-webkit-scrollbar {
width: 4px;
}
.custom-scrollbar::-webkit-scrollbar-track {
background: rgba(255, 255, 255, 0.02);
border-radius: 10px;
}
.custom-scrollbar::-webkit-scrollbar-thumb {
background: rgba(255, 255, 255, 0.1);
border-radius: 10px;
}
.custom-scrollbar::-webkit-scrollbar-thumb:hover {
background: rgba(16, 185, 129, 0.3);
}
`}</style>
</div>
)
}
if (step === 'voting') {
if (!currentPlayer) {
return (
<div className="max-w-2xl mx-auto py-12 text-center">
<p className="text-muted text-sm pb-4">Carregando jogador ou nenhum jogador para votar...</p>
<button onClick={() => setStep('identity')} className="ui-button-ghost">Voltar</button>
</div>
)
}
return (
<div className="max-w-2xl mx-auto space-y-6 md:space-y-8 py-4 md:py-8 animate-in fade-in duration-700 px-4 md:px-0">
{/* Progress Bar */}
<div className="space-y-3 px-2">
<div className="flex items-center justify-between text-[10px] font-black uppercase tracking-widest text-muted">
<span>Resenha</span>
<span>{currentPlayerIndex + 1} / {filteredPlayers.length}</span>
</div>
<div className="h-1.5 md:h-2 w-full bg-white/5 rounded-full overflow-hidden border border-white/5">
<motion.div
className="h-full bg-primary shadow-[0_0_15px_#10b981]"
initial={{ width: 0 }}
animate={{ width: `${((currentPlayerIndex + 1) / filteredPlayers.length) * 100}%` }}
transition={{ type: 'spring', stiffness: 50 }}
/>
</div>
</div>
{/* Player Card Container */}
<div className="relative min-h-[450px] md:min-h-[500px] flex items-center justify-center">
<AnimatePresence mode="wait">
<motion.div
key={currentPlayer.id}
initial={{ opacity: 0, x: 50, scale: 0.95 }}
animate={{ opacity: 1, x: 0, scale: 1 }}
exit={{ opacity: 0, x: -50, scale: 0.95 }}
transition={{ duration: 0.4, ease: "circOut" }}
className="w-full"
>
<div className="ui-card p-8 md:p-12 bg-surface border-white/10 rounded-[2.5rem] md:rounded-[3rem] shadow-2xl relative overflow-hidden group text-center space-y-8 md:space-y-10">
{/* Decorator Background */}
<div className="absolute top-0 inset-x-0 h-40 bg-gradient-to-b from-primary/5 to-transparent pointer-events-none" />
<div className="space-y-4 md:space-y-6">
<div className="w-20 h-20 md:w-24 md:h-24 rounded-2xl md:rounded-[2rem] bg-zinc-950 border border-white/10 mx-auto flex items-center justify-center relative overflow-hidden">
<span className="text-xl md:text-2xl font-black italic relative z-10">{currentPlayer.number || '??'}</span>
<div className="absolute inset-0 bg-gradient-to-br from-white/5 to-transparent" />
<div className="absolute -bottom-2 -right-2 w-10 md:w-12 h-10 md:h-12 bg-primary/10 blur-xl rounded-full" />
</div>
<div className="space-y-1 md:space-y-2">
<h3 className="text-2xl md:text-3xl font-black uppercase italic tracking-tighter truncate px-2">{currentPlayer.name}</h3>
<p className="text-[10px] md:text-[12px] font-black uppercase tracking-[0.4em] md:tracking-[0.5em] text-primary">{currentPlayer.position}</p>
</div>
</div>
{/* Voting Actions */}
<div className="grid grid-cols-1 md:grid-cols-3 gap-3 md:gap-4 h-auto md:h-48">
<button
onClick={() => handleVote('CRAQUE')}
className={clsx(
"flex md:flex-col items-center justify-center gap-4 py-4 md:py-0 rounded-2xl md:rounded-[2.5rem] border transition-all group/btn",
votes[currentPlayer.id] === 'CRAQUE' ? "bg-primary border-primary text-black shadow-lg shadow-primary/20" : "bg-black/40 border-white/5 text-muted hover:border-primary/50"
)}
>
<div className={clsx("w-10 h-10 md:w-14 md:h-14 rounded-xl md:rounded-2xl flex items-center justify-center transition-transform md:group-hover/btn:scale-110", votes[currentPlayer.id] === 'CRAQUE' ? "bg-black/10" : "bg-primary/10")}>
<Star className={clsx("w-5 h-5 md:w-8 md:h-8", votes[currentPlayer.id] === 'CRAQUE' ? "fill-black" : "text-primary")} />
</div>
<span className="text-[10px] md:text-[10px] font-black uppercase tracking-[0.3em]">Craque</span>
</button>
<button
onClick={() => handleVote('FAIR_PLAY')}
className={clsx(
"flex md:flex-col items-center justify-center gap-4 py-4 md:py-0 rounded-2xl md:rounded-[2.5rem] border transition-all group/btn",
votes[currentPlayer.id] === 'FAIR_PLAY' ? "bg-blue-500 border-blue-500 text-white shadow-lg shadow-blue-500/20" : "bg-black/40 border-white/5 text-muted hover:border-blue-500/50"
)}
>
<div className={clsx("w-10 h-10 md:w-14 md:h-14 rounded-xl md:rounded-2xl flex items-center justify-center transition-transform md:group-hover/btn:scale-110", votes[currentPlayer.id] === 'FAIR_PLAY' ? "bg-white/10" : "bg-blue-500/10")}>
<Shield className="w-5 h-5 md:w-8 md:h-8" />
</div>
<span className="text-[10px] md:text-[10px] font-black uppercase tracking-[0.3em]">Equilibrado</span>
</button>
<button
onClick={() => handleVote('PEREBA')}
className={clsx(
"flex md:flex-col items-center justify-center gap-4 py-4 md:py-0 rounded-2xl md:rounded-[2.5rem] border transition-all group/btn",
votes[currentPlayer.id] === 'PEREBA' ? "bg-red-500 border-red-500 text-white shadow-lg shadow-red-500/20" : "bg-black/40 border-white/5 text-muted hover:border-red-500/50"
)}
>
<div className={clsx("w-10 h-10 md:w-14 md:h-14 rounded-xl md:rounded-2xl flex items-center justify-center transition-transform md:group-hover/btn:scale-110", votes[currentPlayer.id] === 'PEREBA' ? "bg-white/10" : "bg-red-500/10")}>
<Zap className="w-5 h-5 md:w-8 md:h-8" />
</div>
<span className="text-[10px] md:text-[10px] font-black uppercase tracking-[0.3em]">Pereba</span>
</button>
</div>
</div>
</motion.div>
</AnimatePresence>
</div>
{/* Navigation Controls */}
<div className="flex items-center justify-between px-2 md:px-6 pb-8 md:pb-0">
<button
onClick={goBack}
disabled={currentPlayerIndex === 0}
className="flex items-center gap-2 text-[10px] font-black uppercase tracking-widest text-muted hover:text-white disabled:opacity-0 transition-all p-2"
>
<ArrowLeft className="w-3 h-3" /> <span className="hidden sm:inline">Anterior</span>
</button>
{currentPlayerIndex === filteredPlayers.length - 1 && votes[currentPlayer.id] ? (
<button
onClick={finishVoting}
disabled={isSubmitting}
className="ui-button h-12 md:h-14 px-8 md:px-12 bg-white text-black text-[10px] font-black uppercase tracking-[0.3em] shadow-2xl hover:scale-105 transition-all flex items-center gap-2 md:gap-3"
>
{isSubmitting ? 'Salvando...' : 'Finalizar'} <Check className="w-4 h-4" />
</button>
) : (
<button
onClick={skipPlayer}
className="flex items-center gap-2 text-[10px] font-black uppercase tracking-widest text-muted hover:text-white transition-all p-2"
>
<span className="hidden sm:inline">Pular</span> <ArrowRight className="w-3 h-3" />
</button>
)}
</div>
</div>
)
}
if (step === 'success') {
return (
<div className="flex flex-col items-center justify-center min-h-[70vh] gap-8 md:gap-12 py-8 md:py-0 px-4 animate-in fade-in zoom-in duration-1000">
<div className="ui-card p-10 md:p-16 text-center space-y-6 md:space-y-8 max-w-md bg-black/40 backdrop-blur-3xl border-primary/20 shadow-[0_0_100px_rgba(16,185,129,0.1)] rounded-[2.50rem] md:rounded-[3rem] relative overflow-hidden w-full">
<div className="relative">
<div className="absolute inset-0 bg-primary blur-3xl opacity-20 scale-150" />
<div className="w-20 h-20 md:w-24 md:h-24 bg-primary text-background rounded-[1.5rem] md:rounded-3xl flex items-center justify-center mx-auto relative z-10 shadow-[0_0_30px_rgba(16,185,129,0.4)]">
<CheckCircle2 className="w-10 h-10 md:w-12 md:h-12" />
</div>
</div>
<div className="space-y-4">
<h1 className="text-3xl md:text-4xl font-black uppercase italic tracking-tighter leading-none">Voto <br />Contabilizado!</h1>
<p className="text-muted text-[10px] font-bold uppercase tracking-[0.4em] leading-relaxed px-4">
Sua opinião é o que faz o TemFut real. <br />Obrigado por fortalecer a resenha!
</p>
</div>
<div className="pt-6 md:pt-8 border-t border-white/5 opacity-50 flex items-center justify-center gap-3">
<Trophy className="w-4 h-4 text-primary" />
<span className="text-[9px] font-black uppercase tracking-[0.5em]">TemFut Gamification Engine</span>
</div>
</div>
{/* Voter Status */}
<div className="max-w-2xl w-full">
<div className="ui-card p-6 md:p-8 bg-zinc-900/40 border-white/5 rounded-[2rem] md:rounded-[2.5rem] backdrop-blur-md">
<div className="flex flex-col sm:flex-row items-center justify-between mb-8 pb-4 border-b border-white/5 gap-4">
<div className="flex items-center gap-3">
<Users className="w-5 h-5 text-primary" />
<h3 className="text-sm font-black uppercase italic tracking-widest text-white/80">Monitor da Resenha</h3>
</div>
<div className="px-4 py-1.5 bg-primary/10 border border-primary/20 rounded-full">
<span className="text-[10px] font-black text-primary tracking-widest">{voters.length} / {allPlayers.length} VOTARAM</span>
</div>
</div>
<div className="grid grid-cols-2 sm:grid-cols-3 md:grid-cols-4 gap-2 md:gap-3">
{allPlayers.map((p: any) => {
const hasVoted = voters.some((v: any) => v.id === p.id)
return (
<div
key={p.id}
className={clsx(
"p-2 md:p-3 rounded-xl border transition-all flex items-center gap-2 md:gap-3",
hasVoted
? "bg-primary/5 border-primary/20 text-primary"
: "bg-white/5 border-white/10 text-muted opacity-30"
)}
>
<div className={clsx(
"w-1.5 h-1.5 md:w-2 md:h-2 rounded-full",
hasVoted ? "bg-primary shadow-[0_0_8px_#10b981]" : "bg-zinc-800"
)} />
<span className="text-[9px] md:text-[10px] font-black uppercase tracking-tight truncate flex-1">{p.name}</span>
{hasVoted && <Check className="w-3 h-3" />}
</div>
)
})}
</div>
</div>
</div>
</div>
)
}
return null
}

View File

@@ -0,0 +1,131 @@
'use client'
import { useState, useTransition } from 'react'
import { updateGroupSettings } from '@/app/actions'
import { Save, Loader2, AlertCircle, CheckCircle, Zap } from 'lucide-react'
import { clsx } from 'clsx'
import { motion, AnimatePresence } from 'framer-motion'
import { useRouter } from 'next/navigation'
interface VotingSettingsProps {
votingEnabled: boolean
}
export function VotingSettings({ votingEnabled: initialVotingState }: VotingSettingsProps) {
const [votingEnabled, setVotingEnabled] = useState(initialVotingState)
const [isPending, startTransition] = useTransition()
const [error, setError] = useState<string | null>(null)
const [successMsg, setSuccessMsg] = useState<string | null>(null)
const router = useRouter()
const handleSubmit = async (e: React.FormEvent<HTMLFormElement>) => {
e.preventDefault()
setError(null)
setSuccessMsg(null)
const formData = new FormData()
formData.append('votingEnabled', votingEnabled ? 'true' : 'false')
startTransition(async () => {
try {
const res = await updateGroupSettings(formData)
if (res.success) {
setSuccessMsg('Configurações de votação salvas!')
setTimeout(() => {
setSuccessMsg(null)
router.refresh()
}, 2000)
} else {
setError(res.error || 'Erro ao salvar.')
}
} catch (err) {
console.error(err)
setError('Erro inesperado.')
}
})
}
return (
<form onSubmit={handleSubmit} className="space-y-6 animate-in fade-in duration-500 max-w-2xl">
<div className="ui-card p-8 space-y-8">
<div className="flex items-start gap-6">
<div className={clsx(
"p-4 rounded-2xl border transition-all",
votingEnabled ? "bg-primary/10 border-primary/20 text-primary" : "bg-surface-raised border-border text-muted"
)}>
<Zap className="w-8 h-8" />
</div>
<div className="space-y-2 flex-1">
<h3 className="text-xl font-bold tracking-tight">Sistema de Resenha (Gamificação)</h3>
<p className="text-muted text-sm leading-relaxed">
Permite que os jogadores votem no "Craque", "Pereba" e "Equilibrado" após cada partida.
Isso gera um ranking divertido e engajamento no grupo.
</p>
</div>
</div>
<div className="p-6 bg-surface-raised/30 rounded-2xl border border-white/5 space-y-4">
<div className="flex items-center justify-between">
<span className="font-bold uppercase text-sm tracking-wider">Habilitar Votação</span>
<div
onClick={() => setVotingEnabled(!votingEnabled)}
className={clsx(
"w-14 h-7 rounded-full relative cursor-pointer transition-all",
votingEnabled ? "bg-primary shadow-[0_0_15px_rgba(16,185,129,0.3)]" : "bg-zinc-800"
)}
>
<div className={clsx("absolute top-1 w-5 h-5 bg-white rounded-full transition-all shadow-sm", votingEnabled ? "left-8" : "left-1")} />
</div>
</div>
<p className="text-xs text-muted font-mono">
{votingEnabled
? "STATUS: O sistema de votação aparecerá automaticamente ao encerrar as partidas."
: "STATUS: O sistema está desativado. Nenhuma opção de voto será mostrada."
}
</p>
</div>
<div className="pt-4 border-t border-white/5">
<p className="text-[10px] text-muted uppercase tracking-widest font-bold text-center">
Regras Atuais: Craque (+1), Equilibrado (0), Pereba (-1)
</p>
</div>
</div>
<AnimatePresence>
{error && (
<motion.div initial={{ opacity: 0, y: 10 }} animate={{ opacity: 1, y: 0 }} exit={{ opacity: 0 }} className="p-4 rounded-xl bg-red-500/10 border border-red-500/20 text-red-500 flex items-center gap-3">
<AlertCircle className="w-5 h-5 shrink-0" />
<p className="text-sm font-bold uppercase tracking-tight">{error}</p>
</motion.div>
)}
{successMsg && (
<motion.div initial={{ opacity: 0, y: 10 }} animate={{ opacity: 1, y: 0 }} exit={{ opacity: 0 }} className="p-4 rounded-xl bg-emerald-500/10 border border-emerald-500/20 text-emerald-500 flex items-center gap-3">
<CheckCircle className="w-5 h-5 shrink-0" />
<p className="text-sm font-bold uppercase tracking-tight">{successMsg}</p>
</motion.div>
)}
</AnimatePresence>
<div className="flex justify-end">
<button
type="submit"
disabled={isPending}
className="ui-button w-full sm:w-auto h-12 px-8 shadow-xl shadow-primary/10 text-sm font-bold uppercase tracking-widest"
>
{isPending ? (
<>
<Loader2 className="w-4 h-4 animate-spin mr-2" />
Salvando...
</>
) : (
<>
<Save className="w-4 h-4 mr-2" />
Salvar Preferências
</>
)}
</button>
</div>
</form>
)
}