feat: adiciona horario e icone de relogio na pagina de confirmacao
This commit is contained in:
5
.agent/rules/regras.md
Normal file
5
.agent/rules/regras.md
Normal file
@@ -0,0 +1,5 @@
|
||||
---
|
||||
trigger: always_on
|
||||
---
|
||||
|
||||
suba o container apos uma atualizacao
|
||||
2
next-env.d.ts
vendored
2
next-env.d.ts
vendored
@@ -1,6 +1,6 @@
|
||||
/// <reference types="next" />
|
||||
/// <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
|
||||
// see https://nextjs.org/docs/app/api-reference/config/typescript for more information.
|
||||
|
||||
11
package-lock.json
generated
11
package-lock.json
generated
@@ -16,6 +16,7 @@
|
||||
"bcryptjs": "^3.0.3",
|
||||
"clsx": "^2.1.1",
|
||||
"cookies-next": "^6.1.1",
|
||||
"date-fns": "^4.1.0",
|
||||
"framer-motion": "^12.26.2",
|
||||
"lucide-react": "^0.562.0",
|
||||
"minio": "^8.0.6",
|
||||
@@ -3194,6 +3195,16 @@
|
||||
"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": {
|
||||
"version": "4.4.3",
|
||||
"resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz",
|
||||
|
||||
@@ -17,6 +17,7 @@
|
||||
"bcryptjs": "^3.0.3",
|
||||
"clsx": "^2.1.1",
|
||||
"cookies-next": "^6.1.1",
|
||||
"date-fns": "^4.1.0",
|
||||
"framer-motion": "^12.26.2",
|
||||
"lucide-react": "^0.562.0",
|
||||
"minio": "^8.0.6",
|
||||
|
||||
@@ -41,10 +41,13 @@ model Group {
|
||||
sponsors Sponsor[]
|
||||
arenas Arena[]
|
||||
financialEvents FinancialEvent[]
|
||||
transactions Transaction[]
|
||||
pixKey String?
|
||||
pixName String?
|
||||
status GroupStatus @default(ACTIVE)
|
||||
showTotalInPublic Boolean @default(true)
|
||||
votingEnabled Boolean @default(true)
|
||||
teamConfigs TeamConfig[]
|
||||
}
|
||||
|
||||
enum GroupStatus {
|
||||
@@ -71,6 +74,9 @@ model Player {
|
||||
teams TeamPlayer[]
|
||||
attendances Attendance[]
|
||||
payments Payment[]
|
||||
reviews Review[]
|
||||
matchEvents MatchEvent[]
|
||||
transactions Transaction[]
|
||||
|
||||
@@unique([number, groupId])
|
||||
}
|
||||
@@ -95,7 +101,7 @@ model Match {
|
||||
arena Arena? @relation(fields: [arenaId], references: [id])
|
||||
maxPlayers Int?
|
||||
drawSeed String?
|
||||
status MatchStatus @default(SCHEDULED)
|
||||
status MatchStatus @default(CONVOCACAO)
|
||||
groupId String
|
||||
createdAt DateTime @default(now())
|
||||
|
||||
@@ -105,15 +111,36 @@ model Match {
|
||||
isRecurring Boolean @default(false)
|
||||
recurrenceInterval String? // 'WEEKLY'
|
||||
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 {
|
||||
id String @id @default(cuid())
|
||||
name String
|
||||
color String
|
||||
shirtUrl String?
|
||||
matchId String
|
||||
match Match @relation(fields: [matchId], references: [id], onDelete: Cascade)
|
||||
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 {
|
||||
@@ -126,8 +153,11 @@ model TeamPlayer {
|
||||
|
||||
enum MatchStatus {
|
||||
SCHEDULED
|
||||
CONVOCACAO
|
||||
SORTEIO
|
||||
LIVE
|
||||
IN_PROGRESS
|
||||
COMPLETED
|
||||
ENCERRAMENTO
|
||||
CANCELED
|
||||
}
|
||||
|
||||
@@ -207,3 +237,49 @@ enum PaymentStatus {
|
||||
PAID
|
||||
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
|
||||
}
|
||||
|
||||
@@ -65,3 +65,25 @@ export async function deleteArena(id: string) {
|
||||
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' }
|
||||
}
|
||||
}
|
||||
|
||||
@@ -260,3 +260,67 @@ export async function toggleEventPrivacy(eventId: string, showTotal: boolean) {
|
||||
revalidatePath('/dashboard/financial')
|
||||
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' }
|
||||
}
|
||||
}
|
||||
|
||||
@@ -9,17 +9,27 @@ export async function createMatch(
|
||||
groupId: string,
|
||||
date: string,
|
||||
teamsData: any[],
|
||||
status: MatchStatus = 'IN_PROGRESS',
|
||||
status: MatchStatus = 'SORTEIO' as any, // Default for immediate matches
|
||||
location?: string,
|
||||
maxPlayers?: number,
|
||||
drawSeed?: string,
|
||||
arenaId?: string
|
||||
arenaId?: string,
|
||||
enableVoting: boolean = true,
|
||||
votingDuration: number = 72,
|
||||
gamificationType: string = 'PADRAO',
|
||||
duration: number = 60
|
||||
) {
|
||||
const match = await prisma.match.create({
|
||||
data: {
|
||||
date: new Date(date),
|
||||
groupId: groupId,
|
||||
status: status,
|
||||
status: status as any,
|
||||
// @ts-ignore
|
||||
enableVoting: enableVoting as any,
|
||||
// @ts-ignore
|
||||
votingDuration: votingDuration,
|
||||
// @ts-ignore
|
||||
gamificationType: gamificationType,
|
||||
// @ts-ignore
|
||||
location: location,
|
||||
arenaId: arenaId || null,
|
||||
@@ -27,10 +37,13 @@ export async function createMatch(
|
||||
maxPlayers: maxPlayers,
|
||||
// @ts-ignore
|
||||
drawSeed: drawSeed,
|
||||
// @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
|
||||
@@ -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,
|
||||
// @ts-ignore
|
||||
maxPlayers,
|
||||
status: 'SCHEDULED' as MatchStatus,
|
||||
status: 'CONVOCACAO' as any,
|
||||
// @ts-ignore
|
||||
isRecurring,
|
||||
// @ts-ignore
|
||||
@@ -139,17 +226,21 @@ export async function createScheduledMatch(
|
||||
}
|
||||
|
||||
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 },
|
||||
data: { status }
|
||||
data
|
||||
})
|
||||
|
||||
// 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 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) {
|
||||
if ((status as any) === 'ENCERRAMENTO' && (match as any).isRecurring) {
|
||||
const nextDate = new Date(match.date)
|
||||
// @ts-ignore
|
||||
const interval = match.recurrenceInterval
|
||||
@@ -176,7 +267,7 @@ export async function updateMatchStatus(matchId: string, status: MatchStatus) {
|
||||
const existingNextMatch = await prisma.match.findFirst({
|
||||
where: {
|
||||
groupId: match.groupId,
|
||||
status: 'SCHEDULED',
|
||||
status: 'CONVOCACAO' as any,
|
||||
date: {
|
||||
gte: 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,
|
||||
arenaId: match.arenaId,
|
||||
maxPlayers: match.maxPlayers,
|
||||
status: 'SCHEDULED',
|
||||
status: 'CONVOCACAO' as any,
|
||||
// @ts-ignore
|
||||
isRecurring: true,
|
||||
// @ts-ignore
|
||||
@@ -262,10 +353,10 @@ export async function getPublicScheduledMatches(slug: string) {
|
||||
if (!group) return { group: null, matches: [] }
|
||||
|
||||
// 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: {
|
||||
groupId: group.id,
|
||||
status: 'SCHEDULED',
|
||||
status: 'CONVOCACAO' as any,
|
||||
date: {
|
||||
gte: new Date(new Date().setHours(0, 0, 0, 0))
|
||||
}
|
||||
@@ -288,3 +379,156 @@ export async function getPublicScheduledMatches(slug: string) {
|
||||
|
||||
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
|
||||
}
|
||||
}
|
||||
|
||||
@@ -39,3 +39,21 @@ export async function deleteSponsor(id: string) {
|
||||
})
|
||||
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
|
||||
}
|
||||
|
||||
65
src/actions/team-config.ts
Normal file
65
src/actions/team-config.ts
Normal 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')
|
||||
}
|
||||
@@ -1,14 +1,14 @@
|
||||
import { getActiveGroup } from '@/lib/auth'
|
||||
import { getFinancialEvents } from '@/actions/finance'
|
||||
import { getFinancialEvents, getTransactions } from '@/actions/finance'
|
||||
import { FinancialDashboard } from '@/components/FinancialDashboard'
|
||||
|
||||
export default async function FinancialPage() {
|
||||
const group = await getActiveGroup()
|
||||
if (!group) return null
|
||||
|
||||
// We fetch events and players
|
||||
// getActiveGroup already includes players, so we can use that list for selection
|
||||
// We fetch events, transactions and players
|
||||
const events = await getFinancialEvents()
|
||||
const transactions = await getTransactions()
|
||||
|
||||
return (
|
||||
<div className="max-w-5xl mx-auto space-y-8 pb-12">
|
||||
@@ -19,7 +19,12 @@ export default async function FinancialPage() {
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<FinancialDashboard events={events} players={group.players} group={group} />
|
||||
<FinancialDashboard
|
||||
events={events}
|
||||
transactions={transactions}
|
||||
players={group.players}
|
||||
group={group}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -4,11 +4,13 @@ import Link from 'next/link'
|
||||
import { ChevronLeft } from 'lucide-react'
|
||||
import { getArenas } from '@/actions/arena'
|
||||
import { getSponsors } from '@/actions/sponsor'
|
||||
import { getTeamConfigs } from '@/actions/team-config'
|
||||
|
||||
export default async function NewMatchPage() {
|
||||
const group = await getActiveGroup()
|
||||
const arenas = await getArenas()
|
||||
const sponsors = await getSponsors(group?.id || '')
|
||||
const teamConfigs = await getTeamConfigs(group?.id || '')
|
||||
|
||||
return (
|
||||
<div className="space-y-8">
|
||||
@@ -25,7 +27,7 @@ export default async function NewMatchPage() {
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<MatchFlow group={group} arenas={arenas} sponsors={sponsors} />
|
||||
<MatchFlow group={group} arenas={arenas} sponsors={sponsors} teamConfigs={teamConfigs} />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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 type { Arena } from '@prisma/client'
|
||||
import { DateTimePicker } from '@/components/DateTimePicker'
|
||||
import { motion, AnimatePresence } from 'framer-motion'
|
||||
import { clsx } from 'clsx'
|
||||
import { MatchScheduler } from '@/components/MatchScheduler'
|
||||
import Link from 'next/link'
|
||||
import { ChevronLeft } from 'lucide-react'
|
||||
|
||||
export default function ScheduleMatchPage() {
|
||||
const router = useRouter()
|
||||
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
|
||||
export default async function SchedulePage() {
|
||||
const arenas = await getArenas()
|
||||
|
||||
return (
|
||||
<div className="max-w-3xl mx-auto pb-20">
|
||||
<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" />
|
||||
Novo Agendamento
|
||||
</div>
|
||||
<h1 className="text-4xl font-black tracking-tighter uppercase leading-none">
|
||||
Agendar <span className="text-primary text-outline-sm">Evento</span>
|
||||
</h1>
|
||||
<p className="text-muted text-sm font-medium">Crie um link de confirmação e organize sua pelada.</p>
|
||||
</div>
|
||||
<div className="max-w-5xl mx-auto space-y-8">
|
||||
<header className="flex items-center gap-4">
|
||||
<Link
|
||||
href="/dashboard/matches"
|
||||
className="p-2 hover:bg-surface-raised rounded-lg transition-all border border-border text-muted hover:text-foreground"
|
||||
>
|
||||
<ChevronLeft className="w-5 h-5" />
|
||||
</Link>
|
||||
<span className="text-xs font-bold uppercase tracking-widest text-muted">Voltar para Partidas</span>
|
||||
</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 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>
|
||||
<MatchScheduler arenas={arenas} />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -70,7 +70,7 @@ export default async function DashboardPage() {
|
||||
<div>
|
||||
<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">
|
||||
{match.status === 'SCHEDULED'
|
||||
{match.status === 'CONVOCACAO'
|
||||
? `${match.attendances.filter((a: any) => a.status === 'CONFIRMED').length} confirmados`
|
||||
: `${match.teams.length} times sorteados`}
|
||||
</p>
|
||||
|
||||
@@ -2,10 +2,14 @@ import { getActiveGroup } from '@/lib/auth'
|
||||
import { SettingsForm } from '@/components/SettingsForm'
|
||||
import { getArenas } from '@/actions/arena'
|
||||
import { getSponsors } from '@/actions/sponsor'
|
||||
import { getTeamConfigs } from '@/actions/team-config'
|
||||
import { ArenasManager } from '@/components/ArenasManager'
|
||||
import { SponsorsManager } from '@/components/SponsorsManager'
|
||||
import { TeamsManager } from '@/components/TeamsManager'
|
||||
import { SettingsTabs } from '@/components/SettingsTabs'
|
||||
|
||||
import { VotingSettings } from '@/components/VotingSettings'
|
||||
|
||||
export default async function SettingsPage() {
|
||||
const group = await getActiveGroup()
|
||||
|
||||
@@ -13,6 +17,7 @@ export default async function SettingsPage() {
|
||||
|
||||
const arenas = await getArenas()
|
||||
const sponsors = await getSponsors(group.id)
|
||||
const teams = await getTeamConfigs(group.id)
|
||||
|
||||
return (
|
||||
<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} />}
|
||||
sponsors={<SponsorsManager groupId={group.id} sponsors={sponsors} />}
|
||||
/>
|
||||
|
||||
@@ -13,19 +13,19 @@ export async function updateGroupSettings(formData: FormData) {
|
||||
const group = await getActiveGroup()
|
||||
if (!group) throw new Error('Unauthorized')
|
||||
|
||||
const name = formData.get('name') as string
|
||||
// Slug is immutable intentionally
|
||||
const primaryColor = formData.get('primaryColor') as string
|
||||
const secondaryColor = formData.get('secondaryColor') as string
|
||||
const pixKey = formData.get('pixKey') as string
|
||||
const pixName = formData.get('pixName') as string
|
||||
const dataToUpdate: any = {}
|
||||
|
||||
if (formData.has('name')) dataToUpdate.name = formData.get('name') as string
|
||||
if (formData.has('primaryColor')) dataToUpdate.primaryColor = formData.get('primaryColor') as string
|
||||
if (formData.has('secondaryColor')) dataToUpdate.secondaryColor = formData.get('secondaryColor') 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
|
||||
|
||||
let logoUrl = group.logoUrl
|
||||
|
||||
if (logoFile && logoFile.size > 0 && logoFile.name !== 'undefined') {
|
||||
try {
|
||||
logoUrl = await uploadFile(logoFile)
|
||||
dataToUpdate.logoUrl = await uploadFile(logoFile)
|
||||
} catch (error) {
|
||||
console.error("Upload failed", error)
|
||||
}
|
||||
@@ -34,15 +34,7 @@ export async function updateGroupSettings(formData: FormData) {
|
||||
try {
|
||||
await prisma.group.update({
|
||||
where: { id: group.id },
|
||||
data: {
|
||||
name,
|
||||
// Slug NOT updated
|
||||
primaryColor,
|
||||
secondaryColor,
|
||||
pixKey,
|
||||
pixName,
|
||||
logoUrl,
|
||||
},
|
||||
data: dataToUpdate,
|
||||
})
|
||||
revalidatePath('/', 'layout')
|
||||
return { success: true, slug: group.slug }
|
||||
|
||||
@@ -19,7 +19,7 @@ async function isAdmin() {
|
||||
|
||||
export async function DELETE(
|
||||
request: Request,
|
||||
{ params }: { params: { id: string } }
|
||||
{ params }: { params: Promise<{ id: string }> }
|
||||
) {
|
||||
if (!(await isAdmin())) {
|
||||
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
|
||||
@@ -78,7 +78,7 @@ export async function DELETE(
|
||||
|
||||
export async function PATCH(
|
||||
request: Request,
|
||||
{ params }: { params: { id: string } }
|
||||
{ params }: { params: Promise<{ id: string }> }
|
||||
) {
|
||||
if (!(await isAdmin())) {
|
||||
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
|
||||
|
||||
@@ -11,6 +11,7 @@
|
||||
|
||||
--color-primary: var(--primary-color);
|
||||
--color-secondary: var(--secondary-color);
|
||||
--color-secondary-foreground: color-mix(in srgb, var(--secondary-color), white 80%);
|
||||
/* Emerald 500 */
|
||||
--color-primary-soft: color-mix(in srgb, var(--primary-color), transparent 90%);
|
||||
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
'use client'
|
||||
|
||||
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 { motion, AnimatePresence } from 'framer-motion'
|
||||
import { clsx } from 'clsx'
|
||||
@@ -24,6 +24,13 @@ export default function ConfirmationPage() {
|
||||
|
||||
const loadMatch = async () => {
|
||||
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)
|
||||
setLoading(false)
|
||||
}
|
||||
@@ -113,6 +120,11 @@ export default function ConfirmationPage() {
|
||||
{new Date(match.date).toLocaleDateString('pt-BR', { day: 'numeric', month: 'short' })}
|
||||
</div>
|
||||
<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 && (
|
||||
<div className="flex items-center gap-2">
|
||||
<MapPin className="w-3.5 h-3.5 text-primary" />
|
||||
@@ -156,11 +168,11 @@ export default function ConfirmationPage() {
|
||||
</AnimatePresence>
|
||||
|
||||
{/* 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">
|
||||
<div className="flex items-center justify-between">
|
||||
<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>
|
||||
<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'}
|
||||
@@ -193,11 +205,33 @@ export default function ConfirmationPage() {
|
||||
</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>
|
||||
)}
|
||||
|
||||
{/* Selection Box */}
|
||||
{match.status === 'SCHEDULED' && (
|
||||
{match.status === 'CONVOCACAO' && (
|
||||
<section className="space-y-6 animate-in fade-in slide-in-from-bottom-6 duration-1000">
|
||||
<div className="flex items-center justify-between">
|
||||
<h3 className="text-xs font-black uppercase tracking-[0.2em] text-primary flex items-center gap-2">
|
||||
|
||||
179
src/app/match/[id]/vote/page.tsx
Normal file
179
src/app/match/[id]/vote/page.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
76
src/app/match/[id]/vote/success/page.tsx
Normal file
76
src/app/match/[id]/vote/success/page.tsx
Normal 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 já 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>
|
||||
)
|
||||
}
|
||||
@@ -1,8 +1,8 @@
|
||||
'use client'
|
||||
|
||||
import { useState, useTransition } from 'react'
|
||||
import { createArena, deleteArena } from '@/actions/arena'
|
||||
import { MapPin, Plus, Trash2, Loader2, Navigation } from 'lucide-react'
|
||||
import { createArena, deleteArena, updateArena } from '@/actions/arena'
|
||||
import { MapPin, Plus, Trash2, Loader2, Navigation, Pencil, X } from 'lucide-react'
|
||||
import type { Arena } from '@prisma/client'
|
||||
import { DeleteConfirmationModal } from '@/components/DeleteConfirmationModal'
|
||||
|
||||
@@ -12,6 +12,7 @@ interface ArenasManagerProps {
|
||||
|
||||
export function ArenasManager({ arenas }: ArenasManagerProps) {
|
||||
const [isPending, startTransition] = useTransition()
|
||||
const [editingArena, setEditingArena] = useState<Arena | null>(null)
|
||||
const [deleteModal, setDeleteModal] = useState<{
|
||||
isOpen: boolean
|
||||
arenaId: string | null
|
||||
@@ -46,6 +47,17 @@ export function ArenasManager({ arenas }: ArenasManagerProps) {
|
||||
})
|
||||
}
|
||||
|
||||
const handleEdit = (arena: Arena) => {
|
||||
setEditingArena(arena)
|
||||
document.getElementById('arena-form')?.scrollIntoView({ behavior: 'smooth' })
|
||||
}
|
||||
|
||||
const cancelEdit = () => {
|
||||
setEditingArena(null)
|
||||
const form = document.getElementById('arena-form') as HTMLFormElement
|
||||
form?.reset()
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="ui-card p-8 space-y-8">
|
||||
<header>
|
||||
@@ -70,14 +82,23 @@ export function ArenasManager({ arenas }: ArenasManagerProps) {
|
||||
{arena.address && <p className="text-sm text-muted">{arena.address}</p>}
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
onClick={() => handleDelete(arena.id)}
|
||||
disabled={isPending}
|
||||
className="p-2 text-muted hover:text-destructive hover:bg-destructive/10 rounded-lg transition-colors opacity-0 group-hover:opacity-100 focus:opacity-100"
|
||||
title="Excluir local"
|
||||
>
|
||||
<Trash2 className="w-4 h-4" />
|
||||
</button>
|
||||
<div className="flex items-center gap-1 opacity-0 group-hover:opacity-100 focus-within:opacity-100 transition-opacity">
|
||||
<button
|
||||
onClick={() => handleEdit(arena)}
|
||||
className="p-2 text-muted hover:text-primary hover:bg-primary/10 rounded-lg transition-colors"
|
||||
title="Editar local"
|
||||
>
|
||||
<Pencil className="w-4 h-4" />
|
||||
</button>
|
||||
<button
|
||||
onClick={() => handleDelete(arena.id)}
|
||||
disabled={isPending}
|
||||
className="p-2 text-muted hover:text-destructive hover:bg-destructive/10 rounded-lg transition-colors"
|
||||
title="Excluir local"
|
||||
>
|
||||
<Trash2 className="w-4 h-4" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
|
||||
@@ -91,16 +112,36 @@ export function ArenasManager({ arenas }: ArenasManagerProps) {
|
||||
|
||||
<form action={(formData) => {
|
||||
startTransition(async () => {
|
||||
await createArena(formData)
|
||||
const form = document.getElementById('arena-form') as HTMLFormElement
|
||||
form?.reset()
|
||||
if (editingArena) {
|
||||
await updateArena(editingArena.id, formData)
|
||||
} else {
|
||||
await createArena(formData)
|
||||
}
|
||||
cancelEdit()
|
||||
})
|
||||
}} id="arena-form" className="pt-6 mt-6 border-t border-border">
|
||||
}} id="arena-form" className="pt-6 mt-6 border-t border-border space-y-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<h4 className="text-sm font-bold uppercase tracking-widest text-primary">
|
||||
{editingArena ? 'Editando Local' : 'Adicionar Novo Local'}
|
||||
</h4>
|
||||
{editingArena && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={cancelEdit}
|
||||
className="text-[10px] font-bold uppercase text-muted hover:text-foreground flex items-center gap-1"
|
||||
>
|
||||
<X className="w-3 h-3" /> Cancelar Edição
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col md:flex-row gap-4 items-end">
|
||||
<div className="ui-form-field flex-1">
|
||||
<label className="text-label ml-1">Nome do Local</label>
|
||||
<input
|
||||
name="name"
|
||||
defaultValue={editingArena?.name || ''}
|
||||
key={editingArena?.id || 'new'}
|
||||
placeholder="Ex: Arena do Zé"
|
||||
className="ui-input w-full"
|
||||
required
|
||||
@@ -110,6 +151,8 @@ export function ArenasManager({ arenas }: ArenasManagerProps) {
|
||||
<label className="text-label ml-1">Endereço (Opcional)</label>
|
||||
<input
|
||||
name="address"
|
||||
defaultValue={editingArena?.address || ''}
|
||||
key={editingArena?.id ? `addr-${editingArena.id}` : 'new-addr'}
|
||||
placeholder="Rua das Flores, 123"
|
||||
className="ui-input w-full"
|
||||
/>
|
||||
@@ -117,14 +160,14 @@ export function ArenasManager({ arenas }: ArenasManagerProps) {
|
||||
<button
|
||||
type="submit"
|
||||
disabled={isPending}
|
||||
className="ui-button h-[42px] px-6 whitespace-nowrap"
|
||||
className="ui-button h-[42px] px-6 whitespace-nowrap min-w-[140px]"
|
||||
>
|
||||
{isPending ? (
|
||||
<Loader2 className="w-4 h-4 animate-spin mr-2" />
|
||||
) : (
|
||||
<Plus className="w-4 h-4 mr-2" />
|
||||
editingArena ? <Pencil className="w-4 h-4 mr-2" /> : <Plus className="w-4 h-4 mr-2" />
|
||||
)}
|
||||
Adicionar
|
||||
{editingArena ? 'Salvar' : 'Adicionar'}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
@@ -74,8 +74,8 @@ export function CreateFinanceEventModal({ isOpen, onClose, players }: CreateFina
|
||||
const selectNone = () => setSelectedPlayers([])
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 z-50 flex items-center justify-center p-4 bg-black/80 backdrop-blur-sm animate-in fade-in duration-200">
|
||||
<div className="bg-surface border border-border rounded-xl w-full max-w-lg shadow-2xl overflow-hidden flex flex-col max-h-[90vh]">
|
||||
<div className="fixed inset-0 z-[100] flex items-start justify-center p-4 bg-black/80 backdrop-blur-sm animate-in fade-in duration-200 overflow-y-auto custom-scrollbar">
|
||||
<div className="bg-surface border border-border rounded-xl w-full max-w-lg shadow-2xl overflow-visible flex flex-col my-8">
|
||||
<div className="p-6 border-b border-border">
|
||||
<h3 className="text-lg font-bold">Novo Evento Financeiro</h3>
|
||||
<p className="text-sm text-muted">Crie mensalidades, churrascos ou arrecadações.</p>
|
||||
@@ -119,12 +119,14 @@ export function CreateFinanceEventModal({ isOpen, onClose, players }: CreateFina
|
||||
/>
|
||||
</div>
|
||||
|
||||
<DateTimePicker
|
||||
label="Vencimento"
|
||||
value={dueDate}
|
||||
onChange={setDueDate}
|
||||
mode="date"
|
||||
/>
|
||||
<div className="relative z-[100]">
|
||||
<DateTimePicker
|
||||
label="Vencimento"
|
||||
value={dueDate}
|
||||
onChange={setDueDate}
|
||||
mode="date"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="bg-surface/50 border border-border p-4 rounded-xl space-y-4">
|
||||
<div className="flex items-center justify-between">
|
||||
|
||||
214
src/components/CreateTransactionModal.tsx
Normal file
214
src/components/CreateTransactionModal.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
280
src/components/DateRangePicker.tsx
Normal file
280
src/components/DateRangePicker.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
@@ -12,6 +12,7 @@ interface DateTimePickerProps {
|
||||
placeholder?: string
|
||||
required?: boolean
|
||||
mode?: 'date' | 'datetime'
|
||||
className?: string
|
||||
}
|
||||
|
||||
export function DateTimePicker({
|
||||
@@ -20,7 +21,8 @@ export function DateTimePicker({
|
||||
label,
|
||||
placeholder,
|
||||
required,
|
||||
mode = 'datetime'
|
||||
mode = 'datetime',
|
||||
className
|
||||
}: DateTimePickerProps) {
|
||||
const [isOpen, setIsOpen] = useState(false)
|
||||
const [mounted, setMounted] = useState(false)
|
||||
@@ -112,15 +114,18 @@ export function DateTimePicker({
|
||||
}, [viewDate])
|
||||
|
||||
const handleDateSelect = (day: number, month: number, year: number) => {
|
||||
const newDate = parseValue(value)
|
||||
newDate.setFullYear(year)
|
||||
newDate.setMonth(month)
|
||||
newDate.setDate(day)
|
||||
|
||||
if (mode === 'date') {
|
||||
onChange(newDate.toISOString().split('T')[0])
|
||||
const y = year
|
||||
const m = (month + 1).toString().padStart(2, '0')
|
||||
const d = day.toString().padStart(2, '0')
|
||||
onChange(`${y}-${m}-${d}`)
|
||||
setIsOpen(false)
|
||||
} else {
|
||||
const newDate = parseValue(value)
|
||||
newDate.setFullYear(year)
|
||||
newDate.setMonth(month)
|
||||
newDate.setDate(day)
|
||||
|
||||
// If it's the first time selecting in datetime mode, set a default time
|
||||
if (!value) {
|
||||
newDate.setHours(19, 0, 0, 0)
|
||||
@@ -169,7 +174,7 @@ export function DateTimePicker({
|
||||
const minutes = Array.from({ length: 12 }, (_, i) => i * 5)
|
||||
|
||||
return (
|
||||
<div className="ui-form-field" ref={containerRef}>
|
||||
<div className={clsx("ui-form-field", className)} ref={containerRef}>
|
||||
{label && <label className="text-label ml-1">{label}</label>}
|
||||
<div className="relative">
|
||||
<div
|
||||
@@ -258,7 +263,7 @@ export function DateTimePicker({
|
||||
"w-full aspect-square text-xs font-black rounded-xl transition-all flex items-center justify-center relative border overflow-hidden group",
|
||||
d.currentMonth ? "text-foreground" : "text-muted/10",
|
||||
isSelected
|
||||
? "bg-primary text-black border-primary shadow-[0_0_15px_rgba(var(--primary-rgb),0.3)] scale-105 z-10"
|
||||
? "bg-primary text-background border-primary shadow-[0_0_15px_rgba(var(--primary-rgb),0.3)] scale-105 z-10"
|
||||
: "bg-white/[0.02] border-white/5 hover:border-primary/40 hover:bg-primary/5 hover:text-primary",
|
||||
!isSelected && isToday && "border-primary/20 after:content-[''] after:absolute after:bottom-1.5 after:w-1 after:h-1 after:bg-primary after:rounded-full"
|
||||
)}
|
||||
@@ -334,7 +339,7 @@ export function DateTimePicker({
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setIsOpen(false)}
|
||||
className="mt-6 w-full h-11 bg-white text-black font-black uppercase text-[10px] tracking-widest rounded-xl hover:scale-[1.02] active:scale-[0.98] transition-all shadow-xl shadow-black/20"
|
||||
className="mt-6 w-full h-11 bg-foreground text-background font-black uppercase text-[10px] tracking-widest rounded-xl hover:scale-[1.02] active:scale-[0.98] transition-all shadow-xl shadow-black/20"
|
||||
>
|
||||
Confirmar
|
||||
</button>
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -1,7 +1,7 @@
|
||||
'use client'
|
||||
|
||||
import React, { useState, useMemo, useEffect } from 'react'
|
||||
import { Calendar, Users, Trophy, ChevronRight, X, Clock, ExternalLink, Star, Link as LinkIcon, MapPin, Share2, Shuffle, Trash2, MessageCircle, Repeat, Search, LayoutGrid, List, Check } from 'lucide-react'
|
||||
import { Calendar, Users, Trophy, ChevronRight, X, Clock, ExternalLink, Star, Link as LinkIcon, MapPin, Share2, Shuffle, Trash2, MessageCircle, Repeat, Search, LayoutGrid, List, Check, Pencil, Zap } from 'lucide-react'
|
||||
import { motion, AnimatePresence } from 'framer-motion'
|
||||
import { clsx } from 'clsx'
|
||||
import Link from 'next/link'
|
||||
@@ -150,10 +150,36 @@ export function MatchHistory({ matches, players = [], groupName = 'Pelada' }: {
|
||||
|
||||
const getStatusInfo = (status: string) => {
|
||||
switch (status) {
|
||||
case 'SCHEDULED': return { label: 'Agendado', color: 'bg-blue-500/10 text-blue-500 border-blue-500/20' }
|
||||
case 'IN_PROGRESS': return { label: 'Em Andamento', color: 'bg-primary/10 text-primary border-primary/20' }
|
||||
case 'COMPLETED': return { label: 'Concluído', color: 'bg-white/5 text-muted border-white/10' }
|
||||
default: return { label: status, color: 'bg-white/5 text-muted border-white/10' }
|
||||
case 'CONVOCACAO': return {
|
||||
label: 'Convocação',
|
||||
color: 'bg-blue-500/10 text-blue-500 border-blue-500/20',
|
||||
actionLabel: 'Clique para Sortear',
|
||||
icon: Shuffle
|
||||
}
|
||||
case 'SORTEIO': return {
|
||||
label: 'Times Definidos',
|
||||
color: 'bg-amber-500/10 text-amber-500 border-amber-500/20',
|
||||
actionLabel: 'Gerar Capas / Iniciar Resenha',
|
||||
icon: Zap
|
||||
}
|
||||
case 'IN_PROGRESS': return {
|
||||
label: 'Votação Aberta',
|
||||
color: 'bg-orange-500/10 text-orange-500 border-orange-500/20',
|
||||
actionLabel: 'Ver Acompanhamento',
|
||||
icon: Users
|
||||
}
|
||||
case 'ENCERRAMENTO': return {
|
||||
label: 'Resenha Finalizada',
|
||||
color: 'bg-emerald-500/10 text-emerald-500 border-emerald-500/20',
|
||||
actionLabel: 'Ver Resultados',
|
||||
icon: Trophy
|
||||
}
|
||||
default: return {
|
||||
label: status,
|
||||
color: 'bg-white/5 text-muted border-white/10',
|
||||
actionLabel: 'Gerenciar',
|
||||
icon: Pencil
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -179,32 +205,44 @@ export function MatchHistory({ matches, players = [], groupName = 'Pelada' }: {
|
||||
}
|
||||
|
||||
const shareWhatsAppList = (match: any) => {
|
||||
const confirmed = (match.attendances || []).filter((a: any) => a.status === 'CONFIRMED')
|
||||
const confirmedIds = new Set(confirmed.map((a: any) => a.playerId))
|
||||
const pending = players.filter(p => !confirmedIds.has(p.id))
|
||||
|
||||
const url = `${window.location.origin}/match/${match.id}/confirmacao`
|
||||
|
||||
const dateStr = new Date(match.date).toLocaleDateString('pt-BR', { weekday: 'long', day: 'numeric', month: 'short' })
|
||||
const timeStr = new Date(match.date).toLocaleTimeString('pt-BR', { hour: '2-digit', minute: '2-digit' })
|
||||
const url = `${window.location.origin}/match/${match.id}/confirmacao`
|
||||
const finalGroupName = (match.group?.name || groupName).toUpperCase()
|
||||
|
||||
const finalGroupName = match.group?.name || groupName
|
||||
let text = ''
|
||||
|
||||
const text = `⚽ *LISTA DE PRESENÇA: ${finalGroupName.toUpperCase()}* ⚽\n\n` +
|
||||
`📅 *JOGO:* ${dateStr} às ${timeStr}\n` +
|
||||
`📍 *LOCAL:* ${match.location || 'A definir'}\n\n` +
|
||||
`✅ *CONFIRMADOS (${confirmed.length}/${match.maxPlayers || '∞'}):*\n` +
|
||||
(confirmed.length > 0
|
||||
? confirmed.map((a: any) => `✅ ${a.player.name}`).join('\n')
|
||||
: "_Nenhuma confirmação ainda_") +
|
||||
`\n\n⏳ *AGUARDANDO:* \n` +
|
||||
(pending.length > 0
|
||||
? pending.map((p: any) => `▫️ ${p.name}`).join('\n')
|
||||
: "_Todos confirmados!_") +
|
||||
`\n\n🔗 *Confirme sua presença aqui:* ${url}`
|
||||
if (match.status === 'CONVOCACAO') {
|
||||
const confirmed = (match.attendances || []).filter((a: any) => a.status === 'CONFIRMED')
|
||||
const confirmedIds = new Set(confirmed.map((a: any) => a.playerId))
|
||||
const pending = players.filter(p => !confirmedIds.has(p.id))
|
||||
|
||||
text = `⚽ *LISTA DE PRESENÇA: ${finalGroupName}* ⚽\n\n` +
|
||||
`📅 *JOGO:* ${dateStr} às ${timeStr}\n` +
|
||||
`📍 *LOCAL:* ${match.location || 'A definir'}\n\n` +
|
||||
`✅ *CONFIRMADOS (${confirmed.length}/${match.maxPlayers || '∞'}):*\n` +
|
||||
(confirmed.length > 0
|
||||
? confirmed.map((a: any) => `✅ ${a.player.name}`).join('\n')
|
||||
: "_Nenhuma confirmação ainda_") +
|
||||
`\n\n⏳ *AGUARDANDO:* \n` +
|
||||
(pending.length > 0
|
||||
? pending.map((p: any) => `▫️ ${p.name}`).join('\n')
|
||||
: "_Todos confirmados!_") +
|
||||
`\n\n🔗 *Confirme sua presença aqui:* ${url}`
|
||||
} else {
|
||||
// SORTEIO or ENCERRAMENTO
|
||||
text = `⚽ *SORTEIO REALIZADO: ${finalGroupName}* ⚽\n\n` +
|
||||
`📅 *JOGO:* ${dateStr} às ${timeStr}\n` +
|
||||
`📍 *LOCAL:* ${match.location || 'A definir'}\n\n` +
|
||||
(match.teams || []).map((team: any) => {
|
||||
const playersList = (team.players || []).map((p: any) => `▫️ ${p.player.name}`).join('\n')
|
||||
return `👕 *${team.name.toUpperCase()}:*\n${playersList}`
|
||||
}).join('\n\n') +
|
||||
`\n\n🔗 *Veja os detalhes e vote no craque:* ${url}`
|
||||
}
|
||||
|
||||
navigator.clipboard.writeText(text)
|
||||
setCopySuccess('Lista formatada copiada!')
|
||||
setCopySuccess('Texto para WhatsApp copiado!')
|
||||
setTimeout(() => setCopySuccess(null), 2000)
|
||||
|
||||
window.open(`https://api.whatsapp.com/send?text=${encodeURIComponent(text)}`, '_blank')
|
||||
@@ -359,7 +397,7 @@ export function MatchHistory({ matches, players = [], groupName = 'Pelada' }: {
|
||||
<div className="space-y-1 min-w-0 flex-1">
|
||||
<div className="flex items-center gap-2 flex-wrap">
|
||||
<p className="text-sm font-semibold truncate">
|
||||
{match.status === 'SCHEDULED' ? `Evento: ${match.location || 'Sem local'}` : `Sorteio de ${match.teams.length} times`}
|
||||
{match.status === 'CONVOCACAO' ? `Evento: ${match.location || 'Sem local'}` : `Sorteio de ${match.teams.length} times`}
|
||||
</p>
|
||||
{match.isRecurring && (
|
||||
<span className="badge bg-purple-500/10 text-purple-500 border-purple-500/20 px-1.5 py-0.5" title="Recorrente">
|
||||
@@ -367,18 +405,43 @@ export function MatchHistory({ matches, players = [], groupName = 'Pelada' }: {
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex gap-1.5 flex-wrap">
|
||||
<span className={clsx("badge", s.color)}>{s.label}</span>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className={clsx("flex flex-wrap items-center gap-3 text-[11px] text-muted", viewMode === 'list' ? "" : "border-t border-border/50 pt-4 mt-auto")}>
|
||||
<div className="flex items-center gap-1">
|
||||
<Users className="w-3 h-3" />
|
||||
{match.status === 'SCHEDULED'
|
||||
? `${(match.attendances || []).filter((a: any) => a.status === 'CONFIRMED').length} confirmados`
|
||||
: `${(match.teams || []).reduce((acc: number, t: any) => acc + t.players.length, 0)} jogadores`}
|
||||
{viewMode === 'grid' && (
|
||||
<div className="w-full mb-3">
|
||||
<Link
|
||||
href={`/dashboard/matches/new?id=${match.id}`}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
className={clsx(
|
||||
"w-full flex items-center justify-center gap-2 py-2.5 rounded-xl border text-[10px] font-black uppercase tracking-widest transition-all hover:shadow-lg shadow-sm border-white/5",
|
||||
s.color
|
||||
)}
|
||||
>
|
||||
<s.icon className="w-4 h-4" />
|
||||
{s.actionLabel}
|
||||
</Link>
|
||||
</div>
|
||||
)}
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="flex items-center gap-1">
|
||||
<Users className="w-3 h-3" />
|
||||
<span className="font-bold">
|
||||
{match.status === 'CONVOCACAO'
|
||||
? `${(match.attendances || []).filter((a: any) => a.status === 'CONFIRMED').length} confirmados`
|
||||
: `${(match.teams || []).reduce((acc: number, t: any) => acc + t.players.length, 0)} jogadores`}
|
||||
</span>
|
||||
</div>
|
||||
<div className={clsx(
|
||||
"px-2 py-0.5 rounded-full text-[8px] font-black uppercase tracking-widest border",
|
||||
s.color
|
||||
)}>
|
||||
{s.label}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="w-1 h-1 rounded-full bg-border" />
|
||||
<div className="flex items-center gap-1">
|
||||
@@ -388,20 +451,33 @@ export function MatchHistory({ matches, players = [], groupName = 'Pelada' }: {
|
||||
|
||||
{/* Actions only in List Mode here, else in modal */}
|
||||
{viewMode === 'list' && (
|
||||
<div className="ml-auto flex items-center gap-2 opacity-0 group-hover:opacity-100 transition-opacity">
|
||||
{match.status === 'SCHEDULED' && (
|
||||
<div className="ml-auto flex items-center gap-4 opacity-0 group-hover:opacity-100 transition-all duration-300">
|
||||
<Link
|
||||
href={`/dashboard/matches/new?id=${match.id}`}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
className={clsx(
|
||||
"flex items-center gap-2 px-4 py-2 rounded-xl border text-[9px] font-black uppercase tracking-[0.15em] transition-all hover:scale-105 active:scale-95 shadow-lg",
|
||||
s.color
|
||||
)}
|
||||
title={s.actionLabel}
|
||||
>
|
||||
<s.icon className="w-3.5 h-3.5" />
|
||||
<span>{s.actionLabel}</span>
|
||||
</Link>
|
||||
|
||||
{match.status === 'CONVOCACAO' && (
|
||||
<button
|
||||
onClick={(e) => {
|
||||
e.stopPropagation()
|
||||
copyMatchLink(match)
|
||||
}}
|
||||
className="p-1.5 text-primary hover:bg-primary/10 rounded transition-colors"
|
||||
className="p-2 text-muted hover:text-primary hover:bg-primary/10 rounded-lg transition-colors border border-transparent hover:border-primary/20"
|
||||
title="Copiar Link de Confirmação"
|
||||
>
|
||||
<LinkIcon className="w-4 h-4" />
|
||||
</button>
|
||||
)}
|
||||
<ChevronRight className="w-4 h-4" />
|
||||
<ChevronRight className="w-4 h-4 text-muted/40" />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
@@ -478,6 +554,17 @@ export function MatchHistory({ matches, players = [], groupName = 'Pelada' }: {
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<Link
|
||||
href={`/dashboard/matches/new?id=${selectedMatch.id}`}
|
||||
className={clsx(
|
||||
"flex items-center gap-2 px-4 py-2 rounded-xl border text-[10px] font-black uppercase tracking-widest transition-all hover:scale-105 active:scale-95 shadow-md",
|
||||
getStatusInfo(selectedMatch.status).color
|
||||
)}
|
||||
title={getStatusInfo(selectedMatch.status).actionLabel}
|
||||
>
|
||||
{React.createElement(getStatusInfo(selectedMatch.status).icon, { className: "w-4 h-4" })}
|
||||
<span>{getStatusInfo(selectedMatch.status).actionLabel}</span>
|
||||
</Link>
|
||||
<button
|
||||
onClick={() => handleDeleteMatch(selectedMatch.id)}
|
||||
className="p-2.5 text-muted hover:text-red-500 transition-colors rounded-lg"
|
||||
@@ -495,7 +582,7 @@ export function MatchHistory({ matches, players = [], groupName = 'Pelada' }: {
|
||||
</div>
|
||||
|
||||
<div className="p-6 overflow-y-auto custom-scrollbar">
|
||||
{selectedMatch.status === 'SCHEDULED' ? (
|
||||
{selectedMatch.status === 'CONVOCACAO' ? (
|
||||
<div className="space-y-6">
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
|
||||
<div className="ui-card p-4 bg-surface-raised/30">
|
||||
@@ -631,6 +718,17 @@ export function MatchHistory({ matches, players = [], groupName = 'Pelada' }: {
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{selectedMatch.status === ('SORTEIO' as any) && (
|
||||
<div className="pt-4">
|
||||
<Link
|
||||
href={`/dashboard/matches/new?id=${selectedMatch.id}`}
|
||||
className="ui-button w-full h-12 text-sm font-bold bg-primary text-background shadow-lg shadow-primary/20"
|
||||
>
|
||||
<Zap className="w-4 h-4 mr-2" /> Iniciar Gamificação & Votação
|
||||
</Link>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
199
src/components/MatchPodium.tsx
Normal file
199
src/components/MatchPodium.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
301
src/components/MatchScheduler.tsx
Normal file
301
src/components/MatchScheduler.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
@@ -1,8 +1,8 @@
|
||||
'use client'
|
||||
|
||||
import React, { useState, useMemo } from 'react'
|
||||
import { Plus, Trash2, UserPlus, Star, Search, Filter, MoreHorizontal, User, Shield, Target, Zap, ChevronDown, LayoutGrid, List, ChevronRight, Check, X, AlertCircle } from 'lucide-react'
|
||||
import { addPlayer, deletePlayer, deletePlayers } from '@/actions/player'
|
||||
import { Plus, Trash2, UserPlus, Star, Search, Filter, MoreHorizontal, User, Shield, Target, Zap, ChevronDown, LayoutGrid, List, ChevronRight, Check, X, AlertCircle, Pencil } from 'lucide-react'
|
||||
import { addPlayer, deletePlayer, deletePlayers, updatePlayer } from '@/actions/player'
|
||||
import { motion, AnimatePresence } from 'framer-motion'
|
||||
import { clsx } from 'clsx'
|
||||
import { DeleteConfirmationModal } from '@/components/DeleteConfirmationModal'
|
||||
@@ -16,8 +16,9 @@ export function PlayersList({ group }: { group: any }) {
|
||||
const [activeTab, setActiveTab] = useState<'ALL' | 'DEF' | 'MEI' | 'ATA'>('ALL')
|
||||
const [viewMode, setViewMode] = useState<'grid' | 'list'>('grid')
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
const [isAdding, setIsAdding] = useState(false)
|
||||
const [isSaving, setIsSaving] = useState(false)
|
||||
const [isFormOpen, setIsFormOpen] = useState(false)
|
||||
const [editingPlayer, setEditingPlayer] = useState<any | null>(null)
|
||||
|
||||
// Pagination & Selection
|
||||
const [currentPage, setCurrentPage] = useState(1)
|
||||
@@ -40,27 +41,53 @@ export function PlayersList({ group }: { group: any }) {
|
||||
description: ''
|
||||
})
|
||||
|
||||
const handleAddPlayer = async (e: React.FormEvent) => {
|
||||
const handleSavePlayer = async (e: React.FormEvent) => {
|
||||
e.preventDefault()
|
||||
setError(null)
|
||||
if (!newPlayerName) return
|
||||
|
||||
setIsAdding(true)
|
||||
setIsSaving(true)
|
||||
try {
|
||||
const playerNumber = number.trim() === '' ? null : parseInt(number)
|
||||
await addPlayer(group.id, newPlayerName, level, playerNumber, position)
|
||||
setNewPlayerName('')
|
||||
setLevel(3)
|
||||
setNumber('')
|
||||
setPosition('MEI')
|
||||
setIsFormOpen(false)
|
||||
|
||||
if (editingPlayer) {
|
||||
await updatePlayer(editingPlayer.id, {
|
||||
name: newPlayerName,
|
||||
level,
|
||||
number: playerNumber,
|
||||
position
|
||||
})
|
||||
} else {
|
||||
await addPlayer(group.id, newPlayerName, level, playerNumber, position)
|
||||
}
|
||||
|
||||
closeForm()
|
||||
} catch (err: any) {
|
||||
setError(err.message)
|
||||
} finally {
|
||||
setIsAdding(false)
|
||||
setIsSaving(false)
|
||||
}
|
||||
}
|
||||
|
||||
const closeForm = () => {
|
||||
setNewPlayerName('')
|
||||
setLevel(3)
|
||||
setNumber('')
|
||||
setPosition('MEI')
|
||||
setEditingPlayer(null)
|
||||
setIsFormOpen(false)
|
||||
setError(null)
|
||||
}
|
||||
|
||||
const openEditForm = (player: any) => {
|
||||
setEditingPlayer(player)
|
||||
setNewPlayerName(player.name)
|
||||
setLevel(player.level)
|
||||
setNumber(player.number?.toString() || '')
|
||||
setPosition(player.position as any)
|
||||
setIsFormOpen(true)
|
||||
}
|
||||
|
||||
const filteredPlayers = useMemo(() => {
|
||||
return group.players.filter((p: any) => {
|
||||
const matchesSearch = p.name.toLowerCase().includes(searchQuery.toLowerCase()) ||
|
||||
@@ -174,7 +201,6 @@ export function PlayersList({ group }: { group: any }) {
|
||||
>
|
||||
<button
|
||||
onClick={() => {
|
||||
setError(null)
|
||||
setIsFormOpen(true)
|
||||
}}
|
||||
className="ui-button w-full sm:w-auto shadow-lg shadow-primary/20"
|
||||
@@ -193,7 +219,7 @@ export function PlayersList({ group }: { group: any }) {
|
||||
initial={{ opacity: 0 }}
|
||||
animate={{ opacity: 1 }}
|
||||
exit={{ opacity: 0 }}
|
||||
onClick={() => setIsFormOpen(false)}
|
||||
onClick={closeForm}
|
||||
className="absolute inset-0 bg-background/80 backdrop-blur-sm"
|
||||
/>
|
||||
<motion.div
|
||||
@@ -205,22 +231,26 @@ export function PlayersList({ group }: { group: any }) {
|
||||
<div className="p-6 bg-surface-raised/50 border-b border-border flex items-center justify-between">
|
||||
<div className="flex items-center gap-4">
|
||||
<div className="w-12 h-12 bg-primary/10 rounded-xl flex items-center justify-center border border-primary/20 shadow-inner">
|
||||
<UserPlus className="w-6 h-6 text-primary" />
|
||||
{editingPlayer ? <Pencil className="w-6 h-6 text-primary" /> : <UserPlus className="w-6 h-6 text-primary" />}
|
||||
</div>
|
||||
<div>
|
||||
<h3 className="text-lg font-bold tracking-tight">Novo Atleta</h3>
|
||||
<p className="text-xs text-muted font-medium uppercase tracking-wider">Adicionar ao elenco</p>
|
||||
<h3 className="text-lg font-bold tracking-tight">
|
||||
{editingPlayer ? 'Editar Atleta' : 'Novo Atleta'}
|
||||
</h3>
|
||||
<p className="text-xs text-muted font-medium uppercase tracking-wider">
|
||||
{editingPlayer ? 'Atualizar informações' : 'Adicionar ao elenco'}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
onClick={() => setIsFormOpen(false)}
|
||||
onClick={closeForm}
|
||||
className="p-2 text-muted hover:text-foreground rounded-lg transition-colors"
|
||||
>
|
||||
<X className="w-5 h-5" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<form onSubmit={handleAddPlayer} className="p-6 space-y-6">
|
||||
<form onSubmit={handleSavePlayer} className="p-6 space-y-6">
|
||||
<div className="space-y-4">
|
||||
<div className="flex items-center gap-2">
|
||||
<User className="w-4 h-4 text-primary" />
|
||||
@@ -337,17 +367,17 @@ export function PlayersList({ group }: { group: any }) {
|
||||
<div className="pt-4 flex gap-3">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setIsFormOpen(false)}
|
||||
onClick={closeForm}
|
||||
className="ui-button-ghost flex-1 h-12"
|
||||
>
|
||||
Cancelar
|
||||
</button>
|
||||
<button
|
||||
type="submit"
|
||||
disabled={isAdding || !newPlayerName}
|
||||
disabled={isSaving || !newPlayerName}
|
||||
className="ui-button flex-[2] h-12 shadow-xl shadow-primary/10"
|
||||
>
|
||||
{isAdding ? <div className="w-5 h-5 border-2 border-background/30 border-t-background rounded-full animate-spin" /> : 'Confirmar Cadastro'}
|
||||
{isSaving ? <div className="w-5 h-5 border-2 border-background/30 border-t-background rounded-full animate-spin" /> : (editingPlayer ? 'Salvar Alterações' : 'Confirmar Cadastro')}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
@@ -538,16 +568,28 @@ export function PlayersList({ group }: { group: any }) {
|
||||
Status: {getLevelInfo(p.level).label}
|
||||
</div>
|
||||
)}
|
||||
<button
|
||||
onClick={(e) => {
|
||||
e.stopPropagation()
|
||||
handleSingleDelete(p.id, p.name)
|
||||
}}
|
||||
className="p-2 text-muted hover:text-red-500 hover:bg-red-500/10 rounded-xl transition-all opacity-0 lg:group-hover:opacity-100"
|
||||
title="Excluir Atleta"
|
||||
>
|
||||
<Trash2 className="w-4 h-4" />
|
||||
</button>
|
||||
<div className="flex items-center gap-1">
|
||||
<button
|
||||
onClick={(e) => {
|
||||
e.stopPropagation()
|
||||
openEditForm(p)
|
||||
}}
|
||||
className="p-2 text-muted hover:text-primary hover:bg-primary/10 rounded-xl transition-all opacity-0 lg:group-hover:opacity-100"
|
||||
title="Editar Atleta"
|
||||
>
|
||||
<Pencil className="w-4 h-4" />
|
||||
</button>
|
||||
<button
|
||||
onClick={(e) => {
|
||||
e.stopPropagation()
|
||||
handleSingleDelete(p.id, p.name)
|
||||
}}
|
||||
className="p-2 text-muted hover:text-red-500 hover:bg-red-500/10 rounded-xl transition-all opacity-0 lg:group-hover:opacity-100"
|
||||
title="Excluir Atleta"
|
||||
>
|
||||
<Trash2 className="w-4 h-4" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</motion.div>
|
||||
))}
|
||||
|
||||
@@ -5,6 +5,7 @@ import { updateGroupSettings } from '@/app/actions'
|
||||
import { Upload, Save, Loader2, Image as ImageIcon, AlertCircle, CheckCircle } from 'lucide-react'
|
||||
import { useRouter } from 'next/navigation'
|
||||
import { motion, AnimatePresence } from 'framer-motion'
|
||||
import { clsx } from 'clsx'
|
||||
|
||||
interface SettingsFormProps {
|
||||
initialData: {
|
||||
@@ -186,6 +187,7 @@ export function SettingsForm({ initialData }: SettingsFormProps) {
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
|
||||
{/* Status Messages */}
|
||||
|
||||
@@ -1,34 +1,38 @@
|
||||
'use client'
|
||||
|
||||
import { useState } from 'react'
|
||||
import { MapPin, Palette, Briefcase } from 'lucide-react'
|
||||
import { MapPin, Palette, Briefcase, Shirt, Zap } from 'lucide-react'
|
||||
import { clsx } from 'clsx'
|
||||
import { motion, AnimatePresence } from 'framer-motion'
|
||||
|
||||
interface SettingsTabsProps {
|
||||
branding: React.ReactNode
|
||||
teams: React.ReactNode
|
||||
arenas: React.ReactNode
|
||||
sponsors: React.ReactNode
|
||||
voting: React.ReactNode
|
||||
}
|
||||
|
||||
export function SettingsTabs({ branding, arenas, sponsors }: SettingsTabsProps) {
|
||||
const [activeTab, setActiveTab] = useState<'branding' | 'arenas' | 'sponsors'>('branding')
|
||||
export function SettingsTabs({ branding, teams, arenas, sponsors, voting }: SettingsTabsProps) {
|
||||
const [activeTab, setActiveTab] = useState<'branding' | 'teams' | 'arenas' | 'sponsors' | 'voting'>('branding')
|
||||
|
||||
const tabs = [
|
||||
{ id: 'branding', label: 'Identidade Visual', icon: Palette },
|
||||
{ id: 'voting', label: 'Votação & Resenha', icon: Zap },
|
||||
{ id: 'teams', label: 'Times', icon: Shirt },
|
||||
{ id: 'arenas', label: 'Locais & Arenas', icon: MapPin },
|
||||
{ id: 'sponsors', label: 'Patrocínios', icon: Briefcase },
|
||||
] as const
|
||||
|
||||
return (
|
||||
<div className="space-y-8">
|
||||
<div className="flex p-1 bg-surface-raised rounded-xl border border-border w-full sm:w-fit">
|
||||
<div className="flex p-1 bg-surface-raised rounded-xl border border-border w-full sm:w-fit overflow-x-auto">
|
||||
{tabs.map((tab) => (
|
||||
<button
|
||||
key={tab.id}
|
||||
onClick={() => setActiveTab(tab.id)}
|
||||
className={clsx(
|
||||
"flex items-center gap-2 px-6 py-2.5 rounded-lg text-sm font-bold transition-all flex-1 sm:flex-none justify-center",
|
||||
"flex items-center gap-2 px-6 py-2.5 rounded-lg text-sm font-bold transition-all flex-1 sm:flex-none justify-center whitespace-nowrap",
|
||||
activeTab === tab.id
|
||||
? "bg-primary text-background shadow-lg shadow-emerald-500/10"
|
||||
: "text-muted hover:text-foreground hover:bg-white/5"
|
||||
@@ -53,6 +57,28 @@ export function SettingsTabs({ branding, arenas, sponsors }: SettingsTabsProps)
|
||||
{branding}
|
||||
</motion.div>
|
||||
)}
|
||||
{activeTab === 'voting' && (
|
||||
<motion.div
|
||||
key="voting"
|
||||
initial={{ opacity: 0, y: 10 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
exit={{ opacity: 0, y: -10 }}
|
||||
transition={{ duration: 0.2 }}
|
||||
>
|
||||
{voting}
|
||||
</motion.div>
|
||||
)}
|
||||
{activeTab === 'teams' && (
|
||||
<motion.div
|
||||
key="teams"
|
||||
initial={{ opacity: 0, y: 10 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
exit={{ opacity: 0, y: -10 }}
|
||||
transition={{ duration: 0.2 }}
|
||||
>
|
||||
{teams}
|
||||
</motion.div>
|
||||
)}
|
||||
{activeTab === 'arenas' && (
|
||||
<motion.div
|
||||
key="arenas"
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
'use client'
|
||||
|
||||
import { useState, useTransition } from 'react'
|
||||
import { createSponsor, deleteSponsor } from '@/actions/sponsor'
|
||||
import { Briefcase, Plus, Trash2, Loader2, Image as ImageIcon } from 'lucide-react'
|
||||
import { createSponsor, deleteSponsor, updateSponsor } from '@/actions/sponsor'
|
||||
import { Briefcase, Plus, Trash2, Loader2, Image as ImageIcon, Pencil, X } from 'lucide-react'
|
||||
import type { Sponsor } from '@prisma/client'
|
||||
import { DeleteConfirmationModal } from '@/components/DeleteConfirmationModal'
|
||||
|
||||
@@ -14,6 +14,7 @@ interface SponsorsManagerProps {
|
||||
export function SponsorsManager({ groupId, sponsors }: SponsorsManagerProps) {
|
||||
const [isPending, startTransition] = useTransition()
|
||||
const [filePreview, setFilePreview] = useState<string | null>(null)
|
||||
const [editingSponsor, setEditingSponsor] = useState<Sponsor | null>(null)
|
||||
const [deleteModal, setDeleteModal] = useState<{
|
||||
isOpen: boolean
|
||||
sponsorId: string | null
|
||||
@@ -48,6 +49,19 @@ export function SponsorsManager({ groupId, sponsors }: SponsorsManagerProps) {
|
||||
})
|
||||
}
|
||||
|
||||
const handleEdit = (sponsor: Sponsor) => {
|
||||
setEditingSponsor(sponsor)
|
||||
setFilePreview(sponsor.logoUrl || null)
|
||||
document.getElementById('sponsor-form')?.scrollIntoView({ behavior: 'smooth' })
|
||||
}
|
||||
|
||||
const cancelEdit = () => {
|
||||
setEditingSponsor(null)
|
||||
setFilePreview(null)
|
||||
const form = document.getElementById('sponsor-form') as HTMLFormElement
|
||||
form?.reset()
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="ui-card p-8 space-y-8">
|
||||
<header>
|
||||
@@ -76,14 +90,23 @@ export function SponsorsManager({ groupId, sponsors }: SponsorsManagerProps) {
|
||||
<p className="text-[10px] text-muted font-medium mt-0.5">Patrocinador Ativo</p>
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
onClick={() => handleDelete(sponsor.id)}
|
||||
disabled={isPending}
|
||||
className="p-2 text-muted hover:text-destructive hover:bg-destructive/10 rounded-lg transition-colors opacity-0 group-hover:opacity-100 focus:opacity-100"
|
||||
title="Excluir patrocinador"
|
||||
>
|
||||
<Trash2 className="w-4 h-4" />
|
||||
</button>
|
||||
<div className="flex items-center gap-1 opacity-0 group-hover:opacity-100 focus-within:opacity-100 transition-opacity">
|
||||
<button
|
||||
onClick={() => handleEdit(sponsor)}
|
||||
className="p-2 text-muted hover:text-primary hover:bg-primary/10 rounded-lg transition-colors"
|
||||
title="Editar patrocinador"
|
||||
>
|
||||
<Pencil className="w-4 h-4" />
|
||||
</button>
|
||||
<button
|
||||
onClick={() => handleDelete(sponsor.id)}
|
||||
disabled={isPending}
|
||||
className="p-2 text-muted hover:text-destructive hover:bg-destructive/10 rounded-lg transition-colors"
|
||||
title="Excluir patrocinador"
|
||||
>
|
||||
<Trash2 className="w-4 h-4" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
|
||||
@@ -97,22 +120,41 @@ export function SponsorsManager({ groupId, sponsors }: SponsorsManagerProps) {
|
||||
|
||||
<form action={(formData) => {
|
||||
const name = formData.get('name') as string
|
||||
const logoFile = formData.get('logo') as File
|
||||
if (!name) return
|
||||
|
||||
startTransition(async () => {
|
||||
await createSponsor(formData)
|
||||
const form = document.getElementById('sponsor-form') as HTMLFormElement
|
||||
form?.reset()
|
||||
setFilePreview(null)
|
||||
if (editingSponsor) {
|
||||
await updateSponsor(editingSponsor.id, formData)
|
||||
} else {
|
||||
await createSponsor(formData)
|
||||
}
|
||||
cancelEdit()
|
||||
})
|
||||
}} id="sponsor-form" className="pt-8 mt-8 border-t border-border">
|
||||
}} id="sponsor-form" className="pt-8 mt-8 border-t border-border space-y-4">
|
||||
<input type="hidden" name="groupId" value={groupId} />
|
||||
|
||||
<div className="flex items-center justify-between">
|
||||
<h4 className="text-sm font-bold uppercase tracking-widest text-primary">
|
||||
{editingSponsor ? 'Editando Patrocinador' : 'Adicionar Novo Patrocinador'}
|
||||
</h4>
|
||||
{editingSponsor && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={cancelEdit}
|
||||
className="text-[10px] font-bold uppercase text-muted hover:text-foreground flex items-center gap-1"
|
||||
>
|
||||
<X className="w-3 h-3" /> Cancelar Edição
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 md:grid-cols-12 gap-6 items-end">
|
||||
<div className="ui-form-field md:col-span-5">
|
||||
<label className="text-label ml-1">Nome da Empresa</label>
|
||||
<input
|
||||
name="name"
|
||||
defaultValue={editingSponsor?.name || ''}
|
||||
key={editingSponsor?.id || 'new'}
|
||||
placeholder="Ex: Pizzaria do Vale"
|
||||
className="ui-input w-full"
|
||||
required
|
||||
@@ -162,9 +204,9 @@ export function SponsorsManager({ groupId, sponsors }: SponsorsManagerProps) {
|
||||
{isPending ? (
|
||||
<Loader2 className="w-4 h-4 animate-spin" />
|
||||
) : (
|
||||
<Plus className="w-4 h-4 mr-2" />
|
||||
editingSponsor ? <Pencil className="w-4 h-4 mr-2" /> : <Plus className="w-4 h-4 mr-2" />
|
||||
)}
|
||||
Adicionar
|
||||
{editingSponsor ? 'Salvar' : 'Adicionar'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
262
src/components/TeamsManager.tsx
Normal file
262
src/components/TeamsManager.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
467
src/components/VotingFlow.tsx
Normal file
467
src/components/VotingFlow.tsx
Normal 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">
|
||||
JÁ 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
|
||||
}
|
||||
131
src/components/VotingSettings.tsx
Normal file
131
src/components/VotingSettings.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
@@ -6,6 +6,7 @@
|
||||
interface RenderData {
|
||||
groupName: string;
|
||||
logoUrl?: string;
|
||||
shirtUrl?: string;
|
||||
teamName: string;
|
||||
teamColor: string;
|
||||
day: string;
|
||||
@@ -82,6 +83,33 @@ const removeWhiteBackground = (img: HTMLImageElement): HTMLCanvasElement => {
|
||||
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
|
||||
let lastRenderTimestamp: Record<string, number> = {};
|
||||
|
||||
@@ -89,6 +117,13 @@ export async function renderMatchCard(canvas: HTMLCanvasElement, data: RenderDat
|
||||
const ctx = canvas.getContext('2d');
|
||||
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 currentRenderTime = Date.now();
|
||||
lastRenderTimestamp[canvasId] = currentRenderTime;
|
||||
@@ -98,10 +133,26 @@ export async function renderMatchCard(canvas: HTMLCanvasElement, data: RenderDat
|
||||
canvas.width = W;
|
||||
canvas.height = H;
|
||||
|
||||
// --- 1. CLEAN DARK BACKGROUND ---
|
||||
ctx.fillStyle = '#050505';
|
||||
// --- 1. PREMIUM WHITE THEME DEFAULT ---
|
||||
// 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);
|
||||
|
||||
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 ---
|
||||
let logoImg: 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;
|
||||
|
||||
// --- JERSEY BACKGROUND ELEMENT (Using cleaned logo) ---
|
||||
drawJersey(ctx, W - 450, H * 0.4, 800, data.teamColor, cleanedLogo);
|
||||
// --- JERSEY BACKGROUND ELEMENT ---
|
||||
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) ---
|
||||
const margin = 80;
|
||||
@@ -138,20 +200,41 @@ export async function renderMatchCard(canvas: HTMLCanvasElement, data: RenderDat
|
||||
ctx.textAlign = 'left';
|
||||
ctx.textBaseline = 'top';
|
||||
|
||||
ctx.fillStyle = data.teamColor;
|
||||
ctx.font = 'italic 900 80px Inter, sans-serif';
|
||||
ctx.fillText(data.teamName.toUpperCase(), textX, headerY + 10);
|
||||
const teamLum = getLuminance(data.teamColor);
|
||||
const adaptiveColor = getAdaptiveColor(data.teamColor);
|
||||
|
||||
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.letterSpacing = '8px';
|
||||
(ctx as any).letterSpacing = '8px';
|
||||
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.letterSpacing = '5px';
|
||||
(ctx as any).letterSpacing = '5px';
|
||||
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';
|
||||
|
||||
// --- 4. THE LINEUP (ELITE INLINE) ---
|
||||
@@ -161,30 +244,47 @@ export async function renderMatchCard(canvas: HTMLCanvasElement, data: RenderDat
|
||||
ctx.textBaseline = 'middle';
|
||||
|
||||
// Thin accent line
|
||||
ctx.strokeStyle = 'rgba(255,255,255,0.15)';
|
||||
ctx.strokeStyle = accentLineColor;
|
||||
ctx.lineWidth = 1;
|
||||
ctx.beginPath();
|
||||
ctx.moveTo(margin, 385);
|
||||
ctx.lineTo(margin + 120, 385);
|
||||
ctx.stroke();
|
||||
|
||||
ctx.fillStyle = '#fff';
|
||||
ctx.fillStyle = baseTextColor;
|
||||
ctx.font = 'italic 900 42px Inter, sans-serif';
|
||||
ctx.letterSpacing = '18px';
|
||||
ctx.fillText('ESCALAÇÃO', margin + 180, 385);
|
||||
|
||||
// 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.restore();
|
||||
|
||||
let listY = 460;
|
||||
const itemH = 130;
|
||||
const spacing = 32;
|
||||
const footerTop = H - 460; // Increased gap significantly for "respiro"
|
||||
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;
|
||||
|
||||
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.translate(margin, listY);
|
||||
@@ -194,7 +294,7 @@ export async function renderMatchCard(canvas: HTMLCanvasElement, data: RenderDat
|
||||
ctx.beginPath();
|
||||
ctx.moveTo(0, 0); ctx.lineTo(cardW - rowSlant, 0);
|
||||
ctx.lineTo(cardW, itemH); ctx.lineTo(rowSlant, itemH); ctx.closePath();
|
||||
ctx.fillStyle = 'rgba(255, 255, 255, 0.035)';
|
||||
ctx.fillStyle = beamColor;
|
||||
ctx.fill();
|
||||
|
||||
// B. Slanted Color Accent
|
||||
@@ -205,7 +305,7 @@ export async function renderMatchCard(canvas: HTMLCanvasElement, data: RenderDat
|
||||
ctx.lineTo(cardW, itemH);
|
||||
ctx.lineTo(cardW - accentWidth, itemH);
|
||||
ctx.closePath();
|
||||
ctx.fillStyle = data.teamColor;
|
||||
ctx.fillStyle = adaptiveColor;
|
||||
ctx.fill();
|
||||
|
||||
// --- INLINE FLOW ---
|
||||
@@ -214,31 +314,38 @@ export async function renderMatchCard(canvas: HTMLCanvasElement, data: RenderDat
|
||||
|
||||
// 1. Number (Sleek)
|
||||
if (data.options.showNumbers) {
|
||||
ctx.save();
|
||||
ctx.textAlign = 'left';
|
||||
ctx.fillStyle = data.teamColor;
|
||||
ctx.font = 'italic 900 64px Inter, sans-serif';
|
||||
ctx.fillStyle = adaptiveColor;
|
||||
|
||||
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');
|
||||
ctx.fillText(n, flowX, itemH / 2);
|
||||
flowX += 130;
|
||||
ctx.restore();
|
||||
flowX += Math.max(90, Math.floor(130 * (itemH / 130)));
|
||||
}
|
||||
|
||||
// 2. Name
|
||||
const pName = formatName(p.name).toUpperCase();
|
||||
ctx.textAlign = 'left';
|
||||
ctx.fillStyle = '#fff';
|
||||
ctx.font = 'italic 900 50px Inter, sans-serif';
|
||||
ctx.fillStyle = baseTextColor;
|
||||
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);
|
||||
const nW = ctx.measureText(pName).width;
|
||||
flowX += nW + 50;
|
||||
flowX += nW + 40;
|
||||
|
||||
// 3. Level Stars (Actual Stars)
|
||||
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++) {
|
||||
ctx.fillStyle = s < p.level ? data.teamColor : 'rgba(255,255,255,0.06)';
|
||||
drawStar(ctx, flowX + (s * 40), itemH / 2, 12, 5, 5);
|
||||
ctx.fillStyle = s < p.level ? adaptiveColor : (isWhiteCard ? 'rgba(0,0,0,0.06)' : 'rgba(255,255,255,0.06)');
|
||||
drawStar(ctx, flowX + (s * starSpacing), itemH / 2, starSize, 5, starSize * 0.4);
|
||||
ctx.fill();
|
||||
}
|
||||
flowX += 200;
|
||||
flowX += (starSpacing * 5);
|
||||
}
|
||||
|
||||
// 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 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 th = 38;
|
||||
const th = Math.floor(38 * (itemH / 130));
|
||||
|
||||
ctx.fillStyle = posC;
|
||||
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.stroke();
|
||||
|
||||
ctx.fillStyle = '#fff';
|
||||
ctx.fillStyle = isWhiteCard ? '#000' : '#fff';
|
||||
ctx.textAlign = 'center';
|
||||
ctx.fillText(posT, flowX + (tw / 2), itemH / 2 + 1);
|
||||
}
|
||||
@@ -269,12 +377,13 @@ export async function renderMatchCard(canvas: HTMLCanvasElement, data: RenderDat
|
||||
|
||||
// --- 5. FOOTER (SPONSORS) ---
|
||||
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.fillStyle = 'rgba(255,255,255,0.2)';
|
||||
ctx.fillStyle = mutedTextColor;
|
||||
ctx.font = '900 24px Inter, sans-serif';
|
||||
ctx.letterSpacing = '12px';
|
||||
(ctx as any).letterSpacing = '12px';
|
||||
ctx.fillText('PATROCINADORES', W / 2, footerY);
|
||||
(ctx as any).letterSpacing = '0px';
|
||||
|
||||
footerY += 80;
|
||||
const spW = 320;
|
||||
@@ -339,36 +448,58 @@ function drawJersey(ctx: CanvasRenderingContext2D, x: number, y: number, w: numb
|
||||
ctx.beginPath();
|
||||
ctx.strokeStyle = color;
|
||||
ctx.lineWidth = 14;
|
||||
ctx.globalAlpha = 0.08;
|
||||
ctx.globalAlpha = 0.08; // Increased just a bit for visibility
|
||||
|
||||
// LONG SLEEVE JERSEY PATH (Pro Fit)
|
||||
ctx.moveTo(w * 0.25, 0);
|
||||
ctx.lineTo(w * 0.75, 0);
|
||||
ctx.lineTo(w * 1.3, w * 0.4); // Extended sleeve top right
|
||||
ctx.lineTo(w * 1.1, w * 0.65); // Extended sleeve bottom right
|
||||
ctx.lineTo(w * 0.85, w * 0.45); // Armpit right
|
||||
ctx.lineTo(w * 0.85, h);
|
||||
ctx.lineTo(w * 0.15, h);
|
||||
ctx.lineTo(w * 0.15, w * 0.45);
|
||||
ctx.lineTo(-w * 0.1, w * 0.65); // Extended sleeve bottom left
|
||||
ctx.lineTo(-w * 0.3, w * 0.4); // Extended sleeve top left
|
||||
ctx.lineTo(w * 0.25, 0);
|
||||
// CENTERED LARGE JERSEY - Adjusting coordinates to center around (0,0)
|
||||
ctx.setLineDash([20, 10]);
|
||||
|
||||
const offsetW = w * 0.5;
|
||||
const offsetH = h * 0.4;
|
||||
|
||||
// LONG SLEEVE JERSEY PATH (Centered)
|
||||
ctx.moveTo(w * 0.25 - offsetW, 0 - offsetH);
|
||||
ctx.lineTo(w * 0.75 - offsetW, 0 - offsetH);
|
||||
ctx.lineTo(w * 1.3 - offsetW, w * 0.4 - offsetH);
|
||||
ctx.lineTo(w * 1.1 - offsetW, w * 0.65 - offsetH);
|
||||
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();
|
||||
|
||||
// Neck detail
|
||||
// Neck detail (Centered)
|
||||
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();
|
||||
|
||||
// THE LOGO ON THE JERSEY (Refined Watermark)
|
||||
if (logoImg) {
|
||||
ctx.save();
|
||||
ctx.globalAlpha = 0.04;
|
||||
const iconSize = w * 0.35;
|
||||
ctx.drawImage(logoImg, w * 0.5 - iconSize / 2, w * 0.25, iconSize, iconSize);
|
||||
ctx.globalAlpha = 0.07;
|
||||
const iconSize = w * 0.4;
|
||||
ctx.drawImage(logoImg, w * 0.5 - offsetW - iconSize / 2, w * 0.3 - offsetH, iconSize, iconSize);
|
||||
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();
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user