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

5
.agent/rules/regras.md Normal file
View File

@@ -0,0 +1,5 @@
---
trigger: always_on
---
suba o container apos uma atualizacao

2
next-env.d.ts vendored
View File

@@ -1,6 +1,6 @@
/// <reference types="next" /> /// <reference types="next" />
/// <reference types="next/image-types/global" /> /// <reference types="next/image-types/global" />
import "./.next/types/routes.d.ts"; import "./.next/dev/types/routes.d.ts";
// NOTE: This file should not be edited // NOTE: This file should not be edited
// see https://nextjs.org/docs/app/api-reference/config/typescript for more information. // see https://nextjs.org/docs/app/api-reference/config/typescript for more information.

11
package-lock.json generated
View File

@@ -16,6 +16,7 @@
"bcryptjs": "^3.0.3", "bcryptjs": "^3.0.3",
"clsx": "^2.1.1", "clsx": "^2.1.1",
"cookies-next": "^6.1.1", "cookies-next": "^6.1.1",
"date-fns": "^4.1.0",
"framer-motion": "^12.26.2", "framer-motion": "^12.26.2",
"lucide-react": "^0.562.0", "lucide-react": "^0.562.0",
"minio": "^8.0.6", "minio": "^8.0.6",
@@ -3194,6 +3195,16 @@
"url": "https://github.com/sponsors/ljharb" "url": "https://github.com/sponsors/ljharb"
} }
}, },
"node_modules/date-fns": {
"version": "4.1.0",
"resolved": "https://registry.npmjs.org/date-fns/-/date-fns-4.1.0.tgz",
"integrity": "sha512-Ukq0owbQXxa/U3EGtsdVBkR1w7KOQ5gIBqdH2hkvknzZPYvBxb/aa6E8L7tmjFtkwZBu3UXBbjIgPo/Ez4xaNg==",
"license": "MIT",
"funding": {
"type": "github",
"url": "https://github.com/sponsors/kossnocorp"
}
},
"node_modules/debug": { "node_modules/debug": {
"version": "4.4.3", "version": "4.4.3",
"resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz",

View File

@@ -17,6 +17,7 @@
"bcryptjs": "^3.0.3", "bcryptjs": "^3.0.3",
"clsx": "^2.1.1", "clsx": "^2.1.1",
"cookies-next": "^6.1.1", "cookies-next": "^6.1.1",
"date-fns": "^4.1.0",
"framer-motion": "^12.26.2", "framer-motion": "^12.26.2",
"lucide-react": "^0.562.0", "lucide-react": "^0.562.0",
"minio": "^8.0.6", "minio": "^8.0.6",

View File

@@ -41,10 +41,13 @@ model Group {
sponsors Sponsor[] sponsors Sponsor[]
arenas Arena[] arenas Arena[]
financialEvents FinancialEvent[] financialEvents FinancialEvent[]
transactions Transaction[]
pixKey String? pixKey String?
pixName String? pixName String?
status GroupStatus @default(ACTIVE) status GroupStatus @default(ACTIVE)
showTotalInPublic Boolean @default(true) showTotalInPublic Boolean @default(true)
votingEnabled Boolean @default(true)
teamConfigs TeamConfig[]
} }
enum GroupStatus { enum GroupStatus {
@@ -71,6 +74,9 @@ model Player {
teams TeamPlayer[] teams TeamPlayer[]
attendances Attendance[] attendances Attendance[]
payments Payment[] payments Payment[]
reviews Review[]
matchEvents MatchEvent[]
transactions Transaction[]
@@unique([number, groupId]) @@unique([number, groupId])
} }
@@ -95,7 +101,7 @@ model Match {
arena Arena? @relation(fields: [arenaId], references: [id]) arena Arena? @relation(fields: [arenaId], references: [id])
maxPlayers Int? maxPlayers Int?
drawSeed String? drawSeed String?
status MatchStatus @default(SCHEDULED) status MatchStatus @default(CONVOCACAO)
groupId String groupId String
createdAt DateTime @default(now()) createdAt DateTime @default(now())
@@ -105,15 +111,36 @@ model Match {
isRecurring Boolean @default(false) isRecurring Boolean @default(false)
recurrenceInterval String? // 'WEEKLY' recurrenceInterval String? // 'WEEKLY'
recurrenceEndDate DateTime? recurrenceEndDate DateTime?
duration Int? @default(60) // Duração em minutos
actualStartTime DateTime?
actualEndTime DateTime?
enableVoting Boolean @default(true)
votingDuration Int @default(72) // Duração da votação em horas (24, 48, 72)
gamificationType String @default("PADRAO") // PADRAO, PAREDAO, OSCAR
events MatchEvent[]
reviews Review[]
} }
model Team { model Team {
id String @id @default(cuid()) id String @id @default(cuid())
name String name String
color String color String
shirtUrl String?
matchId String matchId String
match Match @relation(fields: [matchId], references: [id], onDelete: Cascade) match Match @relation(fields: [matchId], references: [id], onDelete: Cascade)
players TeamPlayer[] players TeamPlayer[]
matchEvents MatchEvent[]
}
model TeamConfig {
id String @id @default(cuid())
name String
color String @default("#000000")
shirtUrl String?
groupId String
group Group @relation(fields: [groupId], references: [id])
createdAt DateTime @default(now())
} }
model TeamPlayer { model TeamPlayer {
@@ -126,8 +153,11 @@ model TeamPlayer {
enum MatchStatus { enum MatchStatus {
SCHEDULED SCHEDULED
CONVOCACAO
SORTEIO
LIVE
IN_PROGRESS IN_PROGRESS
COMPLETED ENCERRAMENTO
CANCELED CANCELED
} }
@@ -207,3 +237,49 @@ enum PaymentStatus {
PAID PAID
WAIVED WAIVED
} }
model MatchEvent {
id String @id @default(cuid())
matchId String
match Match @relation(fields: [matchId], references: [id], onDelete: Cascade)
type String // GOAL, CARD_YELLOW, CARD_RED, etc.
playerId String?
player Player? @relation(fields: [playerId], references: [id])
teamId String?
team Team? @relation(fields: [teamId], references: [id])
minute Int?
createdAt DateTime @default(now())
}
model Review {
id String @id @default(cuid())
matchId String
match Match @relation(fields: [matchId], references: [id], onDelete: Cascade)
playerId String // Atleta que está sendo avaliado
player Player @relation(fields: [playerId], references: [id])
reviewerId String? // Opcional
type String // STAR (Craque), PEREBA, FAIR_PLAY, etc.
createdAt DateTime @default(now())
}
model Transaction {
id String @id @default(cuid())
description String
amount Float
type TransactionType @default(INCOME)
category String? // 'Mensalidade', 'Avulso', 'Sobra', 'Material', 'Quadra', etc.
date DateTime @default(now())
groupId String
group Group @relation(fields: [groupId], references: [id])
playerId String? // Opcional: para vincular a um atleta específico (ex: avulso)
player Player? @relation(fields: [playerId], references: [id])
createdAt DateTime @default(now())
}
enum TransactionType {
INCOME // Entrada
EXPENSE // Saída
}

View File

@@ -65,3 +65,25 @@ export async function deleteArena(id: string) {
return { success: false, error: 'Erro ao deletar arena' } return { success: false, error: 'Erro ao deletar arena' }
} }
} }
export async function updateArena(id: string, formData: FormData) {
const group = await getActiveGroup()
if (!group) return { success: false, error: 'Unauthorized' }
const name = formData.get('name') as string
const address = formData.get('address') as string
try {
await prisma.arena.update({
where: { id, groupId: group.id },
data: { name, address }
})
revalidatePath('/dashboard/settings')
revalidatePath('/dashboard/matches')
return { success: true }
} catch (error) {
console.error('Error updating arena:', error)
return { success: false, error: 'Erro ao atualizar arena' }
}
}

View File

@@ -260,3 +260,67 @@ export async function toggleEventPrivacy(eventId: string, showTotal: boolean) {
revalidatePath('/dashboard/financial') revalidatePath('/dashboard/financial')
return { success: true } return { success: true }
} }
// --- Transactions (Generic Cash Flow) ---
export async function createTransaction(data: {
description: string
amount: number
type: 'INCOME' | 'EXPENSE'
category?: string
playerId?: string
date?: string
}) {
const group = await getActiveGroup()
if (!group) return { success: false, error: 'Unauthorized' }
try {
const transaction = await (prisma as any).transaction.create({
data: {
description: data.description,
amount: data.amount,
type: data.type as any,
category: data.category,
playerId: data.playerId || null,
date: data.date ? new Date(data.date) : new Date(),
groupId: group.id
}
})
revalidatePath('/dashboard/financial')
return { success: true, transaction }
} catch (error) {
console.error('Error creating transaction:', error)
return { success: false, error: 'Erro ao criar transação' }
}
}
export async function getTransactions() {
const group = await getActiveGroup()
if (!group) return []
return await (prisma as any).transaction.findMany({
where: { groupId: group.id },
include: {
player: true
},
orderBy: { date: 'desc' }
})
}
export async function deleteTransactions(ids: string[]) {
const group = await getActiveGroup()
if (!group) return { success: false, error: 'Unauthorized' }
try {
await (prisma as any).transaction.deleteMany({
where: {
id: { in: ids },
groupId: group.id
}
})
revalidatePath('/dashboard/financial')
return { success: true }
} catch (error) {
return { success: false, error: 'Erro ao deletar transações' }
}
}

View File

@@ -9,17 +9,27 @@ export async function createMatch(
groupId: string, groupId: string,
date: string, date: string,
teamsData: any[], teamsData: any[],
status: MatchStatus = 'IN_PROGRESS', status: MatchStatus = 'SORTEIO' as any, // Default for immediate matches
location?: string, location?: string,
maxPlayers?: number, maxPlayers?: number,
drawSeed?: string, drawSeed?: string,
arenaId?: string arenaId?: string,
enableVoting: boolean = true,
votingDuration: number = 72,
gamificationType: string = 'PADRAO',
duration: number = 60
) { ) {
const match = await prisma.match.create({ const match = await prisma.match.create({
data: { data: {
date: new Date(date), date: new Date(date),
groupId: groupId, groupId: groupId,
status: status, status: status as any,
// @ts-ignore
enableVoting: enableVoting as any,
// @ts-ignore
votingDuration: votingDuration,
// @ts-ignore
gamificationType: gamificationType,
// @ts-ignore // @ts-ignore
location: location, location: location,
arenaId: arenaId || null, arenaId: arenaId || null,
@@ -27,10 +37,13 @@ export async function createMatch(
maxPlayers: maxPlayers, maxPlayers: maxPlayers,
// @ts-ignore // @ts-ignore
drawSeed: drawSeed, drawSeed: drawSeed,
// @ts-ignore
duration: duration,
teams: { teams: {
create: teamsData.map(team => ({ create: teamsData.map(team => ({
name: team.name, name: team.name,
color: team.color, color: team.color,
shirtUrl: team.shirtUrl,
players: { players: {
create: team.players.map((p: any) => ({ create: team.players.map((p: any) => ({
playerId: p.id playerId: p.id
@@ -38,6 +51,80 @@ export async function createMatch(
} }
})) }))
} }
},
include: {
teams: {
include: {
players: {
include: {
player: true
}
}
}
}
}
})
revalidatePath('/dashboard/matches')
return match
}
export async function updateMatchWithTeams(
matchId: string,
teamsData: any[],
status: MatchStatus = 'SORTEIO' as any,
drawSeed?: string,
enableVoting: boolean = true,
votingDuration: number = 72,
gamificationType: string = 'PADRAO',
duration: number = 60
) {
// Delete existing teams if any (idempotency)
await prisma.teamPlayer.deleteMany({
where: { team: { matchId } }
})
await prisma.team.deleteMany({
where: { matchId }
})
const match = await (prisma.match as any).update({
where: { id: matchId },
data: {
status: status as any,
actualEndTime: (status as any) === 'IN_PROGRESS' ? new Date() : undefined,
// @ts-ignore
drawSeed,
// @ts-ignore
enableVoting: enableVoting as any,
// @ts-ignore
votingDuration: votingDuration,
// @ts-ignore
gamificationType: gamificationType,
// @ts-ignore
duration: duration,
teams: {
create: teamsData.map(team => ({
name: team.name,
color: team.color,
shirtUrl: team.shirtUrl,
players: {
create: team.players.map((p: any) => ({
playerId: p.id
}))
}
}))
}
},
include: {
teams: {
include: {
players: {
include: {
player: true
}
}
}
}
} }
}) })
@@ -104,7 +191,7 @@ export async function createScheduledMatch(
arenaId: validArenaId, arenaId: validArenaId,
// @ts-ignore // @ts-ignore
maxPlayers, maxPlayers,
status: 'SCHEDULED' as MatchStatus, status: 'CONVOCACAO' as any,
// @ts-ignore // @ts-ignore
isRecurring, isRecurring,
// @ts-ignore // @ts-ignore
@@ -139,17 +226,21 @@ export async function createScheduledMatch(
} }
export async function updateMatchStatus(matchId: string, status: MatchStatus) { export async function updateMatchStatus(matchId: string, status: MatchStatus) {
const match = await prisma.match.update({ const data: any = { status }
if ((status as any) === 'SORTEIO') {
data.actualStartTime = new Date()
} else if ((status as any) === 'ENCERRAMENTO') {
data.actualEndTime = new Date()
}
const match = await (prisma.match as any).update({
where: { id: matchId }, where: { id: matchId },
data: { status } data
}) })
// If match is completed and was recurring, create the next one ONLY if it doesn't exist yet // If match is completed and was recurring, create the next one ONLY if it doesn't exist yet
// This handles the "infinite" case by extending the chain as matches are played if ((status as any) === 'ENCERRAMENTO' && (match as any).isRecurring) {
// If match is completed and was recurring, create the next one ONLY if it doesn't exist yet
// This handles the "infinite" case by extending the chain as matches are played
// @ts-ignore
if (status === 'COMPLETED' && match.isRecurring) {
const nextDate = new Date(match.date) const nextDate = new Date(match.date)
// @ts-ignore // @ts-ignore
const interval = match.recurrenceInterval const interval = match.recurrenceInterval
@@ -176,7 +267,7 @@ export async function updateMatchStatus(matchId: string, status: MatchStatus) {
const existingNextMatch = await prisma.match.findFirst({ const existingNextMatch = await prisma.match.findFirst({
where: { where: {
groupId: match.groupId, groupId: match.groupId,
status: 'SCHEDULED', status: 'CONVOCACAO' as any,
date: { date: {
gte: new Date(nextDate.getTime() - 24 * 60 * 60 * 1000), gte: new Date(nextDate.getTime() - 24 * 60 * 60 * 1000),
lte: new Date(nextDate.getTime() + 24 * 60 * 60 * 1000) lte: new Date(nextDate.getTime() + 24 * 60 * 60 * 1000)
@@ -192,7 +283,7 @@ export async function updateMatchStatus(matchId: string, status: MatchStatus) {
location: match.location, location: match.location,
arenaId: match.arenaId, arenaId: match.arenaId,
maxPlayers: match.maxPlayers, maxPlayers: match.maxPlayers,
status: 'SCHEDULED', status: 'CONVOCACAO' as any,
// @ts-ignore // @ts-ignore
isRecurring: true, isRecurring: true,
// @ts-ignore // @ts-ignore
@@ -262,10 +353,10 @@ export async function getPublicScheduledMatches(slug: string) {
if (!group) return { group: null, matches: [] } if (!group) return { group: null, matches: [] }
// Fetch matches that are SCHEDULED and date is in the future or today // Fetch matches that are SCHEDULED and date is in the future or today
const matches = await prisma.match.findMany({ const matches = await (prisma.match as any).findMany({
where: { where: {
groupId: group.id, groupId: group.id,
status: 'SCHEDULED', status: 'CONVOCACAO' as any,
date: { date: {
gte: new Date(new Date().setHours(0, 0, 0, 0)) gte: new Date(new Date().setHours(0, 0, 0, 0))
} }
@@ -288,3 +379,156 @@ export async function getPublicScheduledMatches(slug: string) {
return { group, matches } return { group, matches }
} }
export async function logMatchEvent(
matchId: string,
type: string,
teamId?: string,
playerId?: string
) {
const event = await (prisma as any).matchEvent.create({
data: {
matchId,
type,
teamId,
playerId
}
})
return event
}
export async function submitReviews(matchId: string, reviews: { playerId: string, type: string }[], reviewerId?: string) {
// Check 3 day limit
const match = await prisma.match.findUnique({
where: { id: matchId }
})
if (!match) throw new Error('Match not found')
const matchDate = (match as any).actualEndTime || (match as any).date
const now = new Date()
const matchTime = new Date(matchDate).getTime()
const nowTime = now.getTime()
const hoursSinceMatch = (nowTime - matchTime) / (1000 * 60 * 60)
const limitHours = (match as any).votingDuration || 72
// Only block if NOT admin AND (match in past AND exceeds limit AND finalized)
// We allow voting on IN_PROGRESS matches regardless of time to favor testing and late starts
if (reviewerId !== 'ADMIN' && (match as any).status === 'ENCERRAMENTO') {
if (hoursSinceMatch > limitHours) {
throw new Error(`O prazo para votação expirou (limite de ${limitHours} horas).`)
}
}
// delete previous reviews for this match by THIS reviewer if identified
// If anonymous (reviewerId not provided), we still identify by reviewerId: null
await (prisma as any).review.deleteMany({
where: {
matchId,
reviewerId: reviewerId || null
}
})
const created = await (prisma as any).review.createMany({
data: reviews.map(r => ({
matchId,
playerId: r.playerId,
type: r.type,
reviewerId: reviewerId || null
}))
})
// Auto-finalize if all players have voted
if (reviewerId && reviewerId !== 'ADMIN') {
const matchData = await prisma.match.findUnique({
where: { id: matchId },
include: {
teams: {
include: {
players: true
}
},
reviews: {
select: { reviewerId: true }
}
}
})
if (matchData && (matchData as any).status === 'IN_PROGRESS') {
const totalPlayersCount = matchData.teams.reduce((acc, team) => acc + team.players.length, 0)
const uniqueVotersCount = new Set((matchData as any).reviews.map((r: any) => r.reviewerId).filter(Boolean)).size
if (uniqueVotersCount >= totalPlayersCount) {
await prisma.match.update({
where: { id: matchId },
data: { status: 'ENCERRAMENTO' as any }
})
}
}
}
return created
}
export async function getMatchForVoting(matchId: string) {
const match = await prisma.match.findUnique({
where: { id: matchId },
include: {
group: true,
teams: {
include: {
players: {
include: {
player: true
}
}
}
}
}
})
if (!match) return null
// Check if voting is enabled
if (!(match as any).enableVoting) return null
return match as any
}
export async function getGamificationResults(matchId: string) {
const reviews = await prisma.review.findMany({
where: { matchId },
include: {
player: true
}
})
const results: Record<string, { craque: number, pereba: number, fairPlay: number, player: any }> = {}
const votedPlayerIds = new Set<string>()
reviews.forEach(review => {
// Collect results per athlete
if (!results[review.playerId]) {
results[review.playerId] = { craque: 0, pereba: 0, fairPlay: 0, player: review.player }
}
if (review.type === 'CRAQUE') results[review.playerId].craque++
else if (review.type === 'PEREBA') results[review.playerId].pereba++
else if (review.type === 'FAIR_PLAY') results[review.playerId].fairPlay++
// Track who voted
if (review.reviewerId && review.reviewerId !== 'ADMIN') {
votedPlayerIds.add(review.reviewerId)
}
})
// Fetch winners names if needed or just return IDs
const votedPlayers = await prisma.player.findMany({
where: { id: { in: Array.from(votedPlayerIds) } }
})
return {
results: Object.values(results).sort((a, b) => (b.craque - b.pereba) - (a.craque - a.pereba)),
votedPlayers: votedPlayers
}
}

View File

@@ -39,3 +39,21 @@ export async function deleteSponsor(id: string) {
}) })
revalidatePath('/dashboard/settings') revalidatePath('/dashboard/settings')
} }
export async function updateSponsor(id: string, formData: FormData) {
const name = formData.get('name') as string
const logoFile = formData.get('logo') as File
let data: any = { name }
if (logoFile && logoFile.size > 0) {
data.logoUrl = await uploadFile(logoFile)
}
const sponsor = await prisma.sponsor.update({
where: { id },
data
})
revalidatePath('/dashboard/settings')
return sponsor
}

View File

@@ -0,0 +1,65 @@
'use server'
import { prisma } from '@/lib/prisma'
import { revalidatePath } from 'next/cache'
import { uploadFile } from '@/lib/upload'
export async function getTeamConfigs(groupId: string) {
// @ts-ignore
return await prisma.teamConfig.findMany({
where: { groupId },
orderBy: { createdAt: 'asc' }
})
}
export async function createTeamConfig(formData: FormData) {
const groupId = formData.get('groupId') as string
const name = formData.get('name') as string
const color = formData.get('color') as string
const shirtFile = formData.get('shirt') as File
let shirtUrl = null
if (shirtFile && shirtFile.size > 0) {
shirtUrl = await uploadFile(shirtFile)
}
// @ts-ignore
const config = await prisma.teamConfig.create({
data: {
name,
color,
shirtUrl,
groupId
}
})
revalidatePath('/dashboard/settings')
return config
}
export async function updateTeamConfig(id: string, formData: FormData) {
const name = formData.get('name') as string
const color = formData.get('color') as string
const shirtFile = formData.get('shirt') as File
let data: any = { name, color }
if (shirtFile && shirtFile.size > 0) {
data.shirtUrl = await uploadFile(shirtFile)
}
// @ts-ignore
const config = await prisma.teamConfig.update({
where: { id },
data
})
revalidatePath('/dashboard/settings')
return config
}
export async function deleteTeamConfig(id: string) {
// @ts-ignore
await prisma.teamConfig.delete({
where: { id }
})
revalidatePath('/dashboard/settings')
}

View File

@@ -1,14 +1,14 @@
import { getActiveGroup } from '@/lib/auth' import { getActiveGroup } from '@/lib/auth'
import { getFinancialEvents } from '@/actions/finance' import { getFinancialEvents, getTransactions } from '@/actions/finance'
import { FinancialDashboard } from '@/components/FinancialDashboard' import { FinancialDashboard } from '@/components/FinancialDashboard'
export default async function FinancialPage() { export default async function FinancialPage() {
const group = await getActiveGroup() const group = await getActiveGroup()
if (!group) return null if (!group) return null
// We fetch events and players // We fetch events, transactions and players
// getActiveGroup already includes players, so we can use that list for selection
const events = await getFinancialEvents() const events = await getFinancialEvents()
const transactions = await getTransactions()
return ( return (
<div className="max-w-5xl mx-auto space-y-8 pb-12"> <div className="max-w-5xl mx-auto space-y-8 pb-12">
@@ -19,7 +19,12 @@ export default async function FinancialPage() {
</div> </div>
</header> </header>
<FinancialDashboard events={events} players={group.players} group={group} /> <FinancialDashboard
events={events}
transactions={transactions}
players={group.players}
group={group}
/>
</div> </div>
) )
} }

View File

@@ -4,11 +4,13 @@ import Link from 'next/link'
import { ChevronLeft } from 'lucide-react' import { ChevronLeft } from 'lucide-react'
import { getArenas } from '@/actions/arena' import { getArenas } from '@/actions/arena'
import { getSponsors } from '@/actions/sponsor' import { getSponsors } from '@/actions/sponsor'
import { getTeamConfigs } from '@/actions/team-config'
export default async function NewMatchPage() { export default async function NewMatchPage() {
const group = await getActiveGroup() const group = await getActiveGroup()
const arenas = await getArenas() const arenas = await getArenas()
const sponsors = await getSponsors(group?.id || '') const sponsors = await getSponsors(group?.id || '')
const teamConfigs = await getTeamConfigs(group?.id || '')
return ( return (
<div className="space-y-8"> <div className="space-y-8">
@@ -25,7 +27,7 @@ export default async function NewMatchPage() {
</div> </div>
</header> </header>
<MatchFlow group={group} arenas={arenas} sponsors={sponsors} /> <MatchFlow group={group} arenas={arenas} sponsors={sponsors} teamConfigs={teamConfigs} />
</div> </div>
) )
} }

View File

@@ -1,321 +1,24 @@
'use client'
import React, { useState, useMemo } from 'react'
import { Calendar, MapPin, Users, ArrowRight, Trophy, Repeat, Hash, ChevronRight } from 'lucide-react'
import { createScheduledMatch } from '@/actions/match'
import { useRouter } from 'next/navigation'
import { getArenas } from '@/actions/arena' import { getArenas } from '@/actions/arena'
import type { Arena } from '@prisma/client' import { MatchScheduler } from '@/components/MatchScheduler'
import { DateTimePicker } from '@/components/DateTimePicker' import Link from 'next/link'
import { motion, AnimatePresence } from 'framer-motion' import { ChevronLeft } from 'lucide-react'
import { clsx } from 'clsx'
export default function ScheduleMatchPage() { export default async function SchedulePage() {
const router = useRouter() const arenas = await getArenas()
const [date, setDate] = useState('')
const [location, setLocation] = useState('')
const [selectedArenaId, setSelectedArenaId] = useState('')
const [arenas, setArenas] = useState<Arena[]>([])
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)
React.useEffect(() => {
getArenas().then(setArenas)
}, [])
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 ( return (
<div className="max-w-3xl mx-auto pb-20"> <div className="max-w-5xl mx-auto space-y-8">
<header className="flex flex-col md:flex-row md:items-end justify-between gap-6 mb-12"> <header className="flex items-center gap-4">
<div className="space-y-2"> <Link
<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"> href="/dashboard/matches"
<Calendar className="w-3 h-3" /> className="p-2 hover:bg-surface-raised rounded-lg transition-all border border-border text-muted hover:text-foreground"
Novo Agendamento >
</div> <ChevronLeft className="w-5 h-5" />
<h1 className="text-4xl font-black tracking-tighter uppercase leading-none"> </Link>
Agendar <span className="text-primary text-outline-sm">Evento</span> <span className="text-xs font-bold uppercase tracking-widest text-muted">Voltar para Partidas</span>
</h1>
<p className="text-muted text-sm font-medium">Crie um link de confirmação e organize sua pelada.</p>
</div>
</header> </header>
<div className="grid grid-cols-1 lg:grid-cols-5 gap-8"> <MatchScheduler arenas={arenas} />
<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 Básicos</h2>
</div>
<DateTimePicker
label="Data e Horário de Início"
value={date}
onChange={setDate}
required
/>
<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 de Atletas</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 overflow-hidden relative">
<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">
<DateTimePicker
label="Data Limite de Repetição"
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 (será criado à medida que as peladas forem finalizadas).
</p>
</div>
</motion.div>
)}
</AnimatePresence>
{!isRecurring && (
<p className="text-xs text-muted/60 mt-2">
Este evento será único. Ative a recorrência para criar peladas fixas no calendário.
</p>
)}
</section>
<div className="pt-4 flex flex-col sm:flex-row gap-4">
<button
type="button"
onClick={() => router.back()}
className="ui-button-ghost flex-1 h-14 text-sm font-bold uppercase tracking-widest"
>
Cancelar
</button>
<button
type="submit"
disabled={isSubmitting || !date}
className="ui-button flex-[2] 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 ? 'Gerando Link...' : 'Agendar Evento Agora'}
{!isSubmitting && <ArrowRight className="w-4 h-4 group-hover:translate-x-1 transition-transform" />}
</span>
<div className="absolute inset-0 bg-gradient-to-r from-emerald-400 to-emerald-600 opacity-0 group-hover:opacity-100 transition-opacity" />
</button>
</div>
</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">O que acontece depois?</h3>
</div>
<div className="space-y-4">
{[
{ title: 'Link Gerado', desc: 'Sua pelada terá uma página pública exclusiva para confirmações.' },
{ title: 'Gestão Inteligente', desc: 'Acompanhe quem pagou e quem furou direto no seu celular.' },
{ title: 'Sorteio Fácil', desc: 'Em um clique o sistema sorteia os times baseado no nível técnico.' }
].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">Agenda de Freqüência:</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">Evento #{i + 2}</span>
<span className="font-black text-white">{d.toLocaleDateString('pt-BR', { day: '2-digit', month: 'long' })}</span>
</div>
))}
<p className="text-[9px] text-zinc-500 italic mt-3 text-center">
As demais datas serão geradas progressivamente.
</p>
</div>
</div>
)}
</div>
</aside>
</div>
</div> </div>
) )
} }

View File

@@ -70,7 +70,7 @@ export default async function DashboardPage() {
<div> <div>
<p className="font-medium text-sm">Pelada de {new Date(match.date).toLocaleDateString('pt-BR', { weekday: 'long' })}</p> <p className="font-medium text-sm">Pelada de {new Date(match.date).toLocaleDateString('pt-BR', { weekday: 'long' })}</p>
<p className="text-xs text-muted mt-0.5"> <p className="text-xs text-muted mt-0.5">
{match.status === 'SCHEDULED' {match.status === 'CONVOCACAO'
? `${match.attendances.filter((a: any) => a.status === 'CONFIRMED').length} confirmados` ? `${match.attendances.filter((a: any) => a.status === 'CONFIRMED').length} confirmados`
: `${match.teams.length} times sorteados`} : `${match.teams.length} times sorteados`}
</p> </p>

View File

@@ -2,10 +2,14 @@ import { getActiveGroup } from '@/lib/auth'
import { SettingsForm } from '@/components/SettingsForm' import { SettingsForm } from '@/components/SettingsForm'
import { getArenas } from '@/actions/arena' import { getArenas } from '@/actions/arena'
import { getSponsors } from '@/actions/sponsor' import { getSponsors } from '@/actions/sponsor'
import { getTeamConfigs } from '@/actions/team-config'
import { ArenasManager } from '@/components/ArenasManager' import { ArenasManager } from '@/components/ArenasManager'
import { SponsorsManager } from '@/components/SponsorsManager' import { SponsorsManager } from '@/components/SponsorsManager'
import { TeamsManager } from '@/components/TeamsManager'
import { SettingsTabs } from '@/components/SettingsTabs' import { SettingsTabs } from '@/components/SettingsTabs'
import { VotingSettings } from '@/components/VotingSettings'
export default async function SettingsPage() { export default async function SettingsPage() {
const group = await getActiveGroup() const group = await getActiveGroup()
@@ -13,6 +17,7 @@ export default async function SettingsPage() {
const arenas = await getArenas() const arenas = await getArenas()
const sponsors = await getSponsors(group.id) const sponsors = await getSponsors(group.id)
const teams = await getTeamConfigs(group.id)
return ( return (
<div className="max-w-4xl mx-auto space-y-8 pb-12"> <div className="max-w-4xl mx-auto space-y-8 pb-12">
@@ -35,6 +40,8 @@ export default async function SettingsPage() {
}} }}
/> />
} }
voting={<VotingSettings votingEnabled={group.votingEnabled} />}
teams={<TeamsManager groupId={group.id} teams={teams} />}
arenas={<ArenasManager arenas={arenas} />} arenas={<ArenasManager arenas={arenas} />}
sponsors={<SponsorsManager groupId={group.id} sponsors={sponsors} />} sponsors={<SponsorsManager groupId={group.id} sponsors={sponsors} />}
/> />

View File

@@ -13,19 +13,19 @@ export async function updateGroupSettings(formData: FormData) {
const group = await getActiveGroup() const group = await getActiveGroup()
if (!group) throw new Error('Unauthorized') if (!group) throw new Error('Unauthorized')
const name = formData.get('name') as string const dataToUpdate: any = {}
// Slug is immutable intentionally
const primaryColor = formData.get('primaryColor') as string if (formData.has('name')) dataToUpdate.name = formData.get('name') as string
const secondaryColor = formData.get('secondaryColor') as string if (formData.has('primaryColor')) dataToUpdate.primaryColor = formData.get('primaryColor') as string
const pixKey = formData.get('pixKey') as string if (formData.has('secondaryColor')) dataToUpdate.secondaryColor = formData.get('secondaryColor') as string
const pixName = formData.get('pixName') as string if (formData.has('pixKey')) dataToUpdate.pixKey = formData.get('pixKey') as string
if (formData.has('pixName')) dataToUpdate.pixName = formData.get('pixName') as string
if (formData.has('votingEnabled')) dataToUpdate.votingEnabled = formData.get('votingEnabled') === 'true'
const logoFile = formData.get('logo') as File | null const logoFile = formData.get('logo') as File | null
let logoUrl = group.logoUrl
if (logoFile && logoFile.size > 0 && logoFile.name !== 'undefined') { if (logoFile && logoFile.size > 0 && logoFile.name !== 'undefined') {
try { try {
logoUrl = await uploadFile(logoFile) dataToUpdate.logoUrl = await uploadFile(logoFile)
} catch (error) { } catch (error) {
console.error("Upload failed", error) console.error("Upload failed", error)
} }
@@ -34,15 +34,7 @@ export async function updateGroupSettings(formData: FormData) {
try { try {
await prisma.group.update({ await prisma.group.update({
where: { id: group.id }, where: { id: group.id },
data: { data: dataToUpdate,
name,
// Slug NOT updated
primaryColor,
secondaryColor,
pixKey,
pixName,
logoUrl,
},
}) })
revalidatePath('/', 'layout') revalidatePath('/', 'layout')
return { success: true, slug: group.slug } return { success: true, slug: group.slug }

View File

@@ -19,7 +19,7 @@ async function isAdmin() {
export async function DELETE( export async function DELETE(
request: Request, request: Request,
{ params }: { params: { id: string } } { params }: { params: Promise<{ id: string }> }
) { ) {
if (!(await isAdmin())) { if (!(await isAdmin())) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
@@ -78,7 +78,7 @@ export async function DELETE(
export async function PATCH( export async function PATCH(
request: Request, request: Request,
{ params }: { params: { id: string } } { params }: { params: Promise<{ id: string }> }
) { ) {
if (!(await isAdmin())) { if (!(await isAdmin())) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })

View File

@@ -11,6 +11,7 @@
--color-primary: var(--primary-color); --color-primary: var(--primary-color);
--color-secondary: var(--secondary-color); --color-secondary: var(--secondary-color);
--color-secondary-foreground: color-mix(in srgb, var(--secondary-color), white 80%);
/* Emerald 500 */ /* Emerald 500 */
--color-primary-soft: color-mix(in srgb, var(--primary-color), transparent 90%); --color-primary-soft: color-mix(in srgb, var(--primary-color), transparent 90%);

View File

@@ -1,7 +1,7 @@
'use client' 'use client'
import React, { useState, useEffect, useMemo } from 'react' import React, { useState, useEffect, useMemo } from 'react'
import { Trophy, Calendar, MapPin, Users, Check, X, Star, Shuffle, ArrowRight, Search, Shield, Target, Zap, ChevronRight, User } from 'lucide-react' import { Trophy, Calendar, MapPin, Users, Check, X, Star, Shuffle, ArrowRight, Search, Shield, Target, Zap, ChevronRight, User, Clock } from 'lucide-react'
import { getMatchWithAttendance, confirmAttendance, cancelAttendance } from '@/actions/attendance' import { getMatchWithAttendance, confirmAttendance, cancelAttendance } from '@/actions/attendance'
import { motion, AnimatePresence } from 'framer-motion' import { motion, AnimatePresence } from 'framer-motion'
import { clsx } from 'clsx' import { clsx } from 'clsx'
@@ -24,6 +24,13 @@ export default function ConfirmationPage() {
const loadMatch = async () => { const loadMatch = async () => {
const data = await getMatchWithAttendance(id) const data = await getMatchWithAttendance(id)
// Auto-redirect to voting if enabled and active/ended
if (data && data.enableVoting && (data.status === 'IN_PROGRESS' || data.status === 'ENCERRAMENTO')) {
window.location.href = `/match/${id}/vote`
return
}
setMatch(data) setMatch(data)
setLoading(false) setLoading(false)
} }
@@ -113,6 +120,11 @@ export default function ConfirmationPage() {
{new Date(match.date).toLocaleDateString('pt-BR', { day: 'numeric', month: 'short' })} {new Date(match.date).toLocaleDateString('pt-BR', { day: 'numeric', month: 'short' })}
</div> </div>
<div className="w-1 h-1 rounded-full bg-border" /> <div className="w-1 h-1 rounded-full bg-border" />
<div className="flex items-center gap-2">
<Clock className="w-3.5 h-3.5 text-primary" />
{new Date(match.date).toLocaleTimeString('pt-BR', { hour: '2-digit', minute: '2-digit' })}
</div>
<div className="w-1 h-1 rounded-full bg-border" />
{match.location && ( {match.location && (
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<MapPin className="w-3.5 h-3.5 text-primary" /> <MapPin className="w-3.5 h-3.5 text-primary" />
@@ -156,11 +168,11 @@ export default function ConfirmationPage() {
</AnimatePresence> </AnimatePresence>
{/* Draw Results (If done) */} {/* Draw Results (If done) */}
{(match.status === 'IN_PROGRESS' || match.status === 'COMPLETED') && ( {(match.status === 'SORTEIO' || match.status === 'ENCERRAMENTO' || match.status === 'IN_PROGRESS') && (
<section className="space-y-6 animate-in fade-in duration-1000"> <section className="space-y-6 animate-in fade-in duration-1000">
<div className="flex items-center justify-between"> <div className="flex items-center justify-between">
<h3 className="text-xs font-black uppercase tracking-[0.2em] text-primary flex items-center gap-2"> <h3 className="text-xs font-black uppercase tracking-[0.2em] text-primary flex items-center gap-2">
<Shuffle className="w-4 h-4" /> Escalacões Geradas <Shuffle className="w-4 h-4" /> {match.status === 'SORTEIO' ? 'Times Definidos' : match.status === 'IN_PROGRESS' ? 'Resenha em Andamento' : 'Partida Encerrada'}
</h3> </h3>
<div className="px-3 py-1 bg-surface-raised text-primary border border-primary/20 rounded-lg text-[10px] font-mono font-bold tracking-tighter"> <div className="px-3 py-1 bg-surface-raised text-primary border border-primary/20 rounded-lg text-[10px] font-mono font-bold tracking-tighter">
SEED: {match.drawSeed || 'TRANS-1'} SEED: {match.drawSeed || 'TRANS-1'}
@@ -193,11 +205,33 @@ export default function ConfirmationPage() {
</div> </div>
))} ))}
</div> </div>
{match.enableVoting && (
<motion.div
initial={{ opacity: 0, scale: 0.95 }}
animate={{ opacity: 1, scale: 1 }}
className="pt-6"
>
<a
href={`/match/${match.id}/vote`}
className="ui-button flex flex-col items-center justify-center h-auto py-6 bg-gradient-to-br from-primary to-emerald-600 border-none group relative overflow-hidden text-center"
>
<div className="absolute inset-0 bg-white/10 opacity-0 group-hover:opacity-100 transition-opacity" />
<Zap className="w-8 h-8 text-white mb-2 group-hover:scale-110 transition-transform" />
<span className="text-sm font-black uppercase tracking-[0.2em] text-white">
{match.status === 'ENCERRAMENTO' ? 'Ver Resultados da Resenha' : 'Votar no Craque'}
</span>
<span className="text-[9px] font-bold uppercase tracking-widest text-white/60 mt-1">
{match.status === 'ENCERRAMENTO' ? 'A votação foi encerrada' : 'A resenha começou!'}
</span>
</a>
</motion.div>
)}
</section> </section>
)} )}
{/* Selection Box */} {/* Selection Box */}
{match.status === 'SCHEDULED' && ( {match.status === 'CONVOCACAO' && (
<section className="space-y-6 animate-in fade-in slide-in-from-bottom-6 duration-1000"> <section className="space-y-6 animate-in fade-in slide-in-from-bottom-6 duration-1000">
<div className="flex items-center justify-between"> <div className="flex items-center justify-between">
<h3 className="text-xs font-black uppercase tracking-[0.2em] text-primary flex items-center gap-2"> <h3 className="text-xs font-black uppercase tracking-[0.2em] text-primary flex items-center gap-2">

View File

@@ -0,0 +1,179 @@
import { getMatchForVoting, submitReviews, getGamificationResults } from '@/actions/match'
import { Star, Shield, Trophy, Zap, CheckCircle2, User, Users, RefreshCw, Check, AlertCircle } from 'lucide-react'
import { clsx } from 'clsx'
import { redirect } from 'next/navigation'
import { VotingFlow } from '@/components/VotingFlow'
import { MatchPodium } from '@/components/MatchPodium'
import { motion } from 'framer-motion'
interface VotePageProps {
params: Promise<{ id: string }>
}
export default async function PublicVotePage({ params }: VotePageProps) {
const { id: matchId } = await params
const match = await getMatchForVoting(matchId)
const resultsResponse = await getGamificationResults(matchId)
const voters = resultsResponse.votedPlayers
if (!match) {
return (
<div className="min-h-screen flex items-center justify-center bg-black p-6">
<div className="ui-card p-12 text-center space-y-6 max-w-md">
<div className="w-20 h-20 bg-red-500/10 border border-red-500/20 text-red-500 rounded-3xl flex items-center justify-center mx-auto">
<AlertCircle className="w-10 h-10" />
</div>
<div className="space-y-2">
<h1 className="text-2xl font-black uppercase italic tracking-tighter">Link Inválido</h1>
<p className="text-muted text-xs font-bold uppercase tracking-widest leading-relaxed">
Esta partida não existe ou a votação foi desativada pelo organizador.
</p>
</div>
<a href="/" className="ui-button w-full h-12 bg-white text-black font-black uppercase text-[10px] tracking-widest flex items-center justify-center">
Voltar ao Início
</a>
</div>
</div>
)
}
// Identify who already voted for this match to show in a "voted" list
// This is optional but nice for the requirement
const allPlayers = match.teams.flatMap((t: any) => t.players.map((tp: any) => tp.player))
// Check if voting expired or match finalized
const matchDate = match.actualEndTime || match.date
const now = new Date()
const matchTime = new Date(matchDate).getTime()
const hoursSinceMatch = (now.getTime() - matchTime) / (1000 * 60 * 60)
// Only expired if time passed is POSITIVE and greater than duration
// If hoursSinceMatch is negative, it means the match is in the future, so NOT expired.
const isExpired = (hoursSinceMatch > 0 && hoursSinceMatch > (match.votingDuration || 72)) || match.status === 'ENCERRAMENTO'
if (isExpired) {
// If we have results, show the Podium!
if (resultsResponse && resultsResponse.results && resultsResponse.results.length > 0) {
return (
<div className="min-h-screen bg-black text-white font-sans selection:bg-primary selection:text-black pb-20">
<div className="sticky top-0 z-50 bg-black/80 backdrop-blur-xl border-b border-white/5 py-4 px-6 md:px-12 mb-8">
<div className="max-w-6xl mx-auto flex items-center justify-between">
<h2 className="text-sm font-black uppercase italic tracking-tighter leading-none text-muted">Resultado Final</h2>
<div className="px-3 py-1 bg-red-500/10 border border-red-500/20 rounded-full">
<span className="text-[9px] font-black uppercase tracking-widest text-red-500">Votação Encerrada</span>
</div>
</div>
</div>
<main className="max-w-5xl mx-auto px-6 space-y-8 animate-in fade-in duration-1000">
<div className="text-center space-y-4 mb-12">
<div
className="inline-flex items-center justify-center w-20 h-20 bg-primary/10 rounded-3xl mb-4 border border-primary/20 shadow-[0_0_50px_rgba(16,185,129,0.2)]"
>
<Trophy className="w-10 h-10 text-primary" />
</div>
<h1 className="text-4xl md:text-6xl font-black uppercase italic tracking-tighter leading-none">
Os Destaques <br /><span className="text-transparent bg-clip-text bg-gradient-to-r from-primary to-emerald-600">Da Rodada</span>
</h1>
<p className="text-muted text-[10px] font-bold uppercase tracking-[0.4em]">Confira quem mandou bem na resenha</p>
</div>
<MatchPodium results={resultsResponse.results} />
<div className="flex justify-center pt-12">
<a href="/" className="ui-button px-12 h-14 bg-white text-black font-black uppercase tracking-[0.3em] text-xs flex items-center gap-2 hover:scale-105 transition-all">
Voltar ao Início
</a>
</div>
</main>
</div>
)
}
return (
<div className="min-h-screen flex items-center justify-center bg-black p-6">
<div className="ui-card p-12 text-center space-y-6 max-w-md border-orange-500/20">
<div className="w-20 h-20 bg-orange-500/10 border border-orange-500/20 text-orange-500 rounded-3xl flex items-center justify-center mx-auto">
<Zap className="w-10 h-10" />
</div>
<div className="space-y-2">
<h1 className="text-2xl font-black uppercase italic tracking-tighter">Votação Encerrada</h1>
<p className="text-muted text-xs font-bold uppercase tracking-widest leading-relaxed">
{match.status === 'ENCERRAMENTO'
? "O organizador encerrou a resenha e os resultados já foram consolidados."
: "O prazo para avaliar esta partida expirou."}
</p>
</div>
</div>
</div>
)
}
async function handleVoteAction(formData: FormData) {
'use server'
const reviews: any[] = []
const matchId = formData.get('matchId') as string
const reviewerId = formData.get('reviewerId') as string
if (!reviewerId) {
// This should ideally be handled by client validation but as a fallback:
return;
}
for (const [key, value] of formData.entries()) {
if (key.startsWith('review-')) {
const playerId = key.replace('review-', '')
if (value) {
reviews.push({ playerId, type: value })
}
}
}
if (reviews.length > 0) {
await submitReviews(matchId, reviews, reviewerId)
}
redirect(`/match/${matchId}/vote/success`)
}
return (
<div className="min-h-screen bg-black text-white font-sans selection:bg-primary selection:text-black">
{/* Header / Brand */}
<div className="sticky top-0 z-50 bg-black/80 backdrop-blur-xl border-b border-white/5 py-4 px-6">
<div className="max-w-6xl mx-auto flex items-center justify-between">
<div className="flex items-center gap-4">
<div className="w-10 h-10 rounded-xl bg-zinc-900 border border-white/5 overflow-hidden flex items-center justify-center">
{match.group.logoUrl ? (
<img src={match.group.logoUrl} alt={match.group.name} className="w-full h-full object-cover" />
) : (
<span className="font-black text-xs text-primary">{match.group.name.slice(0, 2).toUpperCase()}</span>
)}
</div>
<div>
<h2 className="text-sm font-black uppercase italic tracking-tighter leading-none">{match.group.name}</h2>
<p className="text-[8px] font-bold text-muted uppercase tracking-[0.3em] mt-1">Live Review Engine</p>
</div>
</div>
<div className="px-4 py-1.5 bg-primary/10 border border-primary/20 rounded-full hidden sm:block">
<span className="text-[9px] font-black uppercase tracking-widest text-primary">Pixel Perfect V3.0</span>
</div>
</div>
</div>
<main className="max-w-6xl mx-auto px-6 pb-24">
<VotingFlow
match={match}
allPlayers={allPlayers}
initialVoters={voters}
/>
</main>
<footer className="py-12 border-t border-white/5 text-center">
<div className="flex items-center justify-center gap-3 opacity-20">
<Trophy className="w-5 h-5 text-primary" />
<span className="text-[10px] font-black uppercase tracking-[0.5em]">TemFut Gamification Engine v3.0</span>
</div>
</footer>
</div>
)
}

View File

@@ -0,0 +1,76 @@
import { Trophy, CheckCircle2, Users, Check } from 'lucide-react'
import { getGamificationResults, getMatchForVoting } from '@/actions/match'
import { clsx } from 'clsx'
interface SuccessPageProps {
params: Promise<{ id: string }>
}
export default async function VoteSuccessPage({ params }: SuccessPageProps) {
const { id: matchId } = await params
const results = await getGamificationResults(matchId)
const match = await getMatchForVoting(matchId)
const voters = results.votedPlayers
const allPlayers = match?.teams.flatMap((t: any) => t.players.map((tp: any) => tp.player)) || []
return (
<div className="min-h-screen flex flex-col items-center justify-center bg-black p-6 gap-8">
<div className="ui-card p-16 text-center 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-[3rem]">
<div className="relative">
<div className="absolute inset-0 bg-primary blur-3xl opacity-20 scale-150" />
<div className="w-24 h-24 bg-primary text-black rounded-3xl flex items-center justify-center mx-auto relative z-10 animate-bounce">
<CheckCircle2 className="w-12 h-12" />
</div>
</div>
<div className="space-y-4">
<h1 className="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">
Sua opinião é o que faz o TemFut real. <br />Obrigado por fortalecer a resenha!
</p>
</div>
<div className="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 animate-in fade-in slide-in-from-bottom-8 duration-1000 delay-300">
<div className="ui-card p-8 bg-zinc-900/40 border-white/5 rounded-[2.5rem] backdrop-blur-md">
<div className="flex items-center justify-between mb-6">
<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">Quem votou</h3>
</div>
<div className="px-3 py-1 bg-primary/10 border border-primary/20 rounded-full">
<span className="text-[10px] font-black text-primary">{voters.length} / {allPlayers.length}</span>
</div>
</div>
<div className="flex flex-wrap justify-center gap-2">
{allPlayers.map((p: any) => {
const hasVoted = voters.some((v: any) => v.id === p.id)
return (
<div
key={p.id}
className={clsx(
"px-3 py-1.5 rounded-lg border transition-all flex items-center gap-2",
hasVoted
? "bg-primary/5 border-primary/20 text-primary"
: "bg-white/5 border-white/10 text-muted opacity-30"
)}
>
<span className="text-[9px] font-bold uppercase">{p.name}</span>
{hasVoted && <Check className="w-2.5 h-2.5" />}
</div>
)
})}
</div>
</div>
</div>
</div>
)
}

View File

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

View File

@@ -74,8 +74,8 @@ export function CreateFinanceEventModal({ isOpen, onClose, players }: CreateFina
const selectNone = () => setSelectedPlayers([]) const selectNone = () => setSelectedPlayers([])
return ( 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="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-hidden flex flex-col max-h-[90vh]"> <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"> <div className="p-6 border-b border-border">
<h3 className="text-lg font-bold">Novo Evento Financeiro</h3> <h3 className="text-lg font-bold">Novo Evento Financeiro</h3>
<p className="text-sm text-muted">Crie mensalidades, churrascos ou arrecadações.</p> <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> </div>
<DateTimePicker <div className="relative z-[100]">
label="Vencimento" <DateTimePicker
value={dueDate} label="Vencimento"
onChange={setDueDate} value={dueDate}
mode="date" onChange={setDueDate}
/> mode="date"
/>
</div>
<div className="bg-surface/50 border border-border p-4 rounded-xl space-y-4"> <div className="bg-surface/50 border border-border p-4 rounded-xl space-y-4">
<div className="flex items-center justify-between"> <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 placeholder?: string
required?: boolean required?: boolean
mode?: 'date' | 'datetime' mode?: 'date' | 'datetime'
className?: string
} }
export function DateTimePicker({ export function DateTimePicker({
@@ -20,7 +21,8 @@ export function DateTimePicker({
label, label,
placeholder, placeholder,
required, required,
mode = 'datetime' mode = 'datetime',
className
}: DateTimePickerProps) { }: DateTimePickerProps) {
const [isOpen, setIsOpen] = useState(false) const [isOpen, setIsOpen] = useState(false)
const [mounted, setMounted] = useState(false) const [mounted, setMounted] = useState(false)
@@ -112,15 +114,18 @@ export function DateTimePicker({
}, [viewDate]) }, [viewDate])
const handleDateSelect = (day: number, month: number, year: number) => { const handleDateSelect = (day: number, month: number, year: number) => {
const newDate = parseValue(value)
newDate.setFullYear(year)
newDate.setMonth(month)
newDate.setDate(day)
if (mode === 'date') { 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) setIsOpen(false)
} else { } 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 it's the first time selecting in datetime mode, set a default time
if (!value) { if (!value) {
newDate.setHours(19, 0, 0, 0) newDate.setHours(19, 0, 0, 0)
@@ -169,7 +174,7 @@ export function DateTimePicker({
const minutes = Array.from({ length: 12 }, (_, i) => i * 5) const minutes = Array.from({ length: 12 }, (_, i) => i * 5)
return ( 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>} {label && <label className="text-label ml-1">{label}</label>}
<div className="relative"> <div className="relative">
<div <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", "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", d.currentMonth ? "text-foreground" : "text-muted/10",
isSelected 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", : "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" !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 <button
type="button" type="button"
onClick={() => setIsOpen(false)} 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 Confirmar
</button> </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' 'use client'
import React, { useState, useMemo, useEffect } from 'react' 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 { motion, AnimatePresence } from 'framer-motion'
import { clsx } from 'clsx' import { clsx } from 'clsx'
import Link from 'next/link' import Link from 'next/link'
@@ -150,10 +150,36 @@ export function MatchHistory({ matches, players = [], groupName = 'Pelada' }: {
const getStatusInfo = (status: string) => { const getStatusInfo = (status: string) => {
switch (status) { switch (status) {
case 'SCHEDULED': return { label: 'Agendado', color: 'bg-blue-500/10 text-blue-500 border-blue-500/20' } case 'CONVOCACAO': return {
case 'IN_PROGRESS': return { label: 'Em Andamento', color: 'bg-primary/10 text-primary border-primary/20' } label: 'Convocação',
case 'COMPLETED': return { label: 'Concluído', color: 'bg-white/5 text-muted border-white/10' } color: 'bg-blue-500/10 text-blue-500 border-blue-500/20',
default: return { label: status, color: 'bg-white/5 text-muted border-white/10' } 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 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 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 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` + if (match.status === 'CONVOCACAO') {
`📅 *JOGO:* ${dateStr} às ${timeStr}\n` + const confirmed = (match.attendances || []).filter((a: any) => a.status === 'CONFIRMED')
`📍 *LOCAL:* ${match.location || 'A definir'}\n\n` + const confirmedIds = new Set(confirmed.map((a: any) => a.playerId))
`✅ *CONFIRMADOS (${confirmed.length}/${match.maxPlayers || '∞'}):*\n` + const pending = players.filter(p => !confirmedIds.has(p.id))
(confirmed.length > 0
? confirmed.map((a: any) => `${a.player.name}`).join('\n') text = `⚽ *LISTA DE PRESENÇA: ${finalGroupName}* ⚽\n\n` +
: "_Nenhuma confirmação ainda_") + `📅 *JOGO:* ${dateStr} às ${timeStr}\n` +
`\n\n⏳ *AGUARDANDO:* \n` + `📍 *LOCAL:* ${match.location || 'A definir'}\n\n` +
(pending.length > 0 `✅ *CONFIRMADOS (${confirmed.length}/${match.maxPlayers || '∞'}):*\n` +
? pending.map((p: any) => `▫️ ${p.name}`).join('\n') (confirmed.length > 0
: "_Todos confirmados!_") + ? confirmed.map((a: any) => `${a.player.name}`).join('\n')
`\n\n🔗 *Confirme sua presença aqui:* ${url}` : "_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) navigator.clipboard.writeText(text)
setCopySuccess('Lista formatada copiada!') setCopySuccess('Texto para WhatsApp copiado!')
setTimeout(() => setCopySuccess(null), 2000) setTimeout(() => setCopySuccess(null), 2000)
window.open(`https://api.whatsapp.com/send?text=${encodeURIComponent(text)}`, '_blank') 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="space-y-1 min-w-0 flex-1">
<div className="flex items-center gap-2 flex-wrap"> <div className="flex items-center gap-2 flex-wrap">
<p className="text-sm font-semibold truncate"> <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> </p>
{match.isRecurring && ( {match.isRecurring && (
<span className="badge bg-purple-500/10 text-purple-500 border-purple-500/20 px-1.5 py-0.5" title="Recorrente"> <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> </span>
)} )}
</div> </div>
<div className="flex gap-1.5 flex-wrap">
<span className={clsx("badge", s.color)}>{s.label}</span>
</div>
</div> </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={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"> {viewMode === 'grid' && (
<Users className="w-3 h-3" /> <div className="w-full mb-3">
{match.status === 'SCHEDULED' <Link
? `${(match.attendances || []).filter((a: any) => a.status === 'CONFIRMED').length} confirmados` href={`/dashboard/matches/new?id=${match.id}`}
: `${(match.teams || []).reduce((acc: number, t: any) => acc + t.players.length, 0)} jogadores`} 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>
<div className="w-1 h-1 rounded-full bg-border" /> <div className="w-1 h-1 rounded-full bg-border" />
<div className="flex items-center gap-1"> <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 */} {/* Actions only in List Mode here, else in modal */}
{viewMode === 'list' && ( {viewMode === 'list' && (
<div className="ml-auto flex items-center gap-2 opacity-0 group-hover:opacity-100 transition-opacity"> <div className="ml-auto flex items-center gap-4 opacity-0 group-hover:opacity-100 transition-all duration-300">
{match.status === 'SCHEDULED' && ( <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 <button
onClick={(e) => { onClick={(e) => {
e.stopPropagation() e.stopPropagation()
copyMatchLink(match) 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" title="Copiar Link de Confirmação"
> >
<LinkIcon className="w-4 h-4" /> <LinkIcon className="w-4 h-4" />
</button> </button>
)} )}
<ChevronRight className="w-4 h-4" /> <ChevronRight className="w-4 h-4 text-muted/40" />
</div> </div>
)} )}
</div> </div>
@@ -478,6 +554,17 @@ export function MatchHistory({ matches, players = [], groupName = 'Pelada' }: {
</p> </p>
</div> </div>
<div className="flex items-center gap-2"> <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 <button
onClick={() => handleDeleteMatch(selectedMatch.id)} onClick={() => handleDeleteMatch(selectedMatch.id)}
className="p-2.5 text-muted hover:text-red-500 transition-colors rounded-lg" 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>
<div className="p-6 overflow-y-auto custom-scrollbar"> <div className="p-6 overflow-y-auto custom-scrollbar">
{selectedMatch.status === 'SCHEDULED' ? ( {selectedMatch.status === 'CONVOCACAO' ? (
<div className="space-y-6"> <div className="space-y-6">
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4"> <div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
<div className="ui-card p-4 bg-surface-raised/30"> <div className="ui-card p-4 bg-surface-raised/30">
@@ -631,6 +718,17 @@ export function MatchHistory({ matches, players = [], groupName = 'Pelada' }: {
</div> </div>
))} ))}
</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>
)} )}
</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' 'use client'
import React, { useState, useMemo } from 'react' 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 { 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 } from '@/actions/player' import { addPlayer, deletePlayer, deletePlayers, updatePlayer } from '@/actions/player'
import { motion, AnimatePresence } from 'framer-motion' import { motion, AnimatePresence } from 'framer-motion'
import { clsx } from 'clsx' import { clsx } from 'clsx'
import { DeleteConfirmationModal } from '@/components/DeleteConfirmationModal' 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 [activeTab, setActiveTab] = useState<'ALL' | 'DEF' | 'MEI' | 'ATA'>('ALL')
const [viewMode, setViewMode] = useState<'grid' | 'list'>('grid') const [viewMode, setViewMode] = useState<'grid' | 'list'>('grid')
const [error, setError] = useState<string | null>(null) const [error, setError] = useState<string | null>(null)
const [isAdding, setIsAdding] = useState(false) const [isSaving, setIsSaving] = useState(false)
const [isFormOpen, setIsFormOpen] = useState(false) const [isFormOpen, setIsFormOpen] = useState(false)
const [editingPlayer, setEditingPlayer] = useState<any | null>(null)
// Pagination & Selection // Pagination & Selection
const [currentPage, setCurrentPage] = useState(1) const [currentPage, setCurrentPage] = useState(1)
@@ -40,27 +41,53 @@ export function PlayersList({ group }: { group: any }) {
description: '' description: ''
}) })
const handleAddPlayer = async (e: React.FormEvent) => { const handleSavePlayer = async (e: React.FormEvent) => {
e.preventDefault() e.preventDefault()
setError(null) setError(null)
if (!newPlayerName) return if (!newPlayerName) return
setIsAdding(true) setIsSaving(true)
try { try {
const playerNumber = number.trim() === '' ? null : parseInt(number) const playerNumber = number.trim() === '' ? null : parseInt(number)
await addPlayer(group.id, newPlayerName, level, playerNumber, position)
setNewPlayerName('') if (editingPlayer) {
setLevel(3) await updatePlayer(editingPlayer.id, {
setNumber('') name: newPlayerName,
setPosition('MEI') level,
setIsFormOpen(false) number: playerNumber,
position
})
} else {
await addPlayer(group.id, newPlayerName, level, playerNumber, position)
}
closeForm()
} catch (err: any) { } catch (err: any) {
setError(err.message) setError(err.message)
} finally { } 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(() => { const filteredPlayers = useMemo(() => {
return group.players.filter((p: any) => { return group.players.filter((p: any) => {
const matchesSearch = p.name.toLowerCase().includes(searchQuery.toLowerCase()) || const matchesSearch = p.name.toLowerCase().includes(searchQuery.toLowerCase()) ||
@@ -174,7 +201,6 @@ export function PlayersList({ group }: { group: any }) {
> >
<button <button
onClick={() => { onClick={() => {
setError(null)
setIsFormOpen(true) setIsFormOpen(true)
}} }}
className="ui-button w-full sm:w-auto shadow-lg shadow-primary/20" 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 }} initial={{ opacity: 0 }}
animate={{ opacity: 1 }} animate={{ opacity: 1 }}
exit={{ opacity: 0 }} exit={{ opacity: 0 }}
onClick={() => setIsFormOpen(false)} onClick={closeForm}
className="absolute inset-0 bg-background/80 backdrop-blur-sm" className="absolute inset-0 bg-background/80 backdrop-blur-sm"
/> />
<motion.div <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="p-6 bg-surface-raised/50 border-b border-border flex items-center justify-between">
<div className="flex items-center gap-4"> <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"> <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>
<div> <div>
<h3 className="text-lg font-bold tracking-tight">Novo Atleta</h3> <h3 className="text-lg font-bold tracking-tight">
<p className="text-xs text-muted font-medium uppercase tracking-wider">Adicionar ao elenco</p> {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>
</div> </div>
<button <button
onClick={() => setIsFormOpen(false)} onClick={closeForm}
className="p-2 text-muted hover:text-foreground rounded-lg transition-colors" className="p-2 text-muted hover:text-foreground rounded-lg transition-colors"
> >
<X className="w-5 h-5" /> <X className="w-5 h-5" />
</button> </button>
</div> </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="space-y-4">
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<User className="w-4 h-4 text-primary" /> <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"> <div className="pt-4 flex gap-3">
<button <button
type="button" type="button"
onClick={() => setIsFormOpen(false)} onClick={closeForm}
className="ui-button-ghost flex-1 h-12" className="ui-button-ghost flex-1 h-12"
> >
Cancelar Cancelar
</button> </button>
<button <button
type="submit" type="submit"
disabled={isAdding || !newPlayerName} disabled={isSaving || !newPlayerName}
className="ui-button flex-[2] h-12 shadow-xl shadow-primary/10" 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> </button>
</div> </div>
</form> </form>
@@ -538,16 +568,28 @@ export function PlayersList({ group }: { group: any }) {
Status: {getLevelInfo(p.level).label} Status: {getLevelInfo(p.level).label}
</div> </div>
)} )}
<button <div className="flex items-center gap-1">
onClick={(e) => { <button
e.stopPropagation() onClick={(e) => {
handleSingleDelete(p.id, p.name) e.stopPropagation()
}} openEditForm(p)
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" 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"
<Trash2 className="w-4 h-4" /> >
</button> <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> </div>
</motion.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 { Upload, Save, Loader2, Image as ImageIcon, AlertCircle, CheckCircle } from 'lucide-react'
import { useRouter } from 'next/navigation' import { useRouter } from 'next/navigation'
import { motion, AnimatePresence } from 'framer-motion' import { motion, AnimatePresence } from 'framer-motion'
import { clsx } from 'clsx'
interface SettingsFormProps { interface SettingsFormProps {
initialData: { initialData: {
@@ -186,6 +187,7 @@ export function SettingsForm({ initialData }: SettingsFormProps) {
</div> </div>
</div> </div>
</div> </div>
</div> </div>
{/* Status Messages */} {/* Status Messages */}

View File

@@ -1,34 +1,38 @@
'use client' 'use client'
import { useState } from 'react' 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 { clsx } from 'clsx'
import { motion, AnimatePresence } from 'framer-motion' import { motion, AnimatePresence } from 'framer-motion'
interface SettingsTabsProps { interface SettingsTabsProps {
branding: React.ReactNode branding: React.ReactNode
teams: React.ReactNode
arenas: React.ReactNode arenas: React.ReactNode
sponsors: React.ReactNode sponsors: React.ReactNode
voting: React.ReactNode
} }
export function SettingsTabs({ branding, arenas, sponsors }: SettingsTabsProps) { export function SettingsTabs({ branding, teams, arenas, sponsors, voting }: SettingsTabsProps) {
const [activeTab, setActiveTab] = useState<'branding' | 'arenas' | 'sponsors'>('branding') const [activeTab, setActiveTab] = useState<'branding' | 'teams' | 'arenas' | 'sponsors' | 'voting'>('branding')
const tabs = [ const tabs = [
{ id: 'branding', label: 'Identidade Visual', icon: Palette }, { 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: 'arenas', label: 'Locais & Arenas', icon: MapPin },
{ id: 'sponsors', label: 'Patrocínios', icon: Briefcase }, { id: 'sponsors', label: 'Patrocínios', icon: Briefcase },
] as const ] as const
return ( return (
<div className="space-y-8"> <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) => ( {tabs.map((tab) => (
<button <button
key={tab.id} key={tab.id}
onClick={() => setActiveTab(tab.id)} onClick={() => setActiveTab(tab.id)}
className={clsx( 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 activeTab === tab.id
? "bg-primary text-background shadow-lg shadow-emerald-500/10" ? "bg-primary text-background shadow-lg shadow-emerald-500/10"
: "text-muted hover:text-foreground hover:bg-white/5" : "text-muted hover:text-foreground hover:bg-white/5"
@@ -53,6 +57,28 @@ export function SettingsTabs({ branding, arenas, sponsors }: SettingsTabsProps)
{branding} {branding}
</motion.div> </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' && ( {activeTab === 'arenas' && (
<motion.div <motion.div
key="arenas" key="arenas"

View File

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

View File

@@ -6,6 +6,7 @@
interface RenderData { interface RenderData {
groupName: string; groupName: string;
logoUrl?: string; logoUrl?: string;
shirtUrl?: string;
teamName: string; teamName: string;
teamColor: string; teamColor: string;
day: string; day: string;
@@ -82,6 +83,33 @@ const removeWhiteBackground = (img: HTMLImageElement): HTMLCanvasElement => {
return canvas; return canvas;
}; };
// --- COLOR CONTRAST UTILS ---
const getLuminance = (hex: string) => {
const r = parseInt(hex.slice(1, 3), 16) / 255;
const g = parseInt(hex.slice(3, 5), 16) / 255;
const b = parseInt(hex.slice(5, 7), 16) / 255;
const a = [r, g, b].map(v => v <= 0.03928 ? v / 12.92 : Math.pow((v + 0.055) / 1.055, 2.4));
return a[0] * 0.2126 + a[1] * 0.7152 + a[2] * 0.0722;
};
const getAdaptiveColor = (teamColor: string) => {
const luminance = getLuminance(teamColor);
// If team color is too LIGHT (> 0.6 luminance) on a white background, DARKEN it
if (luminance > 0.6) {
let r = parseInt(teamColor.slice(1, 3), 16);
let g = parseInt(teamColor.slice(3, 5), 16);
let b = parseInt(teamColor.slice(5, 7), 16);
// Darken the color significantly to be legible on white
r = Math.round(r * 0.7);
g = Math.round(g * 0.7);
b = Math.round(b * 0.7);
return `rgb(${r}, ${g}, ${b})`;
}
return teamColor;
};
// Global control for concurrency // Global control for concurrency
let lastRenderTimestamp: Record<string, number> = {}; let lastRenderTimestamp: Record<string, number> = {};
@@ -89,6 +117,13 @@ export async function renderMatchCard(canvas: HTMLCanvasElement, data: RenderDat
const ctx = canvas.getContext('2d'); const ctx = canvas.getContext('2d');
if (!ctx) return; if (!ctx) return;
// WAIT FOR FONTS TO BE READY - Essential for accurate measureText and clean first-time renders
try {
await (document as any).fonts.ready;
} catch (e) {
console.warn('Fonts not ready or API not supported');
}
const canvasId = canvas.id || 'default-canvas'; const canvasId = canvas.id || 'default-canvas';
const currentRenderTime = Date.now(); const currentRenderTime = Date.now();
lastRenderTimestamp[canvasId] = currentRenderTime; lastRenderTimestamp[canvasId] = currentRenderTime;
@@ -98,10 +133,26 @@ export async function renderMatchCard(canvas: HTMLCanvasElement, data: RenderDat
canvas.width = W; canvas.width = W;
canvas.height = H; canvas.height = H;
// --- 1. CLEAN DARK BACKGROUND --- // --- 1. PREMIUM WHITE THEME DEFAULT ---
ctx.fillStyle = '#050505'; // User requested White as the default for everything.
// Explicitly reset properties that might leak from previous renders
ctx.shadowBlur = 0;
ctx.shadowColor = 'transparent';
ctx.shadowOffsetX = 0;
ctx.shadowOffsetY = 0;
(ctx as any).letterSpacing = '0px';
ctx.globalAlpha = 1.0;
ctx.fillStyle = '#f5f5f5';
ctx.fillRect(0, 0, W, H); ctx.fillRect(0, 0, W, H);
const baseTextColor = '#050505';
const mutedTextColor = 'rgba(0,0,0,0.5)';
const beamColor = 'rgba(0,0,0,0.04)';
const accentLineColor = 'rgba(0,0,0,0.1)';
const isWhiteCard = true; // Hardcoded default now
// --- 2. ASYNC ASSETS LOADING --- // --- 2. ASYNC ASSETS LOADING ---
let logoImg: HTMLImageElement | null = null; let logoImg: HTMLImageElement | null = null;
let cleanedLogo: HTMLCanvasElement | HTMLImageElement | null = null; let cleanedLogo: HTMLCanvasElement | HTMLImageElement | null = null;
@@ -115,8 +166,19 @@ export async function renderMatchCard(canvas: HTMLCanvasElement, data: RenderDat
if (lastRenderTimestamp[canvasId] !== currentRenderTime) return; if (lastRenderTimestamp[canvasId] !== currentRenderTime) return;
// --- JERSEY BACKGROUND ELEMENT (Using cleaned logo) --- // --- JERSEY BACKGROUND ELEMENT ---
drawJersey(ctx, W - 450, H * 0.4, 800, data.teamColor, cleanedLogo); let shirtImg: HTMLImageElement | null = null;
try {
if (data.shirtUrl) {
shirtImg = await loadImg(data.shirtUrl);
}
} catch (e) { }
if (shirtImg) {
drawJerseyWithImage(ctx, W * 0.5, H * 0.5, 1200, data.teamColor, shirtImg);
} else {
drawJersey(ctx, W * 0.5, H * 0.5, 1200, data.teamColor, cleanedLogo);
}
// --- 3. THE HEADER (STABLE) --- // --- 3. THE HEADER (STABLE) ---
const margin = 80; const margin = 80;
@@ -138,20 +200,41 @@ export async function renderMatchCard(canvas: HTMLCanvasElement, data: RenderDat
ctx.textAlign = 'left'; ctx.textAlign = 'left';
ctx.textBaseline = 'top'; ctx.textBaseline = 'top';
ctx.fillStyle = data.teamColor; const teamLum = getLuminance(data.teamColor);
ctx.font = 'italic 900 80px Inter, sans-serif'; const adaptiveColor = getAdaptiveColor(data.teamColor);
ctx.fillText(data.teamName.toUpperCase(), textX, headerY + 10);
ctx.fillStyle = '#ffffff'; // DYNAMIC FONT SIZE FOR TEAM NAME
ctx.save();
ctx.fillStyle = adaptiveColor;
// SHADOW TREATMENT (CLEANER LOOK)
ctx.shadowColor = 'rgba(0,0,0,0.05)';
ctx.shadowBlur = 4;
ctx.shadowOffsetY = 2;
const maxTeamNameWidth = W - textX - margin;
let teamNameFontSize = 80;
ctx.font = `italic 900 ${teamNameFontSize}px Inter, sans-serif`;
while (ctx.measureText(data.teamName.toUpperCase()).width > maxTeamNameWidth && teamNameFontSize > 40) {
teamNameFontSize -= 2;
ctx.font = `italic 900 ${teamNameFontSize}px Inter, sans-serif`;
}
ctx.fillText(data.teamName.toUpperCase(), textX, headerY + 10);
ctx.restore();
ctx.fillStyle = baseTextColor;
ctx.font = '900 32px Inter, sans-serif'; ctx.font = '900 32px Inter, sans-serif';
ctx.letterSpacing = '8px'; (ctx as any).letterSpacing = '8px';
ctx.fillText(data.groupName.toUpperCase(), textX, headerY + 95); ctx.fillText(data.groupName.toUpperCase(), textX, headerY + 95);
ctx.fillStyle = 'rgba(255,255,255,0.4)'; ctx.fillStyle = mutedTextColor;
ctx.font = '900 24px Inter, sans-serif'; ctx.font = '900 24px Inter, sans-serif';
ctx.letterSpacing = '5px'; (ctx as any).letterSpacing = '5px';
ctx.fillText(`${data.day} ${data.month.toUpperCase()}${data.time}${data.location.toUpperCase()}`, textX, headerY + 145); ctx.fillText(`${data.day} ${data.month.toUpperCase()}${data.time}${data.location.toUpperCase()}`, textX, headerY + 145);
// RESET letterSpacing after use
(ctx as any).letterSpacing = '0px';
ctx.textBaseline = 'alphabetic'; ctx.textBaseline = 'alphabetic';
// --- 4. THE LINEUP (ELITE INLINE) --- // --- 4. THE LINEUP (ELITE INLINE) ---
@@ -161,30 +244,47 @@ export async function renderMatchCard(canvas: HTMLCanvasElement, data: RenderDat
ctx.textBaseline = 'middle'; ctx.textBaseline = 'middle';
// Thin accent line // Thin accent line
ctx.strokeStyle = 'rgba(255,255,255,0.15)'; ctx.strokeStyle = accentLineColor;
ctx.lineWidth = 1; ctx.lineWidth = 1;
ctx.beginPath(); ctx.beginPath();
ctx.moveTo(margin, 385); ctx.moveTo(margin, 385);
ctx.lineTo(margin + 120, 385); ctx.lineTo(margin + 120, 385);
ctx.stroke(); ctx.stroke();
ctx.fillStyle = '#fff'; ctx.fillStyle = baseTextColor;
ctx.font = 'italic 900 42px Inter, sans-serif'; ctx.font = 'italic 900 42px Inter, sans-serif';
ctx.letterSpacing = '18px'; ctx.letterSpacing = '18px';
ctx.fillText('ESCALAÇÃO', margin + 180, 385); ctx.fillText('ESCALAÇÃO', margin + 180, 385);
// Team color accent dot // Team color accent dot
ctx.fillStyle = data.teamColor; ctx.fillStyle = adaptiveColor;
ctx.beginPath(); ctx.arc(margin + 155, 385, 6, 0, Math.PI * 2); ctx.fill(); ctx.beginPath(); ctx.arc(margin + 155, 385, 6, 0, Math.PI * 2); ctx.fill();
ctx.restore(); ctx.restore();
let listY = 460; let listY = 460;
const itemH = 130; const footerTop = H - 460; // Increased gap significantly for "respiro"
const spacing = 32; const availableSpace = footerTop - listY;
// Dynamic height calculation
const playerCount = data.players.length;
let itemH = 130;
let spacing = 32;
// Calculate total height needed with current settings
const totalExpectedH = playerCount * (itemH + spacing) - spacing;
// If total height exceeds available space, shrink items and spacing
if (totalExpectedH > availableSpace) {
const ratio = availableSpace / totalExpectedH;
itemH = Math.max(75, Math.floor(itemH * ratio));
spacing = Math.max(8, Math.floor(spacing * ratio));
}
const rowSlant = 42; const rowSlant = 42;
data.players.forEach((p, i) => { data.players.forEach((p, i) => {
if (listY > H - 350) return; // Skip rendering if we run out of space (absolute safety)
if (listY + itemH > footerTop + 60) return;
ctx.save(); ctx.save();
ctx.translate(margin, listY); ctx.translate(margin, listY);
@@ -194,7 +294,7 @@ export async function renderMatchCard(canvas: HTMLCanvasElement, data: RenderDat
ctx.beginPath(); ctx.beginPath();
ctx.moveTo(0, 0); ctx.lineTo(cardW - rowSlant, 0); ctx.moveTo(0, 0); ctx.lineTo(cardW - rowSlant, 0);
ctx.lineTo(cardW, itemH); ctx.lineTo(rowSlant, itemH); ctx.closePath(); ctx.lineTo(cardW, itemH); ctx.lineTo(rowSlant, itemH); ctx.closePath();
ctx.fillStyle = 'rgba(255, 255, 255, 0.035)'; ctx.fillStyle = beamColor;
ctx.fill(); ctx.fill();
// B. Slanted Color Accent // B. Slanted Color Accent
@@ -205,7 +305,7 @@ export async function renderMatchCard(canvas: HTMLCanvasElement, data: RenderDat
ctx.lineTo(cardW, itemH); ctx.lineTo(cardW, itemH);
ctx.lineTo(cardW - accentWidth, itemH); ctx.lineTo(cardW - accentWidth, itemH);
ctx.closePath(); ctx.closePath();
ctx.fillStyle = data.teamColor; ctx.fillStyle = adaptiveColor;
ctx.fill(); ctx.fill();
// --- INLINE FLOW --- // --- INLINE FLOW ---
@@ -214,31 +314,38 @@ export async function renderMatchCard(canvas: HTMLCanvasElement, data: RenderDat
// 1. Number (Sleek) // 1. Number (Sleek)
if (data.options.showNumbers) { if (data.options.showNumbers) {
ctx.save();
ctx.textAlign = 'left'; ctx.textAlign = 'left';
ctx.fillStyle = data.teamColor; ctx.fillStyle = adaptiveColor;
ctx.font = 'italic 900 64px Inter, sans-serif';
const numFontSize = Math.max(40, Math.floor(64 * (itemH / 130)));
ctx.font = `italic 900 ${numFontSize}px Inter, sans-serif`;
const n = String(p.number || (i + 1)).padStart(2, '0'); const n = String(p.number || (i + 1)).padStart(2, '0');
ctx.fillText(n, flowX, itemH / 2); ctx.fillText(n, flowX, itemH / 2);
flowX += 130; ctx.restore();
flowX += Math.max(90, Math.floor(130 * (itemH / 130)));
} }
// 2. Name // 2. Name
const pName = formatName(p.name).toUpperCase(); const pName = formatName(p.name).toUpperCase();
ctx.textAlign = 'left'; ctx.textAlign = 'left';
ctx.fillStyle = '#fff'; ctx.fillStyle = baseTextColor;
ctx.font = 'italic 900 50px Inter, sans-serif'; const nameFontSize = Math.max(30, Math.floor(50 * (itemH / 130)));
ctx.font = `italic 900 ${nameFontSize}px Inter, sans-serif`;
ctx.fillText(pName, flowX, itemH / 2); ctx.fillText(pName, flowX, itemH / 2);
const nW = ctx.measureText(pName).width; const nW = ctx.measureText(pName).width;
flowX += nW + 50; flowX += nW + 40;
// 3. Level Stars (Actual Stars) // 3. Level Stars (Actual Stars)
if (data.options.showStars) { if (data.options.showStars) {
const starSize = Math.max(8, Math.floor(12 * (itemH / 130)));
const starSpacing = Math.max(25, Math.floor(40 * (itemH / 130)));
for (let s = 0; s < 5; s++) { for (let s = 0; s < 5; s++) {
ctx.fillStyle = s < p.level ? data.teamColor : 'rgba(255,255,255,0.06)'; ctx.fillStyle = s < p.level ? adaptiveColor : (isWhiteCard ? 'rgba(0,0,0,0.06)' : 'rgba(255,255,255,0.06)');
drawStar(ctx, flowX + (s * 40), itemH / 2, 12, 5, 5); drawStar(ctx, flowX + (s * starSpacing), itemH / 2, starSize, 5, starSize * 0.4);
ctx.fill(); ctx.fill();
} }
flowX += 200; flowX += (starSpacing * 5);
} }
// 4. Position Tag (Glass Tag) // 4. Position Tag (Glass Tag)
@@ -247,9 +354,10 @@ export async function renderMatchCard(canvas: HTMLCanvasElement, data: RenderDat
const posC = getPosColor(p.position, 0.2); const posC = getPosColor(p.position, 0.2);
const borC = getPosColor(p.position, 0.8); const borC = getPosColor(p.position, 0.8);
ctx.font = '900 20px Inter, sans-serif'; const labelFontSize = Math.max(14, Math.floor(20 * (itemH / 130)));
ctx.font = `900 ${labelFontSize}px Inter, sans-serif`;
const tw = ctx.measureText(posT).width + 30; const tw = ctx.measureText(posT).width + 30;
const th = 38; const th = Math.floor(38 * (itemH / 130));
ctx.fillStyle = posC; ctx.fillStyle = posC;
drawRoundRect(ctx, flowX, (itemH / 2) - (th / 2), tw, th, 8); drawRoundRect(ctx, flowX, (itemH / 2) - (th / 2), tw, th, 8);
@@ -258,7 +366,7 @@ export async function renderMatchCard(canvas: HTMLCanvasElement, data: RenderDat
ctx.lineWidth = 1; ctx.lineWidth = 1;
ctx.stroke(); ctx.stroke();
ctx.fillStyle = '#fff'; ctx.fillStyle = isWhiteCard ? '#000' : '#fff';
ctx.textAlign = 'center'; ctx.textAlign = 'center';
ctx.fillText(posT, flowX + (tw / 2), itemH / 2 + 1); ctx.fillText(posT, flowX + (tw / 2), itemH / 2 + 1);
} }
@@ -269,12 +377,13 @@ export async function renderMatchCard(canvas: HTMLCanvasElement, data: RenderDat
// --- 5. FOOTER (SPONSORS) --- // --- 5. FOOTER (SPONSORS) ---
if (data.options.showSponsors && data.sponsors && data.sponsors.length > 0) { if (data.options.showSponsors && data.sponsors && data.sponsors.length > 0) {
let footerY = H - 320; let footerY = H - 280; // Moved slightly lower to give more room above
ctx.textAlign = 'center'; ctx.textAlign = 'center';
ctx.fillStyle = 'rgba(255,255,255,0.2)'; ctx.fillStyle = mutedTextColor;
ctx.font = '900 24px Inter, sans-serif'; ctx.font = '900 24px Inter, sans-serif';
ctx.letterSpacing = '12px'; (ctx as any).letterSpacing = '12px';
ctx.fillText('PATROCINADORES', W / 2, footerY); ctx.fillText('PATROCINADORES', W / 2, footerY);
(ctx as any).letterSpacing = '0px';
footerY += 80; footerY += 80;
const spW = 320; const spW = 320;
@@ -339,36 +448,58 @@ function drawJersey(ctx: CanvasRenderingContext2D, x: number, y: number, w: numb
ctx.beginPath(); ctx.beginPath();
ctx.strokeStyle = color; ctx.strokeStyle = color;
ctx.lineWidth = 14; ctx.lineWidth = 14;
ctx.globalAlpha = 0.08; ctx.globalAlpha = 0.08; // Increased just a bit for visibility
// LONG SLEEVE JERSEY PATH (Pro Fit) // CENTERED LARGE JERSEY - Adjusting coordinates to center around (0,0)
ctx.moveTo(w * 0.25, 0); ctx.setLineDash([20, 10]);
ctx.lineTo(w * 0.75, 0);
ctx.lineTo(w * 1.3, w * 0.4); // Extended sleeve top right const offsetW = w * 0.5;
ctx.lineTo(w * 1.1, w * 0.65); // Extended sleeve bottom right const offsetH = h * 0.4;
ctx.lineTo(w * 0.85, w * 0.45); // Armpit right
ctx.lineTo(w * 0.85, h); // LONG SLEEVE JERSEY PATH (Centered)
ctx.lineTo(w * 0.15, h); ctx.moveTo(w * 0.25 - offsetW, 0 - offsetH);
ctx.lineTo(w * 0.15, w * 0.45); ctx.lineTo(w * 0.75 - offsetW, 0 - offsetH);
ctx.lineTo(-w * 0.1, w * 0.65); // Extended sleeve bottom left ctx.lineTo(w * 1.3 - offsetW, w * 0.4 - offsetH);
ctx.lineTo(-w * 0.3, w * 0.4); // Extended sleeve top left ctx.lineTo(w * 1.1 - offsetW, w * 0.65 - offsetH);
ctx.lineTo(w * 0.25, 0); ctx.lineTo(w * 0.85 - offsetW, w * 0.45 - offsetH);
ctx.lineTo(w * 0.85 - offsetW, h - offsetH);
ctx.lineTo(w * 0.15 - offsetW, h - offsetH);
ctx.lineTo(w * 0.15 - offsetW, w * 0.45 - offsetH);
ctx.lineTo(-w * 0.1 - offsetW, w * 0.65 - offsetH);
ctx.lineTo(-w * 0.3 - offsetW, w * 0.4 - offsetH);
ctx.lineTo(w * 0.25 - offsetW, 0 - offsetH);
ctx.stroke(); ctx.stroke();
// Neck detail // Neck detail (Centered)
ctx.beginPath(); ctx.beginPath();
ctx.arc(w * 0.5, 0, w * 0.18, 0, Math.PI); ctx.arc(w * 0.5 - offsetW, 0 - offsetH, w * 0.18, 0, Math.PI);
ctx.stroke(); ctx.stroke();
// THE LOGO ON THE JERSEY (Refined Watermark) // THE LOGO ON THE JERSEY (Refined Watermark)
if (logoImg) { if (logoImg) {
ctx.save(); ctx.save();
ctx.globalAlpha = 0.04; ctx.globalAlpha = 0.07;
const iconSize = w * 0.35; const iconSize = w * 0.4;
ctx.drawImage(logoImg, w * 0.5 - iconSize / 2, w * 0.25, iconSize, iconSize); ctx.drawImage(logoImg, w * 0.5 - offsetW - iconSize / 2, w * 0.3 - offsetH, iconSize, iconSize);
ctx.restore(); ctx.restore();
} }
ctx.restore(); ctx.restore();
} }
function drawJerseyWithImage(ctx: CanvasRenderingContext2D, x: number, y: number, w: number, color: string, shirtImg: HTMLImageElement) {
const h = w * 1.5;
ctx.save();
ctx.translate(x, y);
ctx.rotate(15 * Math.PI / 180);
// ULTRA LARGE TRANSLUCENT JERSEY
ctx.globalAlpha = 0.09;
// Draw the shirt image centered
const iconSize = w * 1.25; // Even larger
ctx.drawImage(shirtImg, -iconSize / 2, -iconSize * 0.3, iconSize, iconSize);
ctx.restore();
}