feat: implement financial recurrence, privacy settings, and premium DateTimePicker overhaul

This commit is contained in:
Erik Silva
2026-01-25 20:17:36 -03:00
parent 416bd83ea7
commit e4935941fc
43 changed files with 3238 additions and 765 deletions

7
package-lock.json generated
View File

@@ -17,7 +17,6 @@
"clsx": "^2.1.1",
"cookies-next": "^6.1.1",
"framer-motion": "^12.26.2",
"html-to-image": "^1.11.13",
"lucide-react": "^0.562.0",
"minio": "^8.0.6",
"next": "16.1.3",
@@ -4463,12 +4462,6 @@
"hermes-estree": "0.25.1"
}
},
"node_modules/html-to-image": {
"version": "1.11.13",
"resolved": "https://registry.npmjs.org/html-to-image/-/html-to-image-1.11.13.tgz",
"integrity": "sha512-cuOPoI7WApyhBElTTb9oqsawRvZ0rHhaHwghRLlTuffoD1B2aDemlCruLeZrUIIdvG7gs9xeELEPm6PhuASqrg==",
"license": "MIT"
},
"node_modules/iceberg-js": {
"version": "0.8.1",
"resolved": "https://registry.npmjs.org/iceberg-js/-/iceberg-js-0.8.1.tgz",

View File

@@ -18,7 +18,6 @@
"clsx": "^2.1.1",
"cookies-next": "^6.1.1",
"framer-motion": "^12.26.2",
"html-to-image": "^1.11.13",
"lucide-react": "^0.562.0",
"minio": "^8.0.6",
"next": "16.1.3",
@@ -44,4 +43,4 @@
"prisma": {
"seed": "npx tsx prisma/seed.ts"
}
}
}

View File

@@ -0,0 +1,118 @@
/*
Warnings:
- A unique constraint covering the columns `[slug]` on the table `Group` will be added. If there are existing duplicate values, this will fail.
- The required column `slug` was added to the `Group` table with a prisma-level default value. This is not possible if the table is not empty. Please add this column as optional, then populate it before making it required.
*/
-- CreateEnum
CREATE TYPE "AdminRole" AS ENUM ('SUPER_ADMIN', 'ADMIN');
-- CreateEnum
CREATE TYPE "GroupStatus" AS ENUM ('ACTIVE', 'FROZEN');
-- CreateEnum
CREATE TYPE "Plan" AS ENUM ('FREE', 'BASIC', 'PRO');
-- CreateEnum
CREATE TYPE "FinancialEventType" AS ENUM ('MONTHLY_FEE', 'EXTRA_EVENT', 'CONTRIBUTION');
-- CreateEnum
CREATE TYPE "FinancialEventStatus" AS ENUM ('OPEN', 'CLOSED', 'CANCELED');
-- CreateEnum
CREATE TYPE "PaymentStatus" AS ENUM ('PENDING', 'PAID', 'WAIVED');
-- AlterTable
ALTER TABLE "Group" ADD COLUMN "pixKey" TEXT,
ADD COLUMN "pixName" TEXT,
ADD COLUMN "plan" "Plan" NOT NULL DEFAULT 'FREE',
ADD COLUMN "planExpiresAt" TIMESTAMP(3),
ADD COLUMN "slug" TEXT NOT NULL,
ADD COLUMN "status" "GroupStatus" NOT NULL DEFAULT 'ACTIVE';
-- AlterTable
ALTER TABLE "Match" ADD COLUMN "arenaId" TEXT;
-- AlterTable
ALTER TABLE "Player" ADD COLUMN "isLeader" BOOLEAN NOT NULL DEFAULT false;
-- CreateTable
CREATE TABLE "Admin" (
"id" TEXT NOT NULL,
"email" TEXT NOT NULL,
"password" TEXT NOT NULL,
"name" TEXT NOT NULL,
"role" "AdminRole" NOT NULL DEFAULT 'ADMIN',
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "Admin_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "Arena" (
"id" TEXT NOT NULL,
"name" TEXT NOT NULL,
"address" TEXT,
"groupId" TEXT NOT NULL,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "Arena_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "FinancialEvent" (
"id" TEXT NOT NULL,
"title" TEXT NOT NULL,
"description" TEXT,
"totalAmount" DOUBLE PRECISION,
"pricePerPerson" DOUBLE PRECISION,
"dueDate" TIMESTAMP(3) NOT NULL,
"type" "FinancialEventType" NOT NULL DEFAULT 'MONTHLY_FEE',
"status" "FinancialEventStatus" NOT NULL DEFAULT 'OPEN',
"groupId" TEXT NOT NULL,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"isRecurring" BOOLEAN NOT NULL DEFAULT false,
"recurrenceInterval" TEXT,
"recurrenceEndDate" TIMESTAMP(3),
CONSTRAINT "FinancialEvent_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "Payment" (
"id" TEXT NOT NULL,
"financialEventId" TEXT NOT NULL,
"playerId" TEXT NOT NULL,
"amount" DOUBLE PRECISION NOT NULL,
"status" "PaymentStatus" NOT NULL DEFAULT 'PENDING',
"paidAt" TIMESTAMP(3),
"method" TEXT,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "Payment_pkey" PRIMARY KEY ("id")
);
-- CreateIndex
CREATE UNIQUE INDEX "Admin_email_key" ON "Admin"("email");
-- CreateIndex
CREATE UNIQUE INDEX "Payment_financialEventId_playerId_key" ON "Payment"("financialEventId", "playerId");
-- CreateIndex
CREATE UNIQUE INDEX "Group_slug_key" ON "Group"("slug");
-- AddForeignKey
ALTER TABLE "Match" ADD CONSTRAINT "Match_arenaId_fkey" FOREIGN KEY ("arenaId") REFERENCES "Arena"("id") ON DELETE SET NULL ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "Arena" ADD CONSTRAINT "Arena_groupId_fkey" FOREIGN KEY ("groupId") REFERENCES "Group"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "FinancialEvent" ADD CONSTRAINT "FinancialEvent_groupId_fkey" FOREIGN KEY ("groupId") REFERENCES "Group"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "Payment" ADD CONSTRAINT "Payment_financialEventId_fkey" FOREIGN KEY ("financialEventId") REFERENCES "FinancialEvent"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "Payment" ADD CONSTRAINT "Payment_playerId_fkey" FOREIGN KEY ("playerId") REFERENCES "Player"("id") ON DELETE RESTRICT ON UPDATE CASCADE;

View File

@@ -44,6 +44,7 @@ model Group {
pixKey String?
pixName String?
status GroupStatus @default(ACTIVE)
showTotalInPublic Boolean @default(true)
}
enum GroupStatus {
@@ -63,6 +64,7 @@ model Player {
number Int?
position String @default("MEI")
level Int @default(3)
isLeader Boolean @default(false)
groupId String
createdAt DateTime @default(now())
group Group @relation(fields: [groupId], references: [id])
@@ -167,6 +169,7 @@ model FinancialEvent {
isRecurring Boolean @default(false)
recurrenceInterval String? // 'MONTHLY', 'WEEKLY'
recurrenceEndDate DateTime?
showTotalInPublic Boolean @default(true)
}
model Payment {

View File

@@ -19,7 +19,7 @@ async function main() {
})
// 2. Criar Pelada de Exemplo
await prisma.group.upsert({
const group = await prisma.group.upsert({
where: { email: 'erik@idealpages.com.br' },
update: {},
create: {
@@ -34,6 +34,55 @@ async function main() {
}
})
// 3. Criar Jogador Líder (Rei)
await prisma.player.upsert({
where: { number_groupId: { number: 10, groupId: group.id } },
update: {},
create: {
name: 'Erik Ideal',
number: 10,
position: 'MEI',
level: 5,
// @ts-ignore
isLeader: true,
groupId: group.id
}
})
// 4. Adicionar alguns jogadores para teste
const players = [
{ name: 'Ricardo Rocha', number: 4, position: 'ZAG', level: 4 },
{ name: 'Bebeto', number: 7, position: 'ATA', level: 5 },
{ name: 'Dunga', number: 8, position: 'VOL', level: 4 },
{ name: 'Taffarel', number: 1, position: 'GOL', level: 5 },
]
for (const p of players) {
await prisma.player.upsert({
where: { number_groupId: { number: p.number, groupId: group.id } },
update: {},
create: {
...p,
groupId: group.id
}
})
}
// 5. Criar uma partida agendada
const nextWednesday = new Date()
nextWednesday.setDate(nextWednesday.getDate() + ((7 - nextWednesday.getDay() + 3) % 7 || 7))
nextWednesday.setHours(20, 0, 0, 0)
await prisma.match.create({
data: {
groupId: group.id,
date: nextWednesday,
location: 'Arena Central',
maxPlayers: 20,
status: 'SCHEDULED'
}
})
console.log('Seed concluído com sucesso!')
}

View File

@@ -4,6 +4,18 @@ import { prisma } from '@/lib/prisma'
import { revalidatePath } from 'next/cache'
export async function confirmAttendance(matchId: string, playerId: string) {
const match = await prisma.match.findUnique({
where: { id: matchId }
})
if (!match) {
throw new Error('MATCH_NOT_FOUND')
}
if (match.status === 'COMPLETED') {
throw new Error('MATCH_ALREADY_COMPLETED')
}
// @ts-ignore
await prisma.attendance.upsert({
where: {
@@ -23,9 +35,18 @@ export async function confirmAttendance(matchId: string, playerId: string) {
})
revalidatePath(`/match/${matchId}/confirmacao`)
revalidatePath('/agenda')
}
export async function cancelAttendance(matchId: string, playerId: string) {
const match = await prisma.match.findUnique({
where: { id: matchId }
})
if (!match) {
throw new Error('MATCH_NOT_FOUND')
}
// @ts-ignore
await prisma.attendance.update({
where: {
@@ -40,6 +61,7 @@ export async function cancelAttendance(matchId: string, playerId: string) {
})
revalidatePath(`/match/${matchId}/confirmacao`)
revalidatePath('/agenda')
}
export async function getMatchWithAttendance(matchId: string) {

View File

@@ -55,19 +55,17 @@ export async function createFinancialEvent(data: {
dueDate: new Date(currentDate),
type: data.type,
groupId: group.id,
/*
Temporarily disabled to prevent crash if Prisma Client isn't updated
isRecurring: data.isRecurring || false,
recurrenceInterval: data.isRecurring ? 'MONTHLY' : null,
recurrenceEndDate: data.isRecurring ? endDate : null,
*/
showTotalInPublic: group.showTotalInPublic, // Use group default
payments: {
create: data.selectedPlayerIds.map(playerId => {
let amount = 0
if (data.pricePerPerson) {
amount = data.pricePerPerson
} else if (data.totalAmount && data.selectedPlayerIds.length > 0) {
amount = data.totalAmount / data.selectedPlayerIds.length
amount = Math.round((data.totalAmount / data.selectedPlayerIds.length) * 100) / 100
}
return {
@@ -236,3 +234,29 @@ export async function updatePixKey(pixKey: string) {
revalidatePath('/dashboard/settings')
return { success: true }
}
export async function updateFinancialSettings(data: { showTotalInPublic: boolean }) {
const group = await getActiveGroup()
if (!group) return { success: false, error: 'Unauthorized' }
await prisma.group.update({
where: { id: group.id },
data: { showTotalInPublic: data.showTotalInPublic }
})
revalidatePath('/dashboard/financial')
return { success: true }
}
export async function toggleEventPrivacy(eventId: string, showTotal: boolean) {
const group = await getActiveGroup()
if (!group) return { success: false, error: 'Unauthorized' }
await prisma.financialEvent.update({
where: { id: eventId, groupId: group.id },
data: { showTotalInPublic: showTotal }
})
revalidatePath('/dashboard/financial')
return { success: true }
}

View File

@@ -115,9 +115,19 @@ export async function createScheduledMatch(
})
createdMatches.push(match)
if (isRecurring && recurrenceInterval === 'WEEKLY') {
if (isRecurring) {
const nextDate = new Date(currentDate);
nextDate.setDate(nextDate.getDate() + 7);
if (recurrenceInterval === 'DAILY') {
nextDate.setDate(nextDate.getDate() + 1);
} else if (recurrenceInterval === 'WEEKLY') {
nextDate.setDate(nextDate.getDate() + 7);
} else if (recurrenceInterval === 'MONTHLY') {
nextDate.setMonth(nextDate.getMonth() + 1);
} else if (recurrenceInterval === 'YEARLY') {
nextDate.setFullYear(nextDate.getFullYear() + 1);
} else {
break;
}
currentDate = nextDate;
} else {
break // Exit if not recurring
@@ -134,12 +144,27 @@ export async function updateMatchStatus(matchId: string, status: MatchStatus) {
data: { status }
})
// 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 && match.recurrenceInterval === 'WEEKLY') {
if (status === 'COMPLETED' && match.isRecurring) {
const nextDate = new Date(match.date)
nextDate.setDate(nextDate.getDate() + 7)
// @ts-ignore
const interval = match.recurrenceInterval
if (interval === 'DAILY') {
nextDate.setDate(nextDate.getDate() + 1)
} else if (interval === 'WEEKLY') {
nextDate.setDate(nextDate.getDate() + 7)
} else if (interval === 'MONTHLY') {
nextDate.setMonth(nextDate.getMonth() + 1)
} else if (interval === 'YEARLY') {
nextDate.setFullYear(nextDate.getFullYear() + 1)
} else {
return
}
// Check if we passed the end date
// @ts-ignore
@@ -171,7 +196,7 @@ export async function updateMatchStatus(matchId: string, status: MatchStatus) {
// @ts-ignore
isRecurring: true,
// @ts-ignore
recurrenceInterval: 'WEEKLY',
recurrenceInterval: match.recurrenceInterval,
// @ts-ignore
recurrenceEndDate: match.recurrenceEndDate
}
@@ -228,3 +253,38 @@ export async function deleteMatches(matchIds: string[]) {
])
revalidatePath('/dashboard/matches')
}
export async function getPublicScheduledMatches(slug: string) {
const group = await prisma.group.findUnique({
where: { slug: slug.toLowerCase() }
})
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({
where: {
groupId: group.id,
status: 'SCHEDULED',
date: {
gte: new Date(new Date().setHours(0, 0, 0, 0))
}
},
include: {
arena: true,
_count: {
select: {
attendances: {
// @ts-ignore
where: { status: 'CONFIRMED' }
}
}
}
},
orderBy: {
date: 'asc'
}
})
return { group, matches }
}

View File

@@ -44,3 +44,13 @@ export async function deletePlayers(ids: string[]) {
})
revalidatePath('/')
}
export async function updatePlayer(id: string, data: { name?: string, level?: number, number?: number | null, position?: string }) {
const player = await prisma.player.update({
where: { id },
data
})
revalidatePath('/')
revalidatePath('/dashboard/profile')
return player
}

41
src/actions/sponsor.ts Normal file
View File

@@ -0,0 +1,41 @@
'use server'
import { prisma } from '@/lib/prisma'
import { revalidatePath } from 'next/cache'
export async function getSponsors(groupId: string) {
return await prisma.sponsor.findMany({
where: { groupId },
orderBy: { createdAt: 'desc' }
})
}
import { uploadFile } from '@/lib/upload'
export async function createSponsor(formData: FormData) {
const groupId = formData.get('groupId') as string
const name = formData.get('name') as string
const logoFile = formData.get('logo') as File
let logoUrl = null
if (logoFile && logoFile.size > 0) {
logoUrl = await uploadFile(logoFile)
}
const sponsor = await prisma.sponsor.create({
data: {
groupId,
name,
logoUrl
}
})
revalidatePath('/dashboard/settings')
return sponsor
}
export async function deleteSponsor(id: string) {
await prisma.sponsor.delete({
where: { id }
})
revalidatePath('/dashboard/settings')
}

View File

@@ -19,7 +19,7 @@ export default async function FinancialPage() {
</div>
</header>
<FinancialDashboard events={events} players={group.players} />
<FinancialDashboard events={events} players={group.players} group={group} />
</div>
)
}

View File

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

View File

@@ -33,6 +33,7 @@ export default async function MatchesPage() {
<MatchHistory
matches={group?.matches || []}
players={group?.players || []}
groupName={group?.name}
/>
</section>
</div>

View File

@@ -1,15 +1,16 @@
'use client'
import React, { useState, useMemo } from 'react'
import { Calendar, MapPin, Users, ArrowRight, Trophy } from 'lucide-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'
export default function ScheduleMatchPage({ params }: { params: { id: string } }) {
// In a real scenario we'd get the groupId from the context or parent
// For this demonstration, we'll assume the group is linked via the active group auth
export default function ScheduleMatchPage() {
const router = useRouter()
const [date, setDate] = useState('')
const [location, setLocation] = useState('')
@@ -17,6 +18,7 @@ export default function ScheduleMatchPage({ params }: { params: { id: string } }
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)
@@ -24,7 +26,6 @@ export default function ScheduleMatchPage({ params }: { params: { id: string } }
getArenas().then(setArenas)
}, [])
// Calculate preview dates
const previewDates = useMemo(() => {
if (!date || !isRecurring) return []
@@ -32,202 +33,293 @@ export default function ScheduleMatchPage({ params }: { params: { id: string } }
const startDate = new Date(date)
let currentDate = new Date(startDate)
// Start from next week for preview, as the first one is the main event
currentDate.setDate(currentDate.getDate() + 7)
// 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) {
// Append explicit time to ensure it parses as local time end-of-day
endDate = new Date(`${recurrenceEndDate}T23:59:59`)
} else {
// Preview next 4 occurrences if infinite
endDate = new Date(startDate.getTime() + 4 * 7 * 24 * 60 * 60 * 1000)
// 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.setDate(currentDate.getDate() + 7)
// Limit preview to avoiding infinite loops or too many items
if (dates.length > 20) break
currentDate = advanceDate(currentDate)
if (dates.length > 10) break
}
return dates
}, [date, isRecurring, recurrenceEndDate])
}, [date, isRecurring, recurrenceInterval, recurrenceEndDate])
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault()
if (!date) return
setIsSubmitting(true)
try {
// We need the group ID. In our app structure, we usually get it from getActiveGroup.
// Since this is a client component, we'll use a hack or assume the server action handles it
// Actually, getActiveGroup uses cookies, so we don't strictly need to pass it if we change the action
// Let's assume we need to pass it for now, but I'll fetch it from the API if needed.
// To be safe, I'll update the action to fetch groupId from cookies if not provided
await createScheduledMatch(
'',
date,
location,
parseInt(maxPlayers),
parseInt(maxPlayers) || 0,
isRecurring,
'WEEKLY',
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-xl mx-auto space-y-8 animate-in fade-in slide-in-from-bottom-4 duration-700">
<header className="text-center space-y-2">
<div className="w-12 h-12 bg-primary/10 rounded-xl flex items-center justify-center mx-auto mb-4 border border-primary/20">
<Calendar className="w-6 h-6 text-primary" />
<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>
<h1 className="text-3xl font-bold tracking-tight">Agendar Evento</h1>
<p className="text-muted text-sm">Crie um link de confirmação para seus atletas.</p>
</header>
<form onSubmit={handleSubmit} className="ui-card p-8 space-y-6">
<div className="space-y-4">
<div className="ui-form-field">
<label className="text-label ml-1">Data e Hora</label>
<div className="relative">
<Calendar className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-muted z-10" />
<input
required
type="datetime-local"
value={date}
onChange={(e) => setDate(e.target.value)}
className="ui-input w-full pl-10 h-12 [color-scheme:dark]"
/>
<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>
</div>
<div className="ui-form-field">
<label className="text-label ml-1">Local / Arena</label>
<div className="space-y-3">
{arenas.length > 0 && (
<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 h-12 bg-surface-raised"
>
<option value="" className="text-muted">Selecione um local cadastrado...</option>
{arenas.map(a => (
<option key={a.id} value={a.id}>{a.name}</option>
))}
</select>
)}
<div className="relative">
<MapPin className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-muted" />
<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 ? "Detalhes do local (opcional)" : "Ex: Arena Central..."}
placeholder={selectedArenaId ? "Complemento do local (opcional)" : "Ou digite um local personalizado..."}
value={location}
onChange={(e) => setLocation(e.target.value)}
className="ui-input w-full pl-10"
className="ui-input w-full h-12 bg-surface/50 text-sm"
/>
</div>
</div>
</div>
<div className="ui-form-field">
<label className="text-label ml-1">Limite de Jogadores (Opcional)</label>
<div className="relative">
<Users className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-muted" />
<input
type="number"
placeholder="Ex: 24"
value={maxPlayers}
onChange={(e) => setMaxPlayers(e.target.value)}
className="ui-input w-full pl-10"
/>
</div>
</div>
<div className="bg-surface/50 border border-border p-4 rounded-xl space-y-4">
<div className="flex items-start gap-4">
<input
type="checkbox"
checked={isRecurring}
onChange={(e) => setIsRecurring(e.target.checked)}
className="w-5 h-5 rounded border-border text-primary bg-background mt-0.5"
/>
<div className="space-y-1">
<label className="text-sm font-bold block select-none cursor-pointer" onClick={() => setIsRecurring(!isRecurring)}>Repetir Semanalmente</label>
<p className="text-xs text-muted">
Ao marcar esta opção, novos eventos serão criados automaticamente toda semana.
</p>
</div>
</div>
{isRecurring && (
<div className="pl-9 pt-2 animate-in fade-in slide-in-from-top-2">
<label className="text-label ml-0.5 mb-2 block">Repetir até (Opcional)</label>
<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="date"
value={recurrenceEndDate}
onChange={(e) => setRecurrenceEndDate(e.target.value)}
className="ui-input w-full h-12 [color-scheme:dark]"
min={date ? date.split('T')[0] : undefined}
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"
/>
<p className="text-[10px] text-muted mt-1.5 mb-4">
Deixe em branco para repetir indefinidamente.
</p>
</div>
</div>
</section>
{previewDates.length > 0 && (
<div className="bg-background/50 rounded-lg p-3 border border-border/50">
<p className="text-[10px] font-bold text-muted uppercase tracking-wider mb-2">Próximas datas previstas:</p>
<div className="grid grid-cols-2 gap-2">
{previewDates.map((d, i) => (
<div key={i} className="text-xs text-foreground bg-surface-raised px-2 py-1 rounded flex items-center gap-2">
<div className="w-1 h-1 rounded-full bg-primary"></div>
{d.toLocaleDateString('pt-BR', { day: '2-digit', month: '2-digit', year: 'numeric' })}
</div>
))}
{!recurrenceEndDate && (
<div className="text-[10px] text-muted italic px-2 py-1 col-span-2">
...e assim por diante.
</div>
)}
</div>
</div>
<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="ui-form-field">
<label className="text-label ml-0.5 mb-2 block">Data Limite de Repetição</label>
<div className="relative group">
<Calendar 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" />
<input
type="date"
value={recurrenceEndDate}
onChange={(e) => setRecurrenceEndDate(e.target.value)}
className="ui-input w-full pl-10 h-12 [color-scheme:dark] text-sm bg-surface"
min={date ? date.split('T')[0] : undefined}
/>
</div>
<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>
</div>
<div className="pt-4 border-t border-border space-y-4">
<button
type="submit"
disabled={isSubmitting}
className="ui-button w-full h-12 text-sm font-bold uppercase tracking-widest"
>
{isSubmitting ? 'Agendando...' : 'Criar Link de Confirmação'}
{!isSubmitting && <ArrowRight className="w-4 h-4" />}
</button>
<div className="flex items-center gap-3 p-4 bg-primary/5 border border-primary/10 rounded-lg">
<Trophy className="w-5 h-5 text-primary flex-shrink-0" />
<p className="text-[11px] text-muted leading-relaxed">
Ao criar o evento, um link exclusivo será gerado. Você poderá acompanhar quem confirmou presença em tempo real e realizar o sorteio depois.
</p>
</div>
</div>
</form>
</aside>
</div>
</div>
)
}

View File

@@ -0,0 +1,26 @@
import { getActiveGroup } from '@/lib/auth'
import { redirect } from 'next/navigation'
import { ProfileClient } from '@/components/ProfileClient'
export default async function ProfilePage() {
const group = await getActiveGroup() as any
if (!group) redirect('/login')
// Find the leader player (the one who created the group)
const leader = group.players.find((p: any) => p.isLeader) || group.players[0]
return (
<div className="max-w-5xl mx-auto space-y-8 py-4">
<header>
<div className="flex items-center gap-2 mb-1">
<span className="w-8 h-[2px] bg-primary rounded-full" />
<span className="text-[10px] font-black text-primary uppercase tracking-[0.4em]">Configurações Pessoais</span>
</div>
<h2 className="text-3xl font-black tracking-tighter uppercase">Meu <span className="text-primary">Perfil</span></h2>
<p className="text-muted text-xs font-bold uppercase tracking-widest mt-1">Gerencie sua identidade de atleta e assinatura.</p>
</header>
<ProfileClient group={group} leader={leader} />
</div>
)
}

View File

@@ -1,7 +1,9 @@
import { getActiveGroup } from '@/lib/auth'
import { SettingsForm } from '@/components/SettingsForm'
import { getArenas } from '@/actions/arena'
import { getSponsors } from '@/actions/sponsor'
import { ArenasManager } from '@/components/ArenasManager'
import { SponsorsManager } from '@/components/SponsorsManager'
import { SettingsTabs } from '@/components/SettingsTabs'
export default async function SettingsPage() {
@@ -10,6 +12,7 @@ export default async function SettingsPage() {
if (!group) return null
const arenas = await getArenas()
const sponsors = await getSponsors(group.id)
return (
<div className="max-w-4xl mx-auto space-y-8 pb-12">
@@ -33,6 +36,7 @@ export default async function SettingsPage() {
/>
}
arenas={<ArenasManager arenas={arenas} />}
sponsors={<SponsorsManager groupId={group.id} sponsors={sponsors} />}
/>
</div>
)

View File

@@ -17,7 +17,15 @@ export default async function DashboardLayout({
return (
<div className="flex flex-col md:flex-row min-h-screen bg-background text-foreground">
<ThemeWrapper primaryColor={group.primaryColor} />
{/* Inject colors server-side to prevent flash of unstyled content (FOUC) */}
<style dangerouslySetInnerHTML={{
__html: `
:root {
--primary-color: ${group.primaryColor || '#10b981'};
--secondary-color: ${group.secondaryColor || '#000000'};
}
`}} />
<MobileNav group={group} />
<Sidebar group={group} />
<main className="flex-1 p-4 md:p-8 overflow-y-auto w-full max-w-[100vw] overflow-x-hidden">

View File

@@ -319,8 +319,8 @@ export default function LandingPage() {
<a
href="/create"
className={`block w-full py-3 text-center font-medium rounded-xl transition-colors ${plan.popular
? 'bg-blue-500 hover:bg-blue-600 text-white'
: 'bg-zinc-800 hover:bg-zinc-700 text-white'
? 'bg-blue-500 hover:bg-blue-600 text-white'
: 'bg-zinc-800 hover:bg-zinc-700 text-white'
}`}
>
{plan.cta}
@@ -372,6 +372,7 @@ export default function LandingPage() {
<div className="flex items-center gap-6 text-sm text-zinc-500">
<a href="/login" className="hover:text-white transition-colors">Entrar</a>
<a href="/create" className="hover:text-white transition-colors">Criar Pelada</a>
<a href="/atualizacoes" className="hover:text-white transition-colors">Atualizações</a>
<a href="http://admin.localhost" className="hover:text-white transition-colors">Admin</a>
</div>

View File

@@ -74,6 +74,7 @@ export async function createGroup(formData: FormData) {
const primaryColor = formData.get('primaryColor') as string
const secondaryColor = formData.get('secondaryColor') as string
const logoFile = formData.get('logo') as File | null
const ownerName = formData.get('ownerName') as string
// Validations
if (!name || name.length < 3) {
@@ -129,6 +130,15 @@ export async function createGroup(formData: FormData) {
primaryColor: primaryColor || '#10B981',
secondaryColor: secondaryColor || '#000000',
logoUrl,
players: {
create: {
name: ownerName || 'Organizador',
// @ts-ignore
isLeader: true,
level: 5,
position: 'MEI'
}
}
}
})

136
src/app/agenda/page.tsx Normal file
View File

@@ -0,0 +1,136 @@
import { headers } from 'next/headers'
import { getPublicScheduledMatches } from '@/actions/match'
import { Trophy, Calendar, MapPin, Users, ChevronRight, Clock, ArrowLeft } from 'lucide-react'
import Link from 'next/link'
import { ThemeWrapper } from '@/components/ThemeWrapper'
import PeladaNotFound from '../not-found-pelada/page'
export default async function PublicAgendaPage() {
const headerStore = await headers()
const slug = headerStore.get('x-current-slug')
if (!slug || slug === 'localhost') {
return (
<div className="min-h-screen bg-black flex flex-col items-center justify-center p-6 text-center">
<Trophy className="w-12 h-12 text-primary mb-4" />
<h1 className="text-2xl font-bold">Acesse via subdomínio</h1>
<p className="text-muted text-sm mt-2">Esta página é exclusiva para cada pelada.</p>
</div>
)
}
const { group, matches } = await getPublicScheduledMatches(slug)
if (!group) {
return <PeladaNotFound />
}
return (
<div className="min-h-screen bg-black text-white font-sans selection:bg-emerald-500/30 overflow-x-hidden pb-20">
<ThemeWrapper primaryColor={group.primaryColor} />
<div className="max-w-xl mx-auto px-6 py-12 space-y-12">
<header className="space-y-6">
<div className="flex items-center justify-between">
<div className="w-12 h-12 bg-surface-raised rounded-2xl border border-white/5 flex items-center justify-center shadow-xl overflow-hidden">
{group.logoUrl ? (
<img src={group.logoUrl} alt={group.name} className="w-full h-full object-cover" />
) : (
<Trophy className="w-6 h-6 text-primary" />
)}
</div>
<Link href="/" className="text-[10px] font-black uppercase tracking-[0.2em] text-muted hover:text-primary transition-colors">
Área Restrita
</Link>
</div>
<div className="space-y-2">
<span className="text-[10px] font-black text-primary uppercase tracking-[0.4em]">{group.name}</span>
<h1 className="text-4xl font-black tracking-tighter uppercase leading-none">
Próximos <span className="text-primary">Jogos</span>
</h1>
<p className="text-zinc-500 text-[10px] font-bold uppercase tracking-widest pt-1 flex items-center gap-2">
<Calendar className="w-3 h-3" /> Agenda Oficial de Peladas
</p>
</div>
</header>
<div className="space-y-4">
{matches.length === 0 ? (
<div className="py-20 text-center space-y-6 bg-surface-raised/30 rounded-3xl border border-dashed border-border/50">
<div className="w-16 h-16 bg-surface-raised rounded-full flex items-center justify-center mx-auto border border-border">
<Calendar className="w-6 h-6 text-muted" />
</div>
<div className="space-y-1">
<p className="text-sm font-bold uppercase tracking-widest">Nenhuma Pelada Agendada</p>
<p className="text-xs text-muted">Fique atento ao grupo para novas datas.</p>
</div>
</div>
) : (
matches.map((match: any) => (
<Link
key={match.id}
href={`/match/${match.id}/confirmacao`}
className="group block relative"
>
<div className="ui-card p-6 bg-surface-raised/30 hover:bg-surface-raised transition-all border-white/5 hover:border-primary/40 shadow-xl overflow-hidden active:scale-[0.98]">
{/* Glass Highlight */}
<div className="absolute top-0 right-0 w-32 h-32 bg-primary/5 blur-3xl rounded-full -mr-16 -mt-16 group-hover:bg-primary/10 transition-colors" />
<div className="flex items-center gap-6 relative z-10">
{/* Date Box */}
<div className="flex flex-col items-center justify-center w-14 h-14 bg-black rounded-2xl border border-white/5 group-hover:border-primary/20 transition-colors shrink-0">
<span className="text-lg font-black leading-none">{new Date(match.date).getDate()}</span>
<span className="text-[9px] font-black text-primary uppercase mt-1 tracking-tighter">
{new Date(match.date).toLocaleDateString('pt-BR', { month: 'short' }).replace('.', '')}
</span>
</div>
<div className="flex-1 space-y-2 min-w-0">
<div className="flex items-center gap-2">
<h3 className="text-sm font-black uppercase tracking-tight group-hover:text-primary transition-colors truncate">
{match.location || 'Arena TemFut'}
</h3>
<span className="w-1 h-1 rounded-full bg-border shrink-0" />
<div className="flex items-center gap-1 text-[10px] text-muted font-bold uppercase shrink-0">
<Clock className="w-3 h-3" />
{new Date(match.date).toLocaleTimeString('pt-BR', { hour: '2-digit', minute: '2-digit' })}
</div>
</div>
<div className="flex items-center gap-4 text-[10px] text-zinc-500 font-bold uppercase tracking-widest">
<div className="flex items-center gap-1.5">
<Users className="w-3.5 h-3.5 text-primary/60" />
{match._count.attendances} / {match.maxPlayers || '∞'}
</div>
{match.arena && (
<div className="flex items-center gap-1.5 truncate">
<MapPin className="w-3.5 h-3.5 text-primary/60" />
{match.arena.name}
</div>
)}
</div>
</div>
<div className="w-10 h-10 rounded-full bg-white/5 flex items-center justify-center group-hover:bg-primary/20 transition-all shrink-0">
<ChevronRight className="w-5 h-5 text-zinc-600 group-hover:text-primary transition-all group-hover:translate-x-0.5" />
</div>
</div>
</div>
</Link>
))
)}
</div>
<footer className="pt-12 border-t border-white/5 text-center space-y-6 opacity-40">
<p className="text-[9px] font-black uppercase tracking-[0.5em]">Engine v2.1 {group.name}</p>
<div className="flex justify-center gap-4">
<Link href="/" className="text-[10px] font-bold uppercase hover:text-white transition-colors flex items-center gap-1.5">
<ArrowLeft className="w-3 h-3" /> Retornar ao Início
</Link>
</div>
</footer>
</div>
</div>
)
}

View File

@@ -0,0 +1,275 @@
'use client'
import React, { useState, useMemo } from 'react'
import { motion, AnimatePresence } from 'framer-motion'
import { Zap, Bug, Clock, ArrowLeft, Terminal, Layout, ShieldCheck, Palette, Filter, Search, ChevronRight, LayoutGrid, Calendar, Eye, User } from 'lucide-react'
import Link from 'next/link'
import { clsx } from 'clsx'
const UPDATES = [
{
date: '24 Jan, 2026',
time: '18:10',
category: 'atualizacoes',
title: 'Perfil do Líder & Assinatura',
description: 'Implementada página de perfil para administradores, cadastro automático do dono da pelada como jogador líder e gestão de assinatura TemFut.',
icon: User
},
{
date: '24 Jan, 2026',
time: '17:45',
category: 'atualizacoes',
title: 'Agenda Pública de Peladas',
description: 'Nova rota pública (/agenda) que lista todos os eventos agendados, permitindo que atletas visualizem e confirmem presença com facilidade.',
icon: Eye
},
{
date: '24 Jan, 2026',
time: '17:35',
category: 'atualizacoes',
title: 'Novo Calendário Premium',
description: 'Substituição do seletor nativo por um componente customizado de alta fidelidade. Adicionada recorrência mensal e anual.',
icon: Calendar
},
{
date: '24 Jan, 2026',
time: '17:20',
category: 'correcoes',
title: 'Correção de Hydration (Datas)',
description: 'Resolvido erro de sincronia entre servidor e cliente ao formatar datas e horários locais, garantindo estabilidade no carregamento.',
icon: Bug
},
{
date: '24 Jan, 2026',
time: '17:15',
category: 'atualizacoes',
title: 'Padronização de Multi-seleção',
description: 'Implementação do novo padrão premium de seleção em massa para o histórico de partidas, com checkboxes estilizados e ações flutuantes.',
icon: LayoutGrid
},
{
date: '24 Jan, 2026',
time: '17:05',
category: 'correcoes',
title: 'Logo no Super Admin',
description: 'Correção de roteamento que impedia a visualização dos logos das peladas dentro do painel administrativo.',
icon: ShieldCheck
},
{
date: '24 Jan, 2026',
time: '15:10',
category: 'atualizacoes',
title: 'Migração Next.js 16 (Proxy)',
description: 'Migração da convenção de "middleware" para "proxy", seguindo as novas diretrizes da versão 16 do Next.js.',
icon: Terminal
},
{
date: '24 Jan, 2026',
time: '14:55',
category: 'atualizacoes',
title: 'Refinamento do Login da Pelada',
description: 'Remoção do fundo colorido no logo e melhoria no placeholder para um visual mais limpo.',
icon: Layout
},
{
date: '24 Jan, 2026',
time: '14:43',
category: 'correcoes',
title: 'Correção de Estado no Login',
description: 'Campo de e-mail agora preserva o valor digitado após erros de autenticação.',
icon: Bug
},
{
date: '24 Jan, 2026',
time: '14:35',
category: 'correcoes',
title: 'Bug do "Undefined" na Home',
description: 'Resolvido erro de runtime ao acessar a home de um subdomínio sem estar logado.',
icon: ShieldCheck
},
{
date: '24 Jan, 2026',
time: '14:28',
category: 'implementacaoes',
title: 'Confirmação de Atletas Pública',
description: 'Sistema de confirmação via popup e ajuste de contraste para texto branco em fundos coloridos.',
icon: Zap
},
{
date: '24 Jan, 2026',
time: '14:23',
category: 'atualizacoes',
title: 'Renderização de Temas (SSR)',
description: 'Eliminado o flash verde durante o carregamento. Cores aplicadas via servidor.',
icon: Palette
},
{
date: '24 Jan, 2026',
time: '14:15',
category: 'correcoes',
title: 'Feedback de Atletas Duplicados',
description: 'Mensagem de erro visual ao tentar usar um número de camisa já existente.',
icon: Bug
}
]
const CATEGORIES = [
{ id: 'all', label: 'Todos', icon: Filter },
{ id: 'correcoes', label: 'Correções', icon: Bug, color: 'text-red-500', bg: 'bg-red-500/10' },
{ id: 'implementacaoes', label: 'Implementações', icon: Zap, color: 'text-emerald-500', bg: 'bg-emerald-500/10' },
{ id: 'atualizacoes', label: 'Atualizações', icon: Layout, color: 'text-blue-500', bg: 'bg-blue-500/10' }
]
export default function UpdatesPage() {
const [activeFilter, setActiveFilter] = useState('all')
const [searchQuery, setSearchQuery] = useState('')
const filteredUpdates = useMemo(() => {
return UPDATES.filter(u => {
const matchesFilter = activeFilter === 'all' || u.category === activeFilter
const matchesSearch = u.title.toLowerCase().includes(searchQuery.toLowerCase()) ||
u.description.toLowerCase().includes(searchQuery.toLowerCase())
return matchesFilter && matchesSearch
})
}, [activeFilter, searchQuery])
return (
<div className="min-h-screen bg-[#050505] text-white font-sans selection:bg-emerald-500/30 overflow-x-hidden">
<main className="max-w-4xl mx-auto px-6 py-12">
{/* Header Compacto */}
<header className="flex flex-col md:flex-row md:items-end justify-between gap-6 mb-12">
<div className="space-y-2">
<Link
href="/"
className="inline-flex items-center gap-2 text-zinc-500 hover:text-white transition-colors text-[10px] font-black uppercase tracking-widest group mb-2"
>
<ArrowLeft className="w-3 h-3 group-hover:-translate-x-1 transition-transform" />
Dashboard
</Link>
<h1 className="text-3xl font-black tracking-tighter uppercase leading-none">
Change<span className="text-emerald-500">Log</span>
</h1>
<p className="text-zinc-500 text-xs font-bold uppercase tracking-widest">
v2.1.0 Histórico de Atividade
</p>
</div>
{/* Search & Filters */}
<div className="flex flex-col sm:flex-row items-center gap-3">
<div className="relative w-full sm:w-64 group">
<Search className="absolute left-3 top-1/2 -translate-y-1/2 w-3.5 h-3.5 text-zinc-600 group-focus-within:text-emerald-500 transition-colors" />
<input
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
placeholder="Filtrar logs..."
className="w-full bg-zinc-900/50 border border-zinc-800 rounded-lg pl-9 pr-4 py-2 text-xs focus:border-emerald-500 outline-none transition-all"
/>
</div>
</div>
</header>
{/* Categorias Barra */}
<div className="flex items-center gap-2 overflow-x-auto pb-4 mb-8 no-scrollbar border-b border-zinc-900">
{CATEGORIES.map(cat => {
const Icon = cat.icon
const isActive = activeFilter === cat.id
return (
<button
key={cat.id}
onClick={() => setActiveFilter(cat.id)}
className={clsx(
"flex items-center gap-2 px-4 py-2 rounded-full text-[10px] font-black uppercase tracking-widest transition-all whitespace-nowrap border",
isActive
? "bg-white text-black border-white shadow-[0_0_15px_rgba(255,255,255,0.1)]"
: "bg-zinc-900/50 text-zinc-500 border-zinc-800 hover:border-zinc-700 hover:text-zinc-300"
)}
>
<Icon className="w-3.5 h-3.5" />
{cat.label}
</button>
)
})}
</div>
{/* Logs List - Compacto */}
<div className="space-y-2">
<AnimatePresence mode='popLayout'>
{filteredUpdates.map((update, index) => (
<motion.div
key={update.title}
layout
initial={{ opacity: 0, x: -10 }}
animate={{ opacity: 1, x: 0 }}
exit={{ opacity: 0, scale: 0.98 }}
transition={{ delay: index * 0.05 }}
className="group flex flex-col sm:flex-row sm:items-center gap-4 p-4 bg-zinc-900/20 hover:bg-zinc-900/40 border border-zinc-900 hover:border-zinc-800 rounded-xl transition-all cursor-default"
>
{/* Date Side */}
<div className="sm:w-32 shrink-0">
<div className="flex sm:flex-col gap-2 sm:gap-0">
<span className="text-[10px] font-black text-emerald-500 uppercase tracking-tighter">{update.date}</span>
<span className="text-[10px] font-bold text-zinc-600 uppercase tracking-widest">{update.time}</span>
</div>
</div>
{/* Content */}
<div className="flex-1 flex flex-col sm:flex-row sm:items-center gap-4">
<div className="hidden sm:flex items-center justify-center w-8 h-8 rounded-lg bg-zinc-900 border border-zinc-800 group-hover:border-emerald-500/50 transition-colors">
<update.icon className="w-4 h-4 text-zinc-500 group-hover:text-emerald-500 transition-colors" />
</div>
<div className="space-y-1">
<div className="flex items-center gap-2">
<h3 className="text-sm font-bold tracking-tight text-zinc-200 group-hover:text-white">
{update.title}
</h3>
<span className={clsx(
"sm:hidden w-1.5 h-1.5 rounded-full",
update.category === 'correcoes' ? 'bg-red-500' :
update.category === 'implementacaoes' ? 'bg-emerald-500' : 'bg-blue-500'
)} />
</div>
<p className="text-xs text-zinc-500 font-medium leading-relaxed max-w-xl">
{update.description}
</p>
</div>
</div>
{/* Category Tag (Desktop) */}
<div className="hidden sm:block shrink-0 px-3">
<span className={clsx(
"text-[8px] font-black uppercase tracking-[0.2em] px-2 py-1 rounded border",
update.category === 'correcoes' ? 'text-red-500 border-red-500/20 bg-red-500/5' :
update.category === 'implementacaoes' ? 'text-emerald-500 border-emerald-500/20 bg-emerald-500/5' :
'text-blue-500 border-blue-500/20 bg-blue-500/5'
)}>
{update.category === 'correcoes' ? 'Bugfix' :
update.category === 'implementacaoes' ? 'Feature' : 'Update'}
</span>
</div>
</motion.div>
))}
</AnimatePresence>
{filteredUpdates.length === 0 && (
<div className="py-20 text-center space-y-4">
<div className="w-12 h-12 bg-zinc-900 border border-dashed border-zinc-800 rounded-full flex items-center justify-center mx-auto opacity-50">
<Search className="w-5 h-5 text-zinc-600" />
</div>
<p className="text-xs font-bold text-zinc-600 uppercase tracking-widest">Nenhum registro encontrado</p>
</div>
)}
</div>
{/* Info Footer */}
<footer className="mt-20 flex flex-col sm:flex-row items-center justify-between gap-6 pt-8 border-t border-zinc-900 opacity-40">
<p className="text-[10px] font-black uppercase tracking-[0.4em]">Engine v2.0</p>
<div className="flex gap-4">
<span className="text-[9px] font-bold uppercase">Dev: Antigravity AI</span>
<span className="text-[9px] font-bold uppercase">Status: Live</span>
</div>
</footer>
</main>
</div>
)
}

View File

@@ -2,7 +2,7 @@
import { useState, useEffect } from 'react'
import { motion, AnimatePresence } from 'framer-motion'
import { ArrowRight, Check, X, Loader2, Upload, Palette, Trophy, ChevronLeft, Mail, Lock, Eye, EyeOff } from 'lucide-react'
import { ArrowRight, Check, X, Loader2, Upload, Palette, Trophy, ChevronLeft, Mail, Lock, Eye, EyeOff, User } from 'lucide-react'
import { checkSlugAvailability, createGroup } from '@/app/actions'
export default function CreateTeamWizard() {
@@ -11,6 +11,7 @@ export default function CreateTeamWizard() {
name: '',
slug: '',
email: '',
ownerName: '',
password: '',
confirmPassword: '',
primaryColor: '#10B981',
@@ -54,7 +55,7 @@ export default function CreateTeamWizard() {
switch (step) {
case 1: return formData.name.length >= 3
case 2: return formData.slug.length >= 3 && slugAvailable === true
case 3: return formData.email && formData.password.length >= 6 && formData.password === formData.confirmPassword
case 3: return formData.ownerName.length >= 3 && formData.email && formData.password.length >= 6 && formData.password === formData.confirmPassword
case 4: return true // Logo and colors are optional
default: return false
}
@@ -92,6 +93,7 @@ export default function CreateTeamWizard() {
data.append('name', formData.name)
data.append('slug', formData.slug)
data.append('email', formData.email)
data.append('ownerName', formData.ownerName)
data.append('password', formData.password)
data.append('primaryColor', formData.primaryColor)
data.append('secondaryColor', formData.secondaryColor)
@@ -230,7 +232,7 @@ export default function CreateTeamWizard() {
</motion.div>
)}
{/* Step 3: Email e Senha */}
{/* Step 3: Admin Account */}
{step === 3 && (
<motion.div
key="step3"
@@ -239,6 +241,23 @@ export default function CreateTeamWizard() {
exit={{ opacity: 0, x: -20 }}
className="space-y-4"
>
<div>
<label className="block text-sm font-medium text-zinc-400 mb-2">
Seu Nome (Responsável)
</label>
<div className="relative">
<User className="absolute left-3 top-1/2 -translate-y-1/2 w-5 h-5 text-zinc-500" />
<input
type="text"
value={formData.ownerName}
onChange={(e) => setFormData(prev => ({ ...prev, ownerName: e.target.value }))}
placeholder="Ex: João Silva"
className="w-full pl-11 pr-4 py-3 bg-zinc-800 border border-zinc-700 rounded-xl text-white placeholder:text-zinc-600 focus:outline-none focus:ring-2 focus:ring-emerald-500/50 focus:border-emerald-500"
/>
</div>
<p className="text-[10px] text-zinc-500 mt-1 italic">Você será cadastrado como o primeiro jogador (Líder).</p>
</div>
<div>
<label className="block text-sm font-medium text-zinc-400 mb-2">
Seu email (para login)

View File

@@ -2,31 +2,24 @@ import { prisma } from '@/lib/prisma'
import { notFound } from 'next/navigation'
import { Check, X, AlertCircle, Users } from 'lucide-react'
import { CopyButton } from '@/components/CopyButton'
import { FinancialEvent, Group, Payment, Player } from '@prisma/client'
// This is a public page, so we don't check for auth session strictly,
// but we only show non-sensitive data.
async function getPublicEventData(id: string) {
return await prisma.financialEvent.findUnique({
type FinancialEventWithRelations = FinancialEvent & {
group: Group;
payments: (Payment & { player: Player })[];
};
async function getPublicEventData(id: string): Promise<FinancialEventWithRelations | null> {
const event = await prisma.financialEvent.findUnique({
where: { id },
include: {
group: {
select: {
name: true,
logoUrl: true,
pixKey: true,
pixName: true,
primaryColor: true
}
},
group: true,
payments: {
include: {
player: {
select: {
name: true,
position: true
}
}
player: true
},
orderBy: {
player: {
@@ -36,11 +29,12 @@ async function getPublicEventData(id: string) {
}
}
})
return event as FinancialEventWithRelations | null
}
export default async function PublicFinancialReport(props: { params: Promise<{ id: string }> }) {
const params = await props.params;
const event = await getPublicEventData(params.id)
const event = await getPublicEventData(params.id) as any // Using any to bypass remaining stale type issues in IDE
if (!event) return notFound()
@@ -52,6 +46,14 @@ export default async function PublicFinancialReport(props: { params: Promise<{ i
const paidCount = event.payments.filter((p: any) => p.status === 'PAID').length
const progress = totalExpected > 0 ? (totalPaid / totalExpected) * 100 : 0
const formatDate = (dateInput: string | Date) => {
const d = new Date(dateInput)
const day = String(d.getUTCDate()).padStart(2, '0')
const month = String(d.getUTCMonth() + 1).padStart(2, '0')
const year = d.getUTCFullYear()
return `${day}/${month}/${year}`
}
return (
<div className="min-h-screen bg-background text-foreground font-sans pb-20">
{/* Header */}
@@ -72,28 +74,35 @@ export default async function PublicFinancialReport(props: { params: Promise<{ i
{event.type === 'MONTHLY_FEE' ? 'Mensalidade' : 'Evento Extra'}
</span>
<h2 className="text-3xl font-bold">{event.title}</h2>
<p className="text-muted">Vencimento: {new Date(event.dueDate).toLocaleDateString('pt-BR')}</p>
<p className="text-muted">Vencimento: {formatDate(event.dueDate)}</p>
</div>
{/* Progress */}
<div className="ui-card p-6 border-primary/20 bg-primary/5 space-y-4">
<div className="flex justify-between items-end">
<div>
<p className="text-xs font-bold text-muted uppercase tracking-wider">Arrecadado</p>
<p className="text-3xl font-black text-primary">R$ {totalPaid.toFixed(2)}</p>
{/* Progress - Only if allowed */}
{event.showTotalInPublic ? (
<div className="ui-card p-6 border-primary/20 bg-primary/5 space-y-4">
<div className="flex justify-between items-end">
<div>
<p className="text-xs font-bold text-muted uppercase tracking-wider">Arrecadado</p>
<p className="text-3xl font-black text-primary">R$ {totalPaid.toFixed(2)}</p>
</div>
<div className="text-right">
<p className="text-xs font-bold text-muted uppercase tracking-wider">Meta</p>
<p className="text-lg font-bold text-muted">R$ {totalExpected.toFixed(2)}</p>
</div>
</div>
<div className="text-right">
<p className="text-xs font-bold text-muted uppercase tracking-wider">Meta</p>
<p className="text-lg font-bold text-muted">R$ {totalExpected.toFixed(2)}</p>
<div className="h-3 bg-background rounded-full overflow-hidden border border-white/5">
<div className="h-full bg-primary transition-all" style={{ width: `${progress}%` }} />
</div>
<p className="text-center text-xs font-bold text-muted">
{paidCount} de {event.payments.length} pagamentos confirmados
</p>
</div>
<div className="h-3 bg-background rounded-full overflow-hidden border border-white/5">
<div className="h-full bg-primary transition-all" style={{ width: `${progress}%` }} />
) : (
<div className="ui-card p-4 border-dashed border-border bg-surface-raised flex items-center justify-center gap-3">
<AlertCircle className="w-4 h-4 text-muted" />
<p className="text-xs font-bold text-muted uppercase tracking-widest">Resumo de valores oculto</p>
</div>
<p className="text-center text-xs font-bold text-muted">
{paidCount} de {event.payments.length} pagamentos confirmados
</p>
</div>
)}
{/* Pix Key if available */}
{event.group.pixKey && (
@@ -130,9 +139,14 @@ export default async function PublicFinancialReport(props: { params: Promise<{ i
}`}>
{payment.status === 'PAID' ? <Check className="w-4 h-4" /> : payment.player.name.substring(0, 2).toUpperCase()}
</div>
<span className={`font-medium text-sm ${payment.status === 'PAID' ? 'text-foreground' : 'text-muted'}`}>
{payment.player.name}
</span>
<div className="flex flex-col">
<span className={`font-medium text-sm ${payment.status === 'PAID' ? 'text-foreground' : 'text-muted'}`}>
{payment.player.name}
</span>
<span className="text-[10px] font-bold text-primary font-mono">
R$ {payment.amount.toFixed(2)}
</span>
</div>
</div>
<span className={`text-xs font-bold px-2 py-1 rounded-md uppercase tracking-wider ${payment.status === 'PAID'
? 'bg-green-500/10 text-green-500'

View File

@@ -1,3 +1,4 @@
@import url('https://fonts.googleapis.com/css2?family=Inter:ital,opsz,wght@0,14..32,100..900;1,14..32,100..900&display=swap');
@import "tailwindcss";
@theme {
@@ -9,6 +10,7 @@
--color-surface-raised: #141414;
--color-primary: var(--primary-color);
--color-secondary: var(--secondary-color);
/* Emerald 500 */
--color-primary-soft: color-mix(in srgb, var(--primary-color), transparent 90%);
@@ -24,6 +26,7 @@
--primary: 158 82% 39%;
--border: 0 0% 12%;
--primary-color: #10b981;
--secondary-color: #000000;
}
@layer base {
@@ -106,6 +109,18 @@
@apply bg-border rounded-full hover:bg-white/20;
}
.custom-scrollbar::-webkit-scrollbar {
width: 4px;
}
.custom-scrollbar::-webkit-scrollbar-thumb {
@apply bg-white/10 rounded-full hover:bg-primary/50;
}
select option {
@apply bg-surface text-foreground;
}
/* Date Picker Customization for Dark Mode */
::-webkit-calendar-picker-indicator {
filter: invert(1);

View File

@@ -1,12 +1,6 @@
import type { Metadata } from "next";
import { Inter } from "next/font/google";
import "./globals.css";
const inter = Inter({
variable: "--font-sans",
subsets: ["latin"],
});
export const metadata: Metadata = {
title: "TemFut - Gestão de Pelada",
description: "Organize sua pelada, sorteie times e gerencie seu esquadrão.",
@@ -19,7 +13,7 @@ export default function RootLayout({
}>) {
return (
<html lang="pt-BR">
<body className={`${inter.variable} font-sans antialiased`}>
<body className="font-sans antialiased">
{children}
</body>
</html>

View File

@@ -29,5 +29,5 @@ export default async function LoginPage() {
redirect('/dashboard')
}
return <PeladaLoginPage slug={slug} groupName={group.name} />
return <PeladaLoginPage slug={slug} group={group} />
}

View File

@@ -15,6 +15,8 @@ export default function ConfirmationPage() {
const [searchQuery, setSearchQuery] = useState('')
const [isProcessing, setIsProcessing] = useState(false)
const [showSuccess, setShowSuccess] = useState(false)
const [error, setError] = useState<string | null>(null)
const [selectedPlayerForConfirm, setSelectedPlayerForConfirm] = useState<any>(null)
useEffect(() => {
loadMatch()
@@ -32,9 +34,19 @@ export default function ConfirmationPage() {
await confirmAttendance(id, playerId)
await loadMatch()
setShowSuccess(true)
setSelectedPlayerForConfirm(null)
setTimeout(() => setShowSuccess(false), 3000)
} catch (error) {
console.error(error)
} catch (err: any) {
console.error(err)
if (err.message === 'MATCH_NOT_FOUND') {
setError('Esta pelada foi removida ou não existe mais.')
} else if (err.message === 'MATCH_ALREADY_COMPLETED') {
setError('As confirmações para esta pelada já foram encerradas.')
} else {
setError('Ocorreu um erro ao confirmar sua presença. Tente novamente.')
}
setSelectedPlayerForConfirm(null)
setTimeout(() => setError(null), 5000)
} finally {
setIsProcessing(false)
}
@@ -87,7 +99,7 @@ export default function ConfirmationPage() {
animate={{ scale: 1, opacity: 1 }}
className="w-16 h-16 bg-primary rounded-2xl flex items-center justify-center shadow-[0_8px_30px_rgb(16,185,129,0.3)] border border-primary/20"
>
<Trophy className="w-8 h-8 text-background" />
<Trophy className="w-8 h-8 text-white" />
</motion.div>
<div className="space-y-1">
@@ -119,14 +131,28 @@ export default function ConfirmationPage() {
initial={{ opacity: 0, y: -20 }}
animate={{ opacity: 1, y: 0 }}
exit={{ opacity: 0, scale: 0.95 }}
className="fixed top-6 left-6 right-6 z-50 bg-primary text-background p-4 rounded-xl shadow-2xl flex items-center gap-3 font-bold text-sm"
className="fixed top-6 left-6 right-6 z-50 bg-primary text-white p-4 rounded-xl shadow-2xl flex items-center gap-3 font-bold text-sm"
>
<div className="w-6 h-6 bg-background/20 rounded-full flex items-center justify-center">
<div className="w-6 h-6 bg-white/20 rounded-full flex items-center justify-center">
<Check className="w-4 h-4" />
</div>
Presença confirmada com sucesso!
</motion.div>
)}
{error && (
<motion.div
initial={{ opacity: 0, y: -20 }}
animate={{ opacity: 1, y: 0 }}
exit={{ opacity: 0, scale: 0.95 }}
className="fixed top-6 left-6 right-6 z-50 bg-red-500 text-white p-4 rounded-xl shadow-2xl flex items-center gap-3 font-bold text-sm"
>
<div className="w-6 h-6 bg-white/20 rounded-full flex items-center justify-center">
<X className="w-4 h-4" />
</div>
{error}
</motion.div>
)}
</AnimatePresence>
{/* Draw Results (If done) */}
@@ -203,12 +229,12 @@ export default function ConfirmationPage() {
animate={{ opacity: 1, scale: 1 }}
exit={{ opacity: 0, scale: 0.98 }}
key={p.id}
onClick={() => handleConfirm(p.id)}
onClick={() => setSelectedPlayerForConfirm(p)}
disabled={isProcessing}
className="ui-card p-4 flex items-center justify-between hover:border-primary/40 hover:bg-primary/5 transition-all group text-left"
>
<div className="flex items-center gap-4">
<div className="w-10 h-10 bg-surface-raised rounded-xl border border-border flex items-center justify-center font-black text-[11px] text-primary group-hover:bg-primary group-hover:text-background transition-colors">
<div className="w-10 h-10 bg-surface-raised rounded-xl border border-border flex items-center justify-center font-black text-[11px] text-primary group-hover:bg-primary group-hover:text-white transition-colors">
{p.number !== null ? p.number : getInitials(p.name)}
</div>
<div className="space-y-0.5">
@@ -304,6 +330,63 @@ export default function ConfirmationPage() {
)}
</div>
</section>
{/* Confirmation Modal */}
<AnimatePresence>
{selectedPlayerForConfirm && (
<div className="fixed inset-0 z-[100] flex items-end sm:items-center justify-center p-4">
<motion.div
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
exit={{ opacity: 0 }}
onClick={() => setSelectedPlayerForConfirm(null)}
className="absolute inset-0 bg-background/80 backdrop-blur-md"
/>
<motion.div
initial={{ opacity: 0, y: 100, scale: 0.95 }}
animate={{ opacity: 1, y: 0, scale: 1 }}
exit={{ opacity: 0, y: 100, scale: 0.95 }}
className="relative w-full max-w-sm ui-card overflow-hidden shadow-2xl border-primary/20"
>
<div className="p-8 text-center space-y-6">
<div className="w-20 h-20 bg-primary/10 rounded-3xl flex items-center justify-center mx-auto border border-primary/20 shadow-inner">
<div className="text-2xl font-black text-primary">
{selectedPlayerForConfirm.number !== null ? selectedPlayerForConfirm.number : getInitials(selectedPlayerForConfirm.name)}
</div>
</div>
<div className="space-y-2">
<h3 className="text-xl font-black uppercase tracking-tight">Confirmar Presença?</h3>
<p className="text-muted text-sm font-medium">
Você está confirmando a ida de <span className="text-foreground font-bold">{selectedPlayerForConfirm.name}</span> para a pelada.
</p>
</div>
<div className="grid grid-cols-2 gap-3 pt-2">
<button
onClick={() => setSelectedPlayerForConfirm(null)}
className="ui-button-ghost h-14 font-bold uppercase tracking-widest text-[10px]"
disabled={isProcessing}
>
Cancelar
</button>
<button
onClick={() => handleConfirm(selectedPlayerForConfirm.id)}
className="ui-button h-14 font-bold uppercase tracking-widest text-[10px] bg-primary text-white hover:bg-primary/90"
disabled={isProcessing}
>
{isProcessing ? (
<div className="w-5 h-5 border-2 border-white/30 border-t-white rounded-full animate-spin" />
) : (
'Sim, Confirmar'
)}
</button>
</div>
</div>
</motion.div>
</div>
)}
</AnimatePresence>
</main>
{/* Footer */}

View File

@@ -33,5 +33,5 @@ export default async function Home() {
}
// Caso contrário, mostra a tela de login da pelada
return <PeladaLoginPage slug={slug} groupName={group.name} />
return <PeladaLoginPage slug={slug} group={group} />
}

View File

@@ -3,9 +3,11 @@
import { useState } from 'react'
import { createFinancialEvent } from '@/actions/finance'
import type { FinancialEventType } from '@prisma/client'
import { Loader2, Plus, Users, Calculator } from 'lucide-react'
import { Loader2, Plus, Users, Calculator, Calendar, AlertCircle } from 'lucide-react'
import { useRouter } from 'next/navigation'
import { DateTimePicker } from '@/components/DateTimePicker'
interface CreateFinanceEventModalProps {
isOpen: boolean
onClose: () => void
@@ -20,7 +22,8 @@ export function CreateFinanceEventModal({ isOpen, onClose, players }: CreateFina
// Form State
const [type, setType] = useState<FinancialEventType>('MONTHLY_FEE')
const [title, setTitle] = useState('')
const [dueDate, setDueDate] = useState('')
const [description, setDescription] = useState('')
const [dueDate, setDueDate] = useState(() => new Date().toISOString().split('T')[0])
const [priceMode, setPriceMode] = useState<'FIXED' | 'TOTAL'>('FIXED') // Fixed per person or Total to split
const [amount, setAmount] = useState('')
const [isRecurring, setIsRecurring] = useState(false)
@@ -37,6 +40,7 @@ export function CreateFinanceEventModal({ isOpen, onClose, players }: CreateFina
const result = await createFinancialEvent({
title: title || (type === 'MONTHLY_FEE' ? 'Mensalidade' : 'Evento'),
description: description,
type,
dueDate,
selectedPlayerIds: selectedPlayers,
@@ -106,47 +110,61 @@ export function CreateFinanceEventModal({ isOpen, onClose, players }: CreateFina
</div>
<div className="ui-form-field">
<label className="text-label ml-1">Vencimento</label>
<input
type="date"
value={dueDate}
onChange={e => setDueDate(e.target.value)}
className="ui-input w-full [color-scheme:dark]"
<label className="text-label ml-1">Descrição (Opcional)</label>
<textarea
value={description}
onChange={e => setDescription(e.target.value)}
placeholder="Ex: Pagamento da quadra do mês"
className="ui-input w-full min-h-[80px] py-3 resize-none"
/>
</div>
{type === 'MONTHLY_FEE' && (
<div className="bg-surface/50 border border-border p-4 rounded-xl space-y-4">
<div className="flex items-center gap-3">
<input
type="checkbox"
checked={isRecurring}
onChange={(e) => setIsRecurring(e.target.checked)}
className="w-4 h-4 rounded border-border text-primary bg-background accent-primary"
id="recurring-check"
/>
<label htmlFor="recurring-check" className="text-sm font-medium cursor-pointer select-none">
Repetir mensalmente
</label>
</div>
<DateTimePicker
label="Vencimento"
value={dueDate}
onChange={setDueDate}
mode="date"
/>
{isRecurring && (
<div className="pl-7 animate-in fade-in slide-in-from-top-2">
<label className="text-label ml-0.5 mb-1.5 block">Repetir até</label>
<input
type="date"
value={recurrenceEndDate}
onChange={(e) => setRecurrenceEndDate(e.target.value)}
className="ui-input w-full h-10 text-sm [color-scheme:dark]"
min={dueDate}
/>
<p className="text-[10px] text-muted mt-1.5">
Serão criados eventos para cada mês até a data limite.
</p>
<div className="bg-surface/50 border border-border p-4 rounded-xl space-y-4">
<div className="flex items-center justify-between">
<div className="flex items-center gap-3">
<div className={`p-2 rounded-lg ${isRecurring ? 'bg-primary/20 text-primary' : 'bg-surface-raised text-muted'}`}>
<Calendar className="w-4 h-4" />
</div>
)}
<div className="space-y-0.5">
<label htmlFor="recurring-check" className="text-sm font-bold cursor-pointer select-none">
Repetir Evento
</label>
<p className="text-[10px] text-muted">Criar cópias mensais automaticamente</p>
</div>
</div>
<button
type="button"
onClick={() => setIsRecurring(!isRecurring)}
className={`w-10 h-5 rounded-full transition-colors relative ${isRecurring ? 'bg-primary' : 'bg-zinc-700'}`}
>
<div className={`absolute top-1 w-3 h-3 bg-white rounded-full transition-all ${isRecurring ? 'right-1' : 'left-1'}`} />
</button>
</div>
)}
{isRecurring && (
<div className="pt-2 border-t border-border/50 animate-in fade-in slide-in-from-top-2">
<DateTimePicker
label="Até quando repetir?"
value={recurrenceEndDate}
onChange={setRecurrenceEndDate}
placeholder="Data final da recorrência"
mode="date"
/>
<p className="text-[10px] text-orange-400 font-medium mt-2 flex items-center gap-1">
<AlertCircle className="w-3 h-3" />
O sistema parará de criar eventos após esta data.
</p>
</div>
)}
</div>
<div className="space-y-2">
<label className="text-label ml-1">Valor</label>

View File

@@ -0,0 +1,349 @@
'use client'
import React, { useState, useMemo, useEffect, useRef } from 'react'
import { Calendar as CalendarIcon, ChevronLeft, ChevronRight, Clock, X, Check } from 'lucide-react'
import { motion, AnimatePresence } from 'framer-motion'
import { clsx } from 'clsx'
interface DateTimePickerProps {
value: string // ISO string or datetime-local format
onChange: (value: string) => void
label?: string
placeholder?: string
required?: boolean
mode?: 'date' | 'datetime'
}
export function DateTimePicker({
value,
onChange,
label,
placeholder,
required,
mode = 'datetime'
}: DateTimePickerProps) {
const [isOpen, setIsOpen] = useState(false)
const [mounted, setMounted] = useState(false)
// Better initialization to avoid timezone issues with YYYY-MM-DD
const parseValue = (val: string) => {
if (!val) return new Date()
if (val.length === 10) { // YYYY-MM-DD
const [year, month, day] = val.split('-').map(Number)
return new Date(year, month - 1, day, 12, 0, 0)
}
return new Date(val)
}
const [viewDate, setViewDate] = useState(() => parseValue(value))
const containerRef = useRef<HTMLDivElement>(null)
const defaultPlaceholder = mode === 'date' ? "Selecione a data" : "Selecione data e hora"
const currentPlaceholder = placeholder || defaultPlaceholder
useEffect(() => {
setMounted(true)
}, [])
// Sync viewDate when value changes from outside
useEffect(() => {
if (value) {
setViewDate(parseValue(value))
}
}, [value])
// 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 selectedDate = value ? new Date(value) : null
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({
day: prevMonthDays - i,
month: month - 1,
year: month === 0 ? year - 1 : year,
currentMonth: false
})
}
// Current month days
const currentMonthDays = daysInMonth(year, month)
for (let i = 1; i <= currentMonthDays; i++) {
days.push({
day: i,
month,
year,
currentMonth: true
})
}
// Next month days
const remaining = 42 - days.length
for (let i = 1; i <= remaining; i++) {
days.push({
day: i,
month: month + 1,
year: month === 11 ? year + 1 : year,
currentMonth: false
})
}
return days
}, [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])
setIsOpen(false)
} else {
// If it's the first time selecting in datetime mode, set a default time
if (!value) {
newDate.setHours(19, 0, 0, 0)
}
onChange(newDate.toISOString())
}
}
const handleTimeChange = (hours: number, minutes: number) => {
const newDate = parseValue(value)
newDate.setHours(hours)
newDate.setMinutes(minutes)
newDate.setSeconds(0)
newDate.setMilliseconds(0)
onChange(newDate.toISOString())
}
const formatDisplay = (val: string) => {
if (!val) return ''
const d = parseValue(val)
return d.toLocaleString('pt-BR', {
day: '2-digit',
month: '2-digit',
year: 'numeric',
...(mode === 'datetime' ? {
hour: '2-digit',
minute: '2-digit'
} : {})
})
}
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))
}
const hours = Array.from({ length: 24 }, (_, i) => i)
const minutes = Array.from({ length: 12 }, (_, i) => i * 5)
return (
<div className="ui-form-field" ref={containerRef}>
{label && <label className="text-label ml-1">{label}</label>}
<div className="relative">
<div
onClick={() => setIsOpen(!isOpen)}
className={clsx(
"ui-input w-full h-12 flex items-center justify-between cursor-pointer transition-all",
isOpen ? "border-primary ring-2 ring-primary/20 shadow-lg shadow-primary/10" : "bg-surface"
)}
>
<div className="flex items-center gap-3">
<CalendarIcon className={clsx("w-4 h-4 transition-colors", isOpen ? "text-primary" : "text-muted")} />
<span className={clsx("text-sm transition-colors", !value && "text-muted")}>
{!mounted ? currentPlaceholder : (value ? formatDisplay(value) : currentPlaceholder)}
</span>
</div>
</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={clsx(
"absolute top-full left-0 mt-2 z-[100] bg-surface-raised border border-border shadow-2xl rounded-2xl overflow-hidden flex flex-col sm:flex-row",
mode === 'date' ? "w-[310px]" : "w-[320px] sm:w-[500px]"
)}
>
{/* Calendar Section */}
<div className={clsx(
"p-6 flex-1 bg-surface-raised/40 backdrop-blur-xl",
mode === 'datetime' && "border-b sm:border-b-0 sm:border-r border-white/5"
)}>
<div className="flex items-center justify-between mb-6">
<div className="space-y-0.5">
<h4 className="text-[10px] font-black uppercase tracking-[0.3em] text-primary/80">
Selecione a Data
</h4>
<p className="text-lg font-black uppercase italic tracking-tighter leading-none">
{months[viewDate.getMonth()]} <span className="text-muted/40">{viewDate.getFullYear()}</span>
</p>
</div>
<div className="flex gap-2">
<button
type="button"
onClick={(e) => { e.stopPropagation(); prevMonth(); }}
className="p-2 bg-white/5 hover:bg-white/10 border border-white/5 rounded-xl transition-all text-muted hover:text-primary active:scale-90"
>
<ChevronLeft className="w-4 h-4" />
</button>
<button
type="button"
onClick={(e) => { e.stopPropagation(); nextMonth(); }}
className="p-2 bg-white/5 hover:bg-white/10 border border-white/5 rounded-xl transition-all text-muted hover:text-primary active:scale-90"
>
<ChevronRight className="w-4 h-4" />
</button>
</div>
</div>
<div className="grid grid-cols-7 gap-2 mb-2">
{['Dom', 'Seg', 'Ter', 'Qua', 'Qui', 'Sex', 'Sáb'].map((d, i) => (
<div key={i} className="text-[9px] font-black text-muted/30 text-center uppercase tracking-widest py-2">
{d}
</div>
))}
</div>
<div className="grid grid-cols-7 gap-2">
{calendarDays.map((d, i) => {
const isSelected = selectedDate &&
selectedDate.getDate() === d.day &&
selectedDate.getMonth() === d.month &&
selectedDate.getFullYear() === d.year
const isToday = new Date().getDate() === d.day &&
new Date().getMonth() === d.month &&
new Date().getFullYear() === d.year
return (
<button
key={i}
type="button"
onClick={() => handleDateSelect(d.day, d.month, d.year)}
className={clsx(
"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-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"
)}
>
<span className="relative z-10">{d.day}</span>
{isSelected && (
<motion.div
layoutId="activeDay"
className="absolute inset-0 bg-primary"
transition={{ type: "spring", bounce: 0.2, duration: 0.6 }}
/>
)}
</button>
)
})}
</div>
</div>
{/* Time Section */}
{mode === 'datetime' && (
<div className="bg-black/40 backdrop-blur-2xl p-6 w-full sm:w-56 flex flex-col font-sans">
<div className="space-y-1 mb-6">
<h4 className="text-[10px] font-black uppercase tracking-[0.3em] text-primary/80">Ajuste de</h4>
<p className="text-xl font-black uppercase italic tracking-tighter leading-none">Horário</p>
</div>
<div className="flex gap-4 flex-1">
{/* Hours */}
<div className="flex-1 flex flex-col">
<span className="text-[9px] font-black uppercase text-muted/30 mb-3 tracking-widest text-center">Hora</span>
<div className="h-[220px] overflow-y-auto w-full space-y-2 pr-2 custom-scrollbar">
{hours.map(h => (
<button
key={h}
type="button"
onClick={() => handleTimeChange(h, selectedDate?.getMinutes() || 0)}
className={clsx(
"w-full py-2.5 text-sm font-black rounded-xl transition-all border",
selectedDate?.getHours() === h
? "bg-primary/20 text-primary border-primary/40 shadow-[0_0_10px_rgba(var(--primary-rgb),0.1)]"
: "bg-white/[0.02] border-white/5 hover:bg-white/5 text-muted/60 hover:text-foreground"
)}
>
{h.toString().padStart(2, '0')}
</button>
))}
</div>
</div>
{/* Minutes */}
<div className="flex-1 flex flex-col">
<span className="text-[9px] font-black uppercase text-muted/30 mb-3 tracking-widest text-center">Min</span>
<div className="h-[220px] overflow-y-auto w-full space-y-2 pr-2 custom-scrollbar">
{minutes.map(m => (
<button
key={m}
type="button"
onClick={() => handleTimeChange(selectedDate?.getHours() || 0, m)}
className={clsx(
"w-full py-2.5 text-sm font-black rounded-xl transition-all border",
selectedDate?.getMinutes() === m
? "bg-primary/20 text-primary border-primary/40 shadow-[0_0_10px_rgba(var(--primary-rgb),0.1)]"
: "bg-white/[0.02] border-white/5 hover:bg-white/5 text-muted/60 hover:text-foreground"
)}
>
{m.toString().padStart(2, '0')}
</button>
))}
</div>
</div>
</div>
<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"
>
Confirmar
</button>
</div>
)}
</motion.div>
)}
</AnimatePresence>
</div>
</div>
)
}

View File

@@ -1,9 +1,10 @@
'use client'
import React, { useState, useMemo } from 'react'
import { Plus, Wallet, TrendingUp, Calendar, AlertCircle, ChevronRight, Check, X, Search, Filter, LayoutGrid, List, Trash2, MoreHorizontal, Share2, Copy } from 'lucide-react'
import { Plus, Wallet, TrendingUp, Calendar, AlertCircle, ChevronRight, Check, X, Search, Filter, LayoutGrid, List, Trash2, MoreHorizontal, Share2, Copy, Settings, Eye, EyeOff } from 'lucide-react'
import { CreateFinanceEventModal } from '@/components/CreateFinanceEventModal'
import { markPaymentAsPaid, markPaymentAsPending, deleteFinancialEvents } from '@/actions/finance'
import { FinancialSettingsModal } from '@/components/FinancialSettingsModal'
import { markPaymentAsPaid, markPaymentAsPending, deleteFinancialEvents, toggleEventPrivacy } from '@/actions/finance'
import { useRouter } from 'next/navigation'
import Link from 'next/link'
import { motion, AnimatePresence } from 'framer-motion'
@@ -13,12 +14,32 @@ import { DeleteConfirmationModal } from '@/components/DeleteConfirmationModal'
interface FinancialPageProps {
events: any[]
players: any[]
group: any
}
export function FinancialDashboard({ events, players }: FinancialPageProps) {
export function FinancialDashboard({ events, players, group }: FinancialPageProps) {
const router = useRouter()
const [mounted, setMounted] = React.useState(false)
const [isCreateModalOpen, setIsCreateModalOpen] = useState(false)
const [isSettingsModalOpen, setIsSettingsModalOpen] = useState(false)
React.useEffect(() => {
setMounted(true)
}, [])
// Helper to format date safely without hydration mismatch
const formatDate = (dateInput: string | Date) => {
const d = new Date(dateInput)
// Since dueDate is saved at 00:00:00 UTC (from YYYY-MM-DD string),
// we use UTC methods to ensure the same date is shown everywhere.
const day = String(d.getUTCDate()).padStart(2, '0')
const month = String(d.getUTCMonth() + 1).padStart(2, '0')
const year = d.getUTCFullYear()
return `${day}/${month}/${year}`
}
const [expandedEventId, setExpandedEventId] = useState<string | null>(null)
const [privacyPendingId, setPrivacyPendingId] = useState<string | null>(null)
// Filter & View State
const [searchQuery, setSearchQuery] = useState('')
@@ -182,10 +203,20 @@ export function FinancialDashboard({ events, players }: FinancialPageProps) {
</button>
</div>
<button onClick={() => setIsCreateModalOpen(true)} className="ui-button w-full sm:w-auto shadow-lg shadow-primary/20 h-10">
<Plus className="w-4 h-4 mr-2" />
Novo Evento
</button>
<div className="flex items-center gap-2 w-full sm:w-auto">
<button
onClick={() => setIsSettingsModalOpen(true)}
className="p-2.5 bg-surface-raised border border-border rounded-xl text-muted hover:text-primary hover:border-primary/30 transition-all"
title="Configurações de Privacidade"
>
<Settings className="w-5 h-5" />
</button>
<button onClick={() => setIsCreateModalOpen(true)} className="ui-button flex-1 sm:flex-initial shadow-lg shadow-primary/20 h-10 px-6">
<Plus className="w-4 h-4 mr-2" />
Novo Evento
</button>
</div>
</>
)}
</div>
@@ -300,7 +331,7 @@ export function FinancialDashboard({ events, players }: FinancialPageProps) {
{event.type === 'MONTHLY_FEE' ? 'Mensalidade' : 'Evento'}
</span>
<span className="text-[10px] text-muted font-bold">
{new Date(event.dueDate).toLocaleDateString('pt-BR')}
{mounted ? formatDate(event.dueDate) : '--/--/----'}
</span>
</div>
<h3 className="font-bold text-base leading-tight group-hover:text-primary transition-colors">{event.title}</h3>
@@ -342,7 +373,7 @@ export function FinancialDashboard({ events, players }: FinancialPageProps) {
const paid = event.payments.filter((p: any) => p.status === 'PAID').map((p: any) => p.player.name)
const pending = event.payments.filter((p: any) => p.status !== 'PAID').map((p: any) => p.player.name)
const message = `*${event.title.toUpperCase()}*\nVencimento: ${new Date(event.dueDate).toLocaleDateString('pt-BR')}\n\n` +
const message = `*${event.title.toUpperCase()}*\nVencimento: ${formatDate(event.dueDate)}\n\n` +
`✅ *PAGOS (${paid.length})*:\n${paid.join(', ')}\n\n` +
`⏳ *PENDENTES (${pending.length})*:\n${pending.join(', ')}\n\n` +
`💰 *Total Arrecadado*: R$ ${event.stats.totalPaid.toFixed(2)}\n` +
@@ -370,44 +401,82 @@ export function FinancialDashboard({ events, players }: FinancialPageProps) {
</div>
</div>
<div>
<div className="flex items-center justify-between mb-3">
<h4 className="text-xs font-bold uppercase tracking-widest text-muted">Gerenciar Pagamentos</h4>
<Link
href={`/financial-report/${event.id}`}
className="text-[10px] font-bold text-primary hover:underline flex items-center gap-1"
target="_blank"
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
<div className="space-y-3">
<div className="flex items-center justify-between">
<h4 className="text-xs font-bold uppercase tracking-widest text-muted">Privacidade</h4>
</div>
<button
onClick={async (e) => {
e.stopPropagation();
setPrivacyPendingId(event.id);
await toggleEventPrivacy(event.id, !event.showTotalInPublic);
setPrivacyPendingId(null);
router.refresh();
}}
className={clsx(
"flex items-center justify-between w-full p-3 rounded-xl border transition-all",
event.showTotalInPublic
? "bg-primary/5 border-primary/20 text-primary"
: "bg-surface border-border text-muted"
)}
>
Ver Página Pública <ChevronRight className="w-3 h-3" />
</Link>
<div className="flex items-center gap-3">
{event.showTotalInPublic ? <Eye className="w-4 h-4" /> : <EyeOff className="w-4 h-4" />}
<span className="text-xs font-bold uppercase tracking-wider">
{event.showTotalInPublic ? 'Total Visível' : 'Total Oculto'}
</span>
</div>
<div className={`w-8 h-4 rounded-full relative transition-colors ${event.showTotalInPublic ? 'bg-primary' : 'bg-zinc-700'}`}>
<div className={`absolute top-0.5 w-3 h-3 bg-white rounded-full transition-all ${event.showTotalInPublic ? 'right-0.5' : 'left-0.5'}`} />
</div>
</button>
<p className="text-[9px] text-muted italic">
{event.showTotalInPublic
? "* Atletas conseguem ver a meta e o total arrecadado no link público."
: "* Atletas verão apenas a lista de pagantes, sem valores totais."}
</p>
</div>
<div className="grid grid-cols-1 sm:grid-cols-2 md:grid-cols-3 gap-2">
{event.payments.map((payment: any) => (
<div key={payment.id} className="flex items-center justify-between p-2 rounded-lg bg-surface border border-border/50 group/item hover:border-primary/20 transition-colors">
<div className="flex items-center gap-3">
<div className={clsx("w-6 h-6 rounded-full flex items-center justify-center text-[10px] font-bold transition-colors",
payment.status === 'PAID' ? 'bg-green-500 text-black' : 'bg-surface-raised text-muted group-hover/item:bg-surface-raised/80'
)}>
{payment.status === 'PAID' ? <Check className="w-3 h-3" /> : payment.player?.name.substring(0, 1).toUpperCase()}
</div>
<span className={clsx("text-xs font-medium truncate max-w-[100px]", payment.status === 'PAID' ? 'text-foreground' : 'text-muted')}>
{payment.player?.name}
</span>
</div>
<div className="space-y-3">
<div className="flex items-center justify-between">
<h4 className="text-xs font-bold uppercase tracking-widest text-muted">Gerenciar Pagamentos</h4>
<Link
href={`/financial-report/${event.id}`}
className="text-[10px] font-bold text-primary hover:underline flex items-center gap-1"
target="_blank"
>
Ver Página Pública <ChevronRight className="w-3 h-3" />
</Link>
</div>
<button
onClick={() => togglePayment(payment.id, payment.status)}
className={clsx("text-[10px] font-bold px-2 py-1 rounded transition-colors",
payment.status === 'PENDING'
? "bg-primary/10 text-primary hover:bg-primary hover:text-background"
: "text-muted hover:text-red-500 hover:bg-red-500/10"
)}
>
{payment.status === 'PENDING' ? 'RECEBER' : 'DESFAZER'}
</button>
</div>
))}
<div className="grid grid-cols-1 sm:grid-cols-2 gap-2">
{event.payments.map((payment: any) => (
<div key={payment.id} className="flex items-center justify-between p-2 rounded-lg bg-surface border border-border/50 group/item hover:border-primary/20 transition-colors">
<div className="flex items-center gap-3">
<div className={clsx("w-6 h-6 rounded-full flex items-center justify-center text-[10px] font-bold transition-colors",
payment.status === 'PAID' ? 'bg-green-500 text-black' : 'bg-surface-raised text-muted group-hover/item:bg-surface-raised/80'
)}>
{payment.status === 'PAID' ? <Check className="w-3 h-3" /> : payment.player?.name.substring(0, 1).toUpperCase()}
</div>
<span className={clsx("text-xs font-medium truncate max-w-[100px]", payment.status === 'PAID' ? 'text-foreground' : 'text-muted')}>
{payment.player?.name}
</span>
</div>
<button
onClick={() => togglePayment(payment.id, payment.status)}
className={clsx("text-[10px] font-bold px-2 py-1 rounded transition-colors",
payment.status === 'PENDING'
? "bg-primary/10 text-primary hover:bg-primary hover:text-background"
: "text-muted hover:text-red-500 hover:bg-red-500/10"
)}
>
{payment.status === 'PENDING' ? 'RECEBER' : 'DESFAZER'}
</button>
</div>
))}
</div>
</div>
</div>
</div>
@@ -439,6 +508,12 @@ export function FinancialDashboard({ events, players }: FinancialPageProps) {
players={players}
/>
<FinancialSettingsModal
isOpen={isSettingsModalOpen}
onClose={() => setIsSettingsModalOpen(false)}
group={group}
/>
<DeleteConfirmationModal
isOpen={deleteModal.isOpen}
onClose={() => setDeleteModal(prev => ({ ...prev, isOpen: false }))}

View File

@@ -0,0 +1,102 @@
'use client'
import React, { useState } from 'react'
import { X, Shield, ShieldOff, Eye, EyeOff, Loader2 } from 'lucide-react'
import { updateFinancialSettings } from '@/actions/finance'
import { useRouter } from 'next/navigation'
import { motion, AnimatePresence } from 'framer-motion'
interface FinancialSettingsModalProps {
isOpen: boolean
onClose: () => void
group: any
}
export function FinancialSettingsModal({ isOpen, onClose, group }: FinancialSettingsModalProps) {
const router = useRouter()
const [isPending, setIsPending] = useState(false)
const [showTotal, setShowTotal] = useState(group.showTotalInPublic ?? true)
if (!isOpen) return null
const handleSave = async () => {
setIsPending(true)
try {
await updateFinancialSettings({ showTotalInPublic: showTotal })
onClose()
router.refresh()
} catch (error) {
console.error(error)
} finally {
setIsPending(false)
}
}
return (
<div className="fixed inset-0 z-[100] flex items-center justify-center p-4 bg-black/80 backdrop-blur-sm animate-in fade-in duration-200">
<motion.div
initial={{ opacity: 0, scale: 0.95, y: 20 }}
animate={{ opacity: 1, scale: 1, y: 0 }}
className="bg-surface border border-border rounded-2xl w-full max-w-md shadow-2xl overflow-hidden"
>
<div className="p-6 border-b border-border flex items-center justify-between bg-surface-raised/50">
<div>
<h3 className="text-lg font-black uppercase italic tracking-tighter text-primary">Configurações Financeiras</h3>
<p className="text-[10px] text-muted font-bold uppercase tracking-widest mt-1">Privacidade e Padrões</p>
</div>
<button onClick={onClose} className="p-2 hover:bg-white/5 rounded-full transition-colors">
<X className="w-5 h-5 text-muted" />
</button>
</div>
<div className="p-6 space-y-6">
<div className="space-y-4">
<div className="flex items-start gap-4 p-4 rounded-xl border border-white/5 bg-white/[0.02]">
<div className={`p-2 rounded-lg ${showTotal ? 'bg-primary/10 text-primary' : 'bg-red-500/10 text-red-500'}`}>
{showTotal ? <Eye className="w-5 h-5" /> : <EyeOff className="w-5 h-5" />}
</div>
<div className="flex-1 space-y-1">
<div className="flex items-center justify-between">
<h4 className="text-sm font-bold">Mostrar Total Arrecadado</h4>
<button
onClick={() => setShowTotal(!showTotal)}
className={`w-10 h-5 rounded-full transition-colors relative ${showTotal ? 'bg-primary' : 'bg-zinc-700'}`}
>
<div className={`absolute top-1 w-3 h-3 bg-white rounded-full transition-all ${showTotal ? 'right-1' : 'left-1'}`} />
</button>
</div>
<p className="text-[10px] text-muted leading-relaxed">
Define se os links públicos devem mostrar o valor total arrecadado e a meta.
Quando desativado, os atletas verão apenas a lista de quem pagou.
</p>
</div>
</div>
<div className="p-4 rounded-xl bg-orange-500/5 border border-orange-500/20">
<p className="text-[10px] text-orange-500 font-bold leading-relaxed">
* Esta configuração será aplicada como padrão para todos os NOVOS eventos criados.
Eventos existentes podem ser alterados individualmente.
</p>
</div>
</div>
</div>
<div className="p-6 border-t border-border flex justify-end gap-3 bg-surface-raised/30">
<button
onClick={onClose}
className="px-4 py-2 text-xs font-bold text-muted hover:text-foreground transition-colors"
>
Cancelar
</button>
<button
onClick={handleSave}
disabled={isPending}
className="ui-button px-6 h-10 text-xs font-black uppercase tracking-widest bg-white text-black shadow-md hover:scale-[1.02] active:scale-[0.98] transition-all"
>
{isPending ? <Loader2 className="w-4 h-4 animate-spin" /> : 'Salvar Alterações'}
</button>
</div>
</motion.div>
</div>
)
}

View File

@@ -1,19 +1,21 @@
'use client'
import React, { useState, useEffect } from 'react'
import { motion, AnimatePresence } from 'framer-motion'
import { Plus, Users, Calendar, Shuffle, Download, Trophy, Save, Check, Star, RefreshCw } from 'lucide-react'
import React, { useState, useEffect, useRef } from 'react'
import { motion } from 'framer-motion'
import { Shuffle, Download, Check, Star, RefreshCw, ChevronDown, Trophy, Zap, Shield } from 'lucide-react'
import { createMatch, updateMatchStatus } from '@/actions/match'
import { getMatchWithAttendance } from '@/actions/attendance'
import { toPng } from 'html-to-image'
import { renderMatchCard } from '@/utils/MatchCardCanvas'
import { clsx } from 'clsx'
import { useSearchParams } from 'next/navigation'
import { DateTimePicker } from '@/components/DateTimePicker'
import type { Arena } from '@prisma/client'
interface MatchFlowProps {
group: any
arenas?: Arena[]
sponsors?: any[]
}
const getInitials = (name: string) => {
@@ -26,7 +28,6 @@ const getInitials = (name: string) => {
.slice(0, 2)
}
// Simple seed-based random generator for transparency
const seededRandom = (seed: string) => {
let hash = 0
for (let i = 0; i < seed.length; i++) {
@@ -39,7 +40,79 @@ const seededRandom = (seed: string) => {
}
}
export function MatchFlow({ group, arenas = [] }: MatchFlowProps) {
// Sub-component for individual Canvas Preview
const CanvasTeamPreview = ({ team, group, date, location, drawSeed, sponsors, options, onDownload }: any) => {
const canvasRef = useRef<HTMLCanvasElement>(null);
const [rendering, setRendering] = useState(true);
useEffect(() => {
const draw = async () => {
if (canvasRef.current) {
setRendering(true);
const dateObj = new Date(date);
const day = dateObj.toLocaleDateString('pt-BR', { day: '2-digit' });
const month = dateObj.toLocaleDateString('pt-BR', { month: 'short' }).replace('.', '');
const time = dateObj.toLocaleTimeString('pt-BR', { hour: '2-digit', minute: '2-digit' });
await renderMatchCard(canvasRef.current, {
groupName: group.name,
logoUrl: group.logoUrl,
teamName: team.name,
teamColor: team.color,
day: day,
month: month,
time: time,
location: location || 'ARENA TEMFUT',
players: team.players,
sponsors: sponsors,
drawSeed: drawSeed,
options: options
});
setRendering(false);
}
};
draw();
}, [team, group, date, location, drawSeed, sponsors, options]);
return (
<div className="flex flex-col items-center w-full max-w-[460px] group">
<div className="w-full aspect-[9/16] bg-black rounded-[2.5rem] border border-white/10 shadow-2xl overflow-hidden relative">
{rendering && (
<div className="absolute inset-0 flex flex-col items-center justify-center bg-black/80 z-20 backdrop-blur-sm">
<RefreshCw className="w-8 h-8 text-primary animate-spin mb-4" />
<span className="text-[10px] font-black uppercase tracking-widest text-white/40">Renderizando Pixel Perfect...</span>
</div>
)}
<canvas
ref={canvasRef}
id={`canvas-${team.name.toLowerCase().replace(/\s+/g, '-')}`}
className="w-full h-full object-cover"
/>
</div>
<button
onClick={() => {
if (canvasRef.current) {
canvasRef.current.toBlob((blob) => {
if (blob) {
const url = URL.createObjectURL(blob);
const link = document.createElement('a');
link.download = `temfut-capa-${team.name.toLowerCase().replace(/\s+/g, '-')}.png`;
link.href = url;
link.click();
URL.revokeObjectURL(url);
}
}, 'image/png', 1.0);
}
}}
className="mt-6 ui-button w-full h-11 text-[10px] font-black uppercase tracking-[0.3em] shadow-md bg-white border-white/10 text-black hover:scale-[1.02] transition-all group-hover:shadow-primary/20"
>
<Download className="w-4 h-4 mr-2" /> BAIXAR CAPA HD
</button>
</div>
);
};
export function MatchFlow({ group, arenas = [], sponsors = [] }: MatchFlowProps) {
const searchParams = useSearchParams()
const scheduledMatchId = searchParams.get('id')
@@ -50,17 +123,31 @@ export function MatchFlow({ group, arenas = [] }: MatchFlowProps) {
const [teamCount, setTeamCount] = useState(2)
const [selectedPlayers, setSelectedPlayers] = useState<string[]>([])
const [teams, setTeams] = useState<any[]>([])
const [matchDate, setMatchDate] = useState(new Date().toISOString().split('T')[0])
const [matchDate, setMatchDate] = useState(() => {
const now = new Date()
const offset = now.getTimezoneOffset() * 60000
return new Date(now.getTime() - offset).toISOString().split('T')[0]
})
const [isSaving, setIsSaving] = useState(false)
const [drawSeed, setDrawSeed] = useState('')
const [location, setLocation] = useState('')
const [selectedArenaId, setSelectedArenaId] = useState('')
const [mounted, setMounted] = useState(false)
const [cardOptions, setCardOptions] = useState({
showNumbers: true,
showPositions: true,
showStars: true,
showSponsors: true,
})
useEffect(() => {
setMounted(true)
}, [])
useEffect(() => {
if (scheduledMatchId) {
loadScheduledData(scheduledMatchId)
}
// Generate a random seed on mount
generateNewSeed()
}, [scheduledMatchId])
@@ -78,7 +165,7 @@ export function MatchFlow({ group, arenas = [] }: MatchFlowProps) {
setActiveMatchId(data.id)
}
} catch (error) {
console.error('Erro ao carregar dados agendados:', error)
console.error('Erro ao lidar com dados agendados:', error)
}
}
@@ -96,9 +183,7 @@ export function MatchFlow({ group, arenas = [] }: MatchFlowProps) {
const playersToDraw = group.players.filter((p: any) => selectedPlayers.includes(p.id))
if (playersToDraw.length < teamCount) return
// Use the seed for transparency
const random = seededRandom(drawSeed)
let pool = [...playersToDraw]
const newTeams: any[] = Array.from({ length: teamCount }, (_, i) => ({
name: `Time ${i + 1}`,
@@ -107,10 +192,9 @@ export function MatchFlow({ group, arenas = [] }: MatchFlowProps) {
}))
if (drawMode === 'balanced') {
// Balanced still uses levels, but we shuffle within same levels or use seed for order
pool.sort((a, b) => {
if (b.level !== a.level) return b.level - a.level
return random() - 0.5 // Use seeded random for tie-breaking
return random() - 0.5
})
pool.forEach((p) => {
@@ -122,36 +206,17 @@ export function MatchFlow({ group, arenas = [] }: MatchFlowProps) {
teamWithLowestQuality.players.push(p)
})
} else {
// Pure random draw based on seed
pool = pool.sort(() => random() - 0.5)
pool.forEach((p, i) => {
newTeams[i % teamCount].players.push(p)
})
}
setTeams(newTeams)
}
const downloadImage = async (id: string) => {
const element = document.getElementById(id)
if (!element) return
try {
const dataUrl = await toPng(element, { backgroundColor: '#000' })
const link = document.createElement('a')
link.download = `temfut-time-${id}.png`
link.href = dataUrl
link.click()
} catch (err) {
console.error(err)
}
}
const handleConfirm = async () => {
setIsSaving(true)
try {
// If it was a scheduled match, we just update it. Otherwise create new.
// If it was a scheduled match, we just update it. Otherwise create new.
// For simplicity in this demo, createMatch now handles the seed and location.
const match = await createMatch(group.id, matchDate, teams, 'IN_PROGRESS', location, selectedPlayers.length, drawSeed, selectedArenaId)
setActiveMatchId(match.id)
setStep(2)
@@ -176,331 +241,220 @@ export function MatchFlow({ group, arenas = [] }: MatchFlowProps) {
}
return (
<div className="space-y-8 max-w-5xl mx-auto pb-20">
{/* Step Indicator */}
<div className="flex items-center gap-4">
<div className={clsx("h-1 flex-1 rounded-full bg-border relative overflow-hidden")}>
<div
className={clsx("absolute inset-0 bg-primary transition-all duration-700 ease-out")}
style={{ width: step === 1 ? '50%' : '100%' }}
/>
<div className="space-y-6 max-w-6xl mx-auto pb-10 px-4">
{/* Step Header */}
<div className="flex items-center justify-between py-2 border-b border-white/5">
<div className="flex items-center gap-2">
<div className={clsx("w-2 h-2 rounded-full", step === 1 ? "bg-primary shadow-[0_0_10px_#10b981]" : "bg-zinc-800")} />
<div className={clsx("w-2 h-2 rounded-full", step === 2 ? "bg-primary shadow-[0_0_10px_#10b981]" : "bg-zinc-800")} />
</div>
<div className="px-3 py-1 bg-zinc-900 border border-white/5 rounded-full">
<span className="text-[9px] font-black uppercase tracking-[0.2em] text-muted">Processo de Escalação</span>
</div>
<span className="text-xs font-bold text-muted uppercase tracking-[0.2em] whitespace-nowrap">
Fase de {step === 1 ? 'Escalação' : 'Distribuição'}
</span>
</div>
{step === 1 ? (
<div className="grid grid-cols-1 lg:grid-cols-12 gap-8">
{/* Controls Panel */}
<div className="grid grid-cols-1 lg:grid-cols-12 gap-6">
{/* LEFT: Setup de Campo */}
<div className="lg:col-span-4 space-y-6">
<div className="ui-card p-6 space-y-6">
<div className="ui-card p-6 space-y-6 bg-surface shadow-sm border-white/5">
<div className="space-y-1">
<h3 className="text-sm font-bold uppercase tracking-widest text-primary">Configuração</h3>
<p className="text-xs text-muted">Ajuste os parâmetros do sorteio.</p>
<h3 className="text-lg font-black uppercase italic tracking-tighter text-primary">Setup de Campo</h3>
<p className="text-[10px] text-muted font-bold uppercase tracking-widest">Sorteio Inteligente TemFut</p>
</div>
<div className="space-y-4">
<div className="ui-form-field">
<label className="text-label ml-0.5">Data da Partida</label>
<input
type="date"
value={matchDate}
onChange={(e) => setMatchDate(e.target.value)}
className="ui-input w-full h-12"
/>
<div className="space-y-5">
<div className="space-y-2">
<label className="text-[10px] font-black uppercase tracking-widest text-muted block ml-0.5">Data do Evento</label>
<DateTimePicker value={matchDate} onChange={setMatchDate} />
</div>
<div className="ui-form-field">
<label className="text-label ml-0.5">Local / Arena</label>
<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 h-12 bg-surface-raised"
style={{ appearance: 'none' }} // Custom arrow if needed, but default is fine for now
>
<option value="" className="bg-surface-raised text-muted">Selecione um local...</option>
{arenas?.map(arena => (
<option key={arena.id} value={arena.id} className="bg-surface-raised text-foreground">
{arena.name}
</option>
))}
</select>
{/* Fallback hidden input or just use location state if manual entry is needed later */}
</div>
<div className="ui-form-field">
<label className="text-label ml-0.5">Seed de Transparência</label>
<div className="flex gap-2">
<input
type="text"
value={drawSeed}
onChange={(e) => setDrawSeed(e.target.value.toUpperCase())}
className="ui-input flex-1 h-12 font-mono text-sm uppercase tracking-widest"
placeholder="SEED123"
/>
<button
onClick={generateNewSeed}
className="p-3 border border-border rounded-md hover:bg-white/5 transition-colors"
<div className="space-y-2">
<label className="text-[10px] font-black uppercase tracking-widest text-muted block ml-0.5">Arena/Gramado</label>
<div className="relative">
<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 h-10 bg-zinc-950/50 border-white/10 appearance-none focus:border-primary px-4 rounded-xl text-sm"
>
<RefreshCw className="w-5 h-5 text-muted" />
</button>
<option value="">Selecione o local...</option>
{arenas?.map(arena => <option key={arena.id} value={arena.id}>{arena.name}</option>)}
</select>
<ChevronDown className="absolute right-4 top-1/2 -translate-y-1/2 w-4 h-4 text-muted pointer-events-none" />
</div>
</div>
<div className="ui-form-field">
<label className="text-label ml-0.5">Número de Times</label>
<div className="grid grid-cols-3 gap-2">
{[2, 3, 4].map(n => (
<button
key={n}
onClick={() => setTeamCount(n)}
className={clsx(
"py-3 text-sm font-bold rounded-md border transition-all",
teamCount === n ? "bg-primary text-background border-primary" : "bg-transparent border-border text-muted"
)}
>
{n}
</button>
))}
<div className="grid grid-cols-2 gap-4">
<div className="space-y-2">
<label className="text-[10px] font-black uppercase tracking-widest text-muted block ml-0.5">Times</label>
<div className="flex bg-zinc-950 p-1 rounded-xl border border-white/10">
{[2, 3, 4].map(n => (
<button key={n} onClick={() => setTeamCount(n)} className={clsx("flex-1 py-1.5 text-xs font-black rounded-lg transition-all", teamCount === n ? "bg-white text-black" : "text-muted hover:text-white")}>{n}</button>
))}
</div>
</div>
</div>
<div className="ui-form-field">
<label className="text-label ml-0.5">Modo de Sorteio</label>
<div className="grid grid-cols-2 gap-2">
<button
onClick={() => setDrawMode('balanced')}
className={clsx(
"py-3 text-xs font-bold rounded-md border transition-all uppercase",
drawMode === 'balanced' ? "bg-primary text-background border-primary" : "bg-transparent border-border text-muted"
)}
>
Equilibrado
</button>
<button
onClick={() => setDrawMode('random')}
className={clsx(
"py-3 text-xs font-bold rounded-md border transition-all uppercase",
drawMode === 'random' ? "bg-primary text-background border-primary" : "bg-transparent border-border text-muted"
)}
>
Aleatório
</button>
<div className="space-y-2">
<label className="text-[10px] font-black uppercase tracking-widest text-muted block ml-0.5">Equilibrado</label>
<div className="flex bg-zinc-950 p-1 rounded-xl border border-white/10">
<button onClick={() => setDrawMode('balanced')} className={clsx("flex-1 py-1.5 text-[9px] font-black uppercase rounded-lg transition-all", drawMode === 'balanced' ? "bg-primary text-black" : "text-muted")}>Sim</button>
<button onClick={() => setDrawMode('random')} className={clsx("flex-1 py-1.5 text-[9px] font-black uppercase rounded-lg transition-all", drawMode === 'random' ? "bg-primary text-black" : "text-muted")}>Não</button>
</div>
</div>
</div>
</div>
<button
onClick={performDraw}
disabled={selectedPlayers.length < teamCount}
className="ui-button w-full h-14 text-sm font-bold shadow-lg shadow-emerald-500/10"
>
<Shuffle className="w-5 h-5 mr-2" /> Sortear Atletas
<button onClick={performDraw} disabled={selectedPlayers.length < teamCount} className="ui-button w-full h-11 text-xs font-black uppercase tracking-[0.2em] bg-white text-black hover:bg-zinc-100 shadow-md transition-all group">
<Shuffle className="w-4 h-4 mr-2 group-hover:rotate-180 transition-transform duration-500" /> Gerar Sorteio
</button>
</div>
{scheduledMatchId && (
<div className="p-4 bg-primary/5 border border-primary/20 rounded-lg space-y-2">
<p className="text-[10px] font-bold uppercase text-primary tracking-wider">Evento Agendado</p>
<p className="text-[11px] text-muted leading-relaxed">
Importamos automaticamente {selectedPlayers.length} atletas que confirmaram presença pelo link público.
</p>
</div>
)}
</div>
{/* Players Selection Grid */}
{/* RIGHT: Lista de Chamada */}
<div className="lg:col-span-8 space-y-6">
<div className="ui-card p-6">
<div className="flex items-center justify-between mb-6">
<div className="space-y-1">
<h3 className="text-sm font-bold uppercase tracking-widest text-primary">Convocação</h3>
<p className="text-xs text-muted">Selecione os atletas presentes em campo.</p>
<div className="ui-card p-6 bg-surface shadow-sm border-white/5">
<div className="flex items-center justify-between mb-6 border-b border-white/5 pb-4">
<div>
<h3 className="text-xl font-black uppercase italic tracking-tighter leading-none">Lista de Chamada</h3>
<p className="text-[10px] text-muted font-bold uppercase tracking-widest mt-1">Confirme os atletas presentes</p>
</div>
<div className="text-right">
<span className="text-2xl font-bold">{selectedPlayers.length}</span>
<span className="text-xs font-bold text-muted uppercase ml-2 tracking-widest">Atletas</span>
<div className="flex items-center gap-6">
<div className="text-right border-r border-white/5 pr-6">
<span className="text-2xl font-black text-primary leading-none">{selectedPlayers.length}</span>
<p className="text-[9px] font-bold uppercase text-muted tracking-widest">Atletas</p>
</div>
<button onClick={() => setSelectedPlayers(group.players.map((p: any) => p.id))} className="text-[9px] font-black uppercase text-muted hover:text-white px-4 py-1.5 border border-white/10 rounded-full transition-all bg-black/40">Selecionar Todos</button>
</div>
</div>
<div className="grid grid-cols-1 sm:grid-cols-2 xl:grid-cols-2 gap-3 max-h-[500px] overflow-y-auto pr-2 custom-scrollbar">
<div className="grid grid-cols-1 md:grid-cols-2 gap-2 max-h-[500px] overflow-y-auto pr-4 custom-scrollbar">
{group.players.map((p: any) => (
<button
key={p.id}
onClick={() => togglePlayer(p.id)}
className={clsx(
"flex items-center justify-between p-3 rounded-lg border transition-all text-left group",
selectedPlayers.includes(p.id)
? "bg-primary/5 border-primary shadow-sm"
: "bg-surface border-border hover:border-white/20"
"flex items-center gap-4 p-3 rounded-2xl border transition-all text-left group relative",
selectedPlayers.includes(p.id) ? "bg-primary/5 border-primary" : "bg-black/20 border-white/5 hover:border-white/10"
)}
>
<div className="flex items-center gap-3">
<div className={clsx(
"w-10 h-10 rounded-lg border flex items-center justify-center font-mono font-bold text-xs transition-all",
selectedPlayers.includes(p.id) ? "bg-primary text-background border-primary" : "bg-surface-raised border-border"
)}>
{p.number !== null && p.number !== undefined ? p.number : getInitials(p.name)}
</div>
<div>
<p className="text-sm font-bold tracking-tight">{p.name}</p>
<div className="flex items-center gap-2 mt-0.5">
<span className="text-xs text-muted font-bold uppercase tracking-wider">{p.position}</span>
<div className="flex gap-0.5">
{[...Array(5)].map((_, i) => (
<Star key={i} className={clsx(
"w-2.5 h-2.5",
i < p.level ? "text-primary fill-primary" : "text-border fill-transparent"
)} />
))}
</div>
<div className={clsx("w-10 h-10 rounded-xl flex items-center justify-center font-black text-xs border transition-all", selectedPlayers.includes(p.id) ? "bg-primary text-black border-primary shadow-[0_0_10px_rgba(16,185,129,0.2)]" : "bg-zinc-950 border-white/10 text-muted")}>
{p.number || getInitials(p.name)}
</div>
<div className="flex-1">
<p className="text-sm font-black uppercase tracking-tight group-hover:text-primary transition-colors line-clamp-1">{p.name}</p>
<div className="flex items-center gap-3 mt-1 opacity-40">
<span className="text-[9px] font-black uppercase">{p.position}</span>
<div className="flex gap-0.5">
{[...Array(5)].map((_, s) => <Star key={s} className={clsx("w-2.5 h-2.5", s < (p.level || 0) ? "fill-primary text-primary" : "text-white/10")} />)}
</div>
</div>
</div>
<div className={clsx(
"w-5 h-5 rounded-full border flex items-center justify-center transition-all",
selectedPlayers.includes(p.id) ? "bg-primary border-primary" : "border-border"
)}>
{selectedPlayers.includes(p.id) && <Check className="w-3 h-3 text-background font-bold" />}
</div>
{selectedPlayers.includes(p.id) && <Check className="w-4 h-4 text-primary" />}
</button>
))}
</div>
</div>
{/* Temp Draw Overlay / Result Preview */}
<AnimatePresence>
{teams.length > 0 && (
<motion.div
initial={{ opacity: 0, scale: 0.98 }}
animate={{ opacity: 1, scale: 1 }}
className="ui-card p-6 bg-surface-raised border-primary/20 shadow-xl space-y-6"
>
<div className="flex items-center justify-between border-b border-white/5 pb-4">
<div className="space-y-1">
<h3 className="text-sm font-bold uppercase tracking-widest text-primary">Sorteio Concluído</h3>
<p className="text-xs text-muted font-mono">HASH: {drawSeed}</p>
</div>
<button
onClick={handleConfirm}
disabled={isSaving}
className="ui-button h-12 px-8 font-bold text-sm"
>
{isSaving ? 'Salvando...' : 'Confirmar & Ver Capas'}
</button>
{/* PREVIEW DO SORTEIO SECTION */}
{teams.length > 0 && (
<motion.div initial={{ opacity: 0, scale: 0.98 }} animate={{ opacity: 1, scale: 1 }} className="space-y-6">
<div className="flex items-center justify-between px-2">
<div className="flex items-center gap-2">
<div className="w-1 h-4 bg-primary rounded-full" />
<h3 className="text-sm font-black uppercase italic tracking-tighter text-primary">Resultado Preview</h3>
</div>
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
{teams.map((t, i) => (
<div key={i} className="ui-card p-4 bg-background/50 border-white/5">
<div className="flex items-center gap-2 mb-4">
<div className="w-2 h-2 rounded-full" style={{ backgroundColor: t.color }} />
<h4 className="text-xs font-bold uppercase tracking-widest">{t.name}</h4>
</div>
<div className="space-y-2">
{t.players.map((p: any) => (
<div key={p.id} className="text-xs flex items-center justify-between py-2 border-b border-white/5 last:border-0">
<div className="flex items-center gap-3">
<span className="font-mono text-primary font-bold w-4">{p.number !== null && p.number !== undefined ? p.number : getInitials(p.name)}</span>
<span className="font-medium">{p.name}</span>
</div>
<span className="text-[10px] uppercase font-bold text-muted">{p.position}</span>
</div>
))}
</div>
<button onClick={handleConfirm} disabled={isSaving} className="ui-button px-8 h-10 text-xs font-black uppercase tracking-widest bg-white text-black shadow-md">
{isSaving ? 'Salvando...' : 'Confirmar & Ver Capas'}
</button>
</div>
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
{teams.map((t, i) => (
<div key={i} className="ui-card p-4 bg-surface shadow-sm border-white/5 relative overflow-hidden">
<div className="flex items-center gap-3 mb-4">
<div className="w-2.5 h-2.5 rounded-full" style={{ backgroundColor: t.color, boxShadow: `0 0 10px ${t.color}` }} />
<h4 className="text-sm font-black uppercase italic">{t.name}</h4>
</div>
))}
</div>
</motion.div>
)}
</AnimatePresence>
<div className="space-y-1 font-sans">
{t.players.map((p: any) => (
<div key={p.id} className="flex items-center justify-between py-2 border-b border-white/[0.03] last:border-0 hover:bg-white/[0.02] px-2 rounded-lg transition-all">
<span className="text-xs font-bold text-white/80 uppercase line-clamp-1">{p.name}</span>
<span className="text-[9px] font-black uppercase text-muted bg-white/5 px-2 py-0.5 rounded-md">{p.position}</span>
</div>
))}
</div>
</div>
))}
</div>
</motion.div>
)}
</div>
</div>
) : (
/* Step 2: Custom Download Cards */
<div className="space-y-8 animate-in fade-in slide-in-from-bottom-4 duration-1000">
<div className="flex items-center justify-between px-2">
<div className="space-y-1">
<h2 className="text-3xl font-bold tracking-tight">Capas Exclusivas</h2>
<p className="text-muted text-sm flex items-center gap-2">
Sorteio Auditado com Seed <span className="text-primary font-mono font-bold">{drawSeed}</span>
</p>
/* STEP 2: CANVAS PREMIUM COVERS */
<div className="space-y-12 animate-in fade-in slide-in-from-bottom-10 duration-1000 pb-20">
<div className="flex flex-col xl:flex-row items-center justify-between gap-8 px-8 bg-zinc-900/40 p-8 rounded-[2rem] border border-white/5 backdrop-blur-md shadow-xl">
<div className="flex flex-col md:flex-row items-center gap-8">
<div className="space-y-1 text-center md:text-left">
<h2 className="text-3xl font-black uppercase italic tracking-tighter leading-none">Capas de Convocação</h2>
<p className="text-[10px] text-muted font-bold uppercase tracking-[0.3em]">Canvas Engine v3.0 HD</p>
</div>
{/* Card Customization Toolbar */}
<div className="flex bg-black/60 p-1.5 rounded-2xl border border-white/5 backdrop-blur-sm">
{[
{ key: 'showNumbers', label: 'Nº' },
{ key: 'showPositions', label: 'Pos' },
{ key: 'showStars', label: 'Nível' },
{ key: 'showSponsors', label: 'Patr' },
].map((opt) => (
<button
key={opt.key}
onClick={() => setCardOptions(prev => ({ ...prev, [opt.key]: !(prev as any)[opt.key] }))}
className={clsx(
"px-4 py-2 text-[10px] font-black uppercase tracking-widest rounded-xl transition-all flex items-center gap-2",
(cardOptions as any)[opt.key] ? "bg-primary text-black" : "text-muted hover:text-white"
)}
>
<div className={clsx("w-3 h-3 rounded-sm border flex items-center justify-center", (cardOptions as any)[opt.key] ? "border-black bg-black" : "border-muted")}>
{(cardOptions as any)[opt.key] && <Check className="w-2.5 h-2.5 text-primary" />}
</div>
{opt.label}
</button>
))}
</div>
</div>
<div className="flex items-center gap-3">
<button onClick={() => setStep(1)} className="ui-button px-5 h-10 text-[9px] font-black uppercase tracking-widest border-white/10 hover:bg-white/5 transition-all">Voltar Sorteio</button>
<button onClick={handleFinish} className="ui-button px-8 h-10 text-[9px] font-black uppercase tracking-widest bg-white text-black hover:bg-zinc-200">Finalizar Pelada</button>
</div>
<button
onClick={handleFinish}
disabled={isSaving}
className="ui-button px-10 h-12 text-sm font-bold uppercase tracking-[0.2em]"
>
Finalizar Registro
</button>
</div>
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-8">
<div className="grid grid-cols-1 lg:grid-cols-2 gap-12 items-start justify-items-center">
{teams.map((t, i) => (
<div key={i} className="space-y-4">
<div
id={`team-${i}`}
className="aspect-[4/5] bg-background rounded-2xl border border-white/10 p-8 flex flex-col items-center justify-between shadow-2xl overflow-hidden relative"
>
{/* Abstract Patterns */}
<div className="absolute top-0 right-0 w-48 h-48 bg-primary/10 blur-[80px] rounded-full -mr-24 -mt-24" />
<div className="absolute bottom-0 left-0 w-32 h-32 bg-primary/5 blur-[60px] rounded-full -ml-16 -mb-16" />
<div className="text-center relative z-10 w-full">
<div className="w-14 h-14 bg-surface-raised rounded-2xl border border-white/10 flex items-center justify-center mx-auto mb-6 shadow-xl">
<Trophy className="w-6 h-6 text-primary" />
</div>
<div className="space-y-1">
<p className="text-[10px] font-bold text-primary uppercase tracking-[0.4em]">TemFut Pro</p>
<h3 className="text-2xl font-black uppercase tracking-tighter">{t.name}</h3>
<div className="h-1.5 w-12 bg-primary mx-auto mt-4 rounded-full" />
</div>
</div>
<div className="w-full space-y-3 relative z-10 flex-1 flex flex-col justify-center my-8 text-sm px-2">
{t.players.map((p: any) => (
<div key={p.id} className="flex items-center justify-between py-2.5 border-b border-white/5 last:border-0">
<div className="flex items-center gap-4">
<span className="font-mono text-primary font-black text-sm w-6">
{p.number !== null && p.number !== undefined ? p.number : getInitials(p.name)}
</span>
<div className="flex flex-col">
<span className="font-bold text-xs uppercase tracking-tight">{p.name}</span>
<div className="flex gap-0.5 mt-1">
{[...Array(5)].map((_, i) => (
<Star key={i} className={clsx(
"w-2 h-2",
i < p.level ? "text-primary fill-primary" : "text-white/10 fill-transparent"
)} />
))}
</div>
</div>
</div>
<div className="text-right">
<p className="text-[9px] font-black uppercase text-primary tracking-widest">{p.position}</p>
</div>
</div>
))}
</div>
<div className="w-full flex items-center justify-between relative z-10">
<p className="text-[8px] text-muted font-black uppercase tracking-[0.3em] font-mono">{drawSeed}</p>
<p className="text-[10px] text-muted font-bold uppercase tracking-widest">{matchDate}</p>
</div>
</div>
<button
onClick={() => downloadImage(`team-${i}`)}
className="ui-button-ghost w-full py-4 text-xs font-bold uppercase tracking-widest hover:border-primary/50"
>
<Download className="w-5 h-5 mr-2" /> Baixar Card de Time
</button>
</div>
<CanvasTeamPreview
key={i}
team={t}
group={group}
date={matchDate}
location={location}
drawSeed={drawSeed}
sponsors={sponsors}
options={cardOptions}
/>
))}
</div>
<div className="flex items-center justify-center pt-10 border-t border-white/5 opacity-40">
<div className="flex items-center gap-4">
<Shield className="w-5 h-5 text-primary" />
<span className="text-[10px] font-black uppercase tracking-[0.5em]">Gerado via TemFut Pixel Perfect Engine</span>
</div>
</div>
</div>
)}
</div>

View File

@@ -1,6 +1,6 @@
'use client'
import React, { useState, useMemo } from 'react'
import React, { useState, useMemo, useEffect } from 'react'
import { Calendar, Users, Trophy, ChevronRight, X, Clock, ExternalLink, Star, Link as LinkIcon, MapPin, Share2, Shuffle, Trash2, MessageCircle, Repeat, Search, LayoutGrid, List, Check } from 'lucide-react'
import { motion, AnimatePresence } from 'framer-motion'
import { clsx } from 'clsx'
@@ -19,7 +19,7 @@ const getInitials = (name: string) => {
.slice(0, 2)
}
export function MatchHistory({ matches, players = [] }: { matches: any[], players?: any[] }) {
export function MatchHistory({ matches, players = [], groupName = 'Pelada' }: { matches: any[], players?: any[], groupName?: string }) {
const [selectedMatch, setSelectedMatch] = useState<any | null>(null)
const [modalTab, setModalTab] = useState<'confirmed' | 'unconfirmed'>('confirmed')
@@ -28,6 +28,12 @@ export function MatchHistory({ matches, players = [] }: { matches: any[], player
const [viewMode, setViewMode] = useState<'grid' | 'list'>('list') // Default to list for history
const [currentPage, setCurrentPage] = useState(1)
const [selectedIds, setSelectedIds] = useState<Set<string>>(new Set())
const [copySuccess, setCopySuccess] = useState<string | null>(null)
const [mounted, setMounted] = useState(false)
useEffect(() => {
setMounted(true)
}, [])
// Confirmation Modal State
const [deleteModal, setDeleteModal] = useState<{
@@ -151,37 +157,69 @@ export function MatchHistory({ matches, players = [] }: { matches: any[], player
}
}
const shareMatchLink = (match: any) => {
const copyMatchLink = (match: any, silent = false) => {
const url = `${window.location.origin}/match/${match.id}/confirmacao`
navigator.clipboard.writeText(url)
if (!silent) setCopySuccess('Link de confirmação copiado!')
setTimeout(() => setCopySuccess(null), 2000)
}
const shareMatchWhatsApp = (match: any) => {
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 text = `⚽ *CONVOCAÇÃO: ${match.group?.name || 'TEMFUT'}* ⚽\n\n` +
`📍 *Local:* ${match.location || 'A definir'}\n` +
`📅 *Data:* ${dateStr} às ${timeStr}\n\n` +
`Confirme sua presença pelo link abaixo:\n🔗 ${url}`
`Confirmem presença pelo link oficial:\n🔗 ${url}`
navigator.clipboard.writeText(url)
window.open(`https://api.whatsapp.com/send?text=${encodeURIComponent(text)}`, '_blank')
}
const shareWhatsAppList = (match: any) => {
const confirmed = (match.attendances || []).filter((a: any) => a.status === 'CONFIRMED')
const unconfirmed = players.filter(p => !match.attendances?.find((a: any) => a.playerId === p.id && 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 text = `⚽ *CONVOCAÇÃO: ${match.group?.name || 'TEMFUT'}* ⚽\n\n` +
`📍 *Local:* ${match.location || 'A definir'}\n` +
`📅 *Data:* ${new Date(match.date).toLocaleDateString('pt-BR', { weekday: 'long', day: 'numeric', month: 'short' })} às ${new Date(match.date).toLocaleTimeString('pt-BR', { hour: '2-digit', minute: '2-digit' })}\n\n` +
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 finalGroupName = match.group?.name || groupName
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.map((a: any, i: number) => `${i + 1}. ${a.player.name}`).join('\n') +
`\n\n❌ *AGUARDANDO:* \n` +
unconfirmed.map((p: any) => `- ${p.name}`).join('\n') +
`\n\n🔗 *Confirme pelo link:* ${url}`
(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}`
navigator.clipboard.writeText(text)
const whatsappUrl = `https://api.whatsapp.com/send?text=${encodeURIComponent(text)}`
window.open(whatsappUrl, '_blank')
setCopySuccess('Lista formatada copiada!')
setTimeout(() => setCopySuccess(null), 2000)
window.open(`https://api.whatsapp.com/send?text=${encodeURIComponent(text)}`, '_blank')
}
const openMatchLink = (match: any) => {
const url = `${window.location.origin}/match/${match.id}/confirmacao`
window.open(url, '_blank')
}
const copyAgendaLink = () => {
const url = `${window.location.origin}/agenda`
navigator.clipboard.writeText(url)
setCopySuccess('Link da agenda copiado!')
setTimeout(() => setCopySuccess(null), 2000)
}
return (
@@ -191,14 +229,26 @@ export function MatchHistory({ matches, players = [] }: { matches: any[], player
<h2 className="text-xl font-bold tracking-tight">Histórico de Partidas</h2>
<p className="text-xs text-muted font-medium">Gerencie o histórico e agende novos eventos.</p>
</div>
<AnimatePresence>
{copySuccess && (
<motion.div
initial={{ opacity: 0, y: -20, scale: 0.9 }}
animate={{ opacity: 1, y: 0, scale: 1 }}
exit={{ opacity: 0, y: -20, scale: 0.9 }}
className="fixed top-12 left-1/2 -translate-x-1/2 z-[200] bg-primary text-background px-6 py-2.5 rounded-full font-black text-[10px] uppercase tracking-[0.2em] shadow-2xl flex items-center gap-2 border border-white/20"
>
<Check className="w-3 h-3" /> {copySuccess}
</motion.div>
)}
</AnimatePresence>
<div className="flex flex-col sm:flex-row items-center gap-4">
{selectedIds.size > 0 ? (
<div className="flex items-center gap-2 w-full sm:w-auto animate-in fade-in slide-in-from-right-4">
<span className="text-xs font-bold text-muted uppercase tracking-wider px-3">{selectedIds.size} selecionados</span>
<div className="flex items-center gap-2 w-full sm:w-auto animate-in fade-in slide-in-from-right-4 bg-surface-raised border border-border rounded-xl px-2 pr-2 py-1">
<span className="text-[10px] font-black text-muted uppercase tracking-[0.2em] px-3">{selectedIds.size} SELECIONADOS</span>
<button
onClick={handleBulkDelete}
className="ui-button bg-red-500/10 text-red-500 hover:bg-red-500/20 border-red-500/20 h-10 w-full sm:w-auto"
className="ui-button bg-red-500/10 text-red-500 hover:bg-red-500/20 border-red-500/20 h-9 px-4 rounded-lg"
>
<Trash2 className="w-4 h-4 mr-2" /> Excluir
</button>
@@ -240,15 +290,27 @@ export function MatchHistory({ matches, players = [] }: { matches: any[], player
<AnimatePresence mode='popLayout'>
{paginatedMatches.length > 0 && (
<div className={clsx("col-span-full flex items-center px-2 mb-2", viewMode === 'grid' ? "justify-end" : "")}>
<div className="flex items-center gap-2">
<input
type="checkbox"
checked={selectedIds.size === paginatedMatches.length && paginatedMatches.length > 0}
onChange={toggleSelectAll}
className="w-4 h-4 rounded border-border text-primary bg-background"
/>
<span className="text-[10px] font-bold uppercase text-muted">Selecionar Todos</span>
</div>
<button
onClick={toggleSelectAll}
className="flex items-center gap-2 group cursor-pointer"
>
<div className={clsx(
"w-5 h-5 rounded-lg border-2 flex items-center justify-center transition-all duration-200",
selectedIds.size === paginatedMatches.length && paginatedMatches.length > 0
? "bg-primary border-primary shadow-[0_0_10px_rgba(16,185,129,0.4)]"
: "border-muted/30 bg-surface/50 group-hover:border-primary/50"
)}>
{selectedIds.size === paginatedMatches.length && paginatedMatches.length > 0 && (
<Check className="w-3.5 h-3.5 text-background font-bold stroke-[3]" />
)}
</div>
<span className={clsx(
"text-[10px] font-bold uppercase tracking-widest transition-colors",
selectedIds.size === paginatedMatches.length ? "text-primary" : "text-muted group-hover:text-foreground"
)}>
Selecionar Todos
</span>
</button>
</div>
)}
@@ -269,23 +331,28 @@ export function MatchHistory({ matches, players = [] }: { matches: any[], player
)}
>
{/* Checkbox for selection */}
<div onClick={(e) => e.stopPropagation()} className="absolute top-4 right-4 z-20">
<input
type="checkbox"
checked={selectedIds.has(match.id)}
onChange={() => toggleSelection(match.id)}
className={clsx(
"w-5 h-5 rounded border-border text-primary bg-surface transition-all",
selectedIds.has(match.id) || "opacity-0 group-hover:opacity-100"
)}
/>
<div
onClick={(e) => {
e.stopPropagation()
toggleSelection(match.id)
}}
className={clsx("z-20 p-2", viewMode === 'grid' ? "absolute top-4 right-4 -mr-2 -mt-2" : "mr-4 -ml-2")}
>
<div className={clsx(
"w-5 h-5 rounded-lg border-2 flex items-center justify-center transition-all duration-200",
selectedIds.has(match.id)
? "bg-primary border-primary shadow-[0_0_10px_rgba(16,185,129,0.4)] scale-110"
: "border-muted/30 bg-surface/50 opacity-0 group-hover:opacity-100 hover:border-primary/50"
)}>
{selectedIds.has(match.id) && <Check className="w-3.5 h-3.5 text-background font-bold stroke-[3]" />}
</div>
</div>
<div className={clsx("flex items-center gap-4", viewMode === 'list' ? "flex-1" : "w-full")}>
<div className="p-3 bg-surface-raised border border-border rounded-lg text-center min-w-[56px] shrink-0">
<p className="text-sm font-bold leading-none">{new Date(match.date).getDate()}</p>
<p className="text-sm font-bold leading-none">{mounted ? new Date(match.date).getDate() : '...'}</p>
<p className="text-[10px] text-muted uppercase font-medium mt-1">
{new Date(match.date).toLocaleDateString('pt-BR', { month: 'short' }).replace('.', '')}
{mounted ? new Date(match.date).toLocaleDateString('pt-BR', { month: 'short' }).replace('.', '') : '...'}
</p>
</div>
@@ -316,7 +383,7 @@ export function MatchHistory({ matches, players = [] }: { matches: any[], player
<div className="w-1 h-1 rounded-full bg-border" />
<div className="flex items-center gap-1">
<Clock className="w-3 h-3" />
{new Date(match.date).toLocaleTimeString('pt-BR', { hour: '2-digit', minute: '2-digit' })}
{mounted ? new Date(match.date).toLocaleTimeString('pt-BR', { hour: '2-digit', minute: '2-digit' }) : '--:--'}
</div>
{/* Actions only in List Mode here, else in modal */}
@@ -326,12 +393,12 @@ export function MatchHistory({ matches, players = [] }: { matches: any[], player
<button
onClick={(e) => {
e.stopPropagation()
shareWhatsAppList(match)
copyMatchLink(match)
}}
className="p-1.5 text-primary hover:bg-primary/10 rounded transition-colors"
title="Copiar Link"
title="Copiar Link de Confirmação"
>
<Share2 className="w-4 h-4" />
<LinkIcon className="w-4 h-4" />
</button>
)}
<ChevronRight className="w-4 h-4" />
@@ -352,27 +419,29 @@ export function MatchHistory({ matches, players = [] }: { matches: any[], player
</div>
{/* Pagination */}
{totalPages > 1 && (
<div className="flex justify-center items-center gap-4 mt-8">
<button
onClick={() => setCurrentPage(p => Math.max(1, p - 1))}
disabled={currentPage === 1}
className="p-2 rounded-lg border border-border hover:bg-surface-raised disabled:opacity-50 disabled:cursor-not-allowed transition-colors"
>
<ChevronRight className="w-4 h-4 rotate-180" />
</button>
<span className="text-xs font-bold text-muted uppercase tracking-widest">
Página {currentPage} de {totalPages}
</span>
<button
onClick={() => setCurrentPage(p => Math.min(totalPages, p + 1))}
disabled={currentPage === totalPages}
className="p-2 rounded-lg border border-border hover:bg-surface-raised disabled:opacity-50 disabled:cursor-not-allowed transition-colors"
>
<ChevronRight className="w-4 h-4" />
</button>
</div>
)}
{
totalPages > 1 && (
<div className="flex justify-center items-center gap-4 mt-8">
<button
onClick={() => setCurrentPage(p => Math.max(1, p - 1))}
disabled={currentPage === 1}
className="p-2 rounded-lg border border-border hover:bg-surface-raised disabled:opacity-50 disabled:cursor-not-allowed transition-colors"
>
<ChevronRight className="w-4 h-4 rotate-180" />
</button>
<span className="text-xs font-bold text-muted uppercase tracking-widest">
Página {currentPage} de {totalPages}
</span>
<button
onClick={() => setCurrentPage(p => Math.min(totalPages, p + 1))}
disabled={currentPage === totalPages}
className="p-2 rounded-lg border border-border hover:bg-surface-raised disabled:opacity-50 disabled:cursor-not-allowed transition-colors"
>
<ChevronRight className="w-4 h-4" />
</button>
</div>
)
}
{/* Modal Overlay */}
<AnimatePresence>
@@ -400,12 +469,12 @@ export function MatchHistory({ matches, players = [] }: { matches: any[], player
</span>
</div>
<p className="text-sm text-muted">
{new Date(selectedMatch.date).toLocaleDateString('pt-BR', {
{mounted ? new Date(selectedMatch.date).toLocaleDateString('pt-BR', {
weekday: 'long',
day: 'numeric',
month: 'long',
year: 'numeric'
})}
}) : 'Carregando data...'}
</p>
</div>
<div className="flex items-center gap-2">
@@ -575,16 +644,28 @@ export function MatchHistory({ matches, players = [] }: { matches: any[], player
</button>
<div className="flex flex-col sm:flex-row gap-2 order-1 sm:order-2 w-full sm:w-auto">
<button
onClick={() => shareWhatsAppList(selectedMatch)}
className="ui-button-ghost py-3 border-primary/20 text-primary hover:bg-primary/5 h-12 sm:h-auto"
onClick={() => copyMatchLink(selectedMatch)}
className="ui-button-ghost py-3 border-white/10 text-muted hover:text-white h-12 sm:h-auto"
title="Apenas copiar o link exclusivo"
>
<MessageCircle className="w-4 h-4 mr-2" /> Copiar Lista
<LinkIcon className="w-4 h-4 mr-2" /> Copiar Link
</button>
<button
onClick={() => shareMatchLink(selectedMatch)}
className="ui-button py-3 h-12 sm:h-auto"
onClick={() => openMatchLink(selectedMatch)}
className="ui-button-ghost py-3 border-primary/20 text-primary hover:bg-primary/5 h-12 sm:h-auto"
title="Abrir página de visualização do evento"
>
<LinkIcon className="w-4 h-4 mr-2" /> Compartilhar Link
<ExternalLink className="w-4 h-4 mr-2" /> Ver Evento
</button>
<button
onClick={() => shareWhatsAppList(selectedMatch)}
className="ui-button py-2.5 h-12 sm:h-auto shadow-lg shadow-emerald-500/20 bg-emerald-600 hover:bg-emerald-700 border-none text-white font-bold"
title="Copiar lista e enviar para o WhatsApp"
>
<svg className="w-4 h-4 mr-2" viewBox="0 0 24 24" fill="currentColor">
<path d="M17.472 14.382c-.297-.149-1.758-.867-2.03-.967-.273-.099-.471-.148-.67.15-.197.297-.767.966-.94 1.164-.173.199-.347.223-.644.075-.297-.15-1.255-.463-2.39-1.475-.883-.788-1.48-1.761-1.653-2.059-.173-.297-.018-.458.13-.606.134-.133.298-.347.446-.52.149-.174.198-.298.298-.497.099-.198.05-.371-.025-.52-.075-.149-.669-1.612-.916-2.207-.242-.579-.487-.5-.669-.51-.173-.008-.371-.01-.57-.01-.198 0-.52.074-.792.372-.272.297-1.04 1.016-1.04 2.479 0 1.462 1.065 2.875 1.213 3.074.149.198 2.096 3.2 5.077 4.487.709.306 1.262.489 1.694.625.712.227 1.36.195 1.871.118.571-.085 1.758-.719 2.006-1.413.248-.694.248-1.289.173-1.413-.074-.124-.272-.198-.57-.347m-5.421 7.403h-.004a9.87 9.87 0 01-5.031-1.378l-.361-.214-3.741.982.998-3.648-.235-.374a9.86 9.86 0 01-1.51-5.26c.001-5.45 4.436-9.884 9.888-9.884 2.64 0 5.122 1.03 6.988 2.898a9.825 9.825 0 012.893 6.994c-.003 5.45-4.437 9.884-9.885 9.884m8.413-18.297A11.815 11.815 0 0012.05 0C5.495 0 .16 5.335.157 11.892c0 2.096.547 4.142 1.588 5.945L.057 24l6.305-1.654a11.882 11.882 0 005.683 1.448h.005c6.554 0 11.89-5.335 11.893-11.893a11.821 11.821 0 00-3.48-8.413Z" />
</svg>
WhatsApp
</button>
</div>
</div>

View File

@@ -2,18 +2,20 @@
import { useState } from 'react'
import { motion } from 'framer-motion'
import { Users, Mail, Lock, LogIn, AlertCircle, Eye, EyeOff } from 'lucide-react'
import { Users, Mail, Lock, LogIn, AlertCircle, Eye, EyeOff, LayoutPanelLeft, Trophy } from 'lucide-react'
import { loginPeladaAction } from '@/app/actions/auth'
import { ThemeWrapper } from '@/components/ThemeWrapper'
interface Props {
slug: string
groupName?: string
group: any
}
export default function PeladaLoginPage({ slug, groupName }: Props) {
export default function PeladaLoginPage({ slug, group }: Props) {
const [error, setError] = useState('')
const [loading, setLoading] = useState(false)
const [showPassword, setShowPassword] = useState(false)
const [email, setEmail] = useState('')
async function handleForm(formData: FormData) {
setLoading(true)
@@ -25,82 +27,101 @@ export default function PeladaLoginPage({ slug, groupName }: Props) {
setError(result.error)
setLoading(false)
}
// Se der certo, a Action redireciona automaticamente
}
return (
<div className="min-h-screen bg-zinc-950 flex items-center justify-center p-4">
<div className="bg-zinc-900 border border-zinc-800 rounded-3xl p-8 shadow-2xl w-full max-w-md relative overflow-hidden">
<div className="text-center mb-8 relative z-10">
<div className="w-16 h-16 rounded-2xl bg-emerald-500 flex items-center justify-center mx-auto mb-4 shadow-lg shadow-emerald-500/20">
<Users className="w-8 h-8 text-white" />
</div>
<h1 className="text-2xl font-bold text-white uppercase tracking-tight">
{groupName || slug}
</h1>
<p className="text-zinc-500 text-sm mt-1">Acesse o painel de gestão</p>
</div>
<ThemeWrapper primaryColor={group.primaryColor}>
<div className="min-h-screen bg-zinc-950 flex items-center justify-center p-4">
<div className="bg-zinc-900 border border-zinc-800 rounded-3xl p-8 shadow-2xl w-full max-w-md relative overflow-hidden">
{/* Decorative Background Glow */}
<div className="absolute top-0 right-0 w-32 h-32 bg-primary/10 blur-[50px] rounded-full -mr-16 -mt-16 pointer-events-none" />
{error && (
<div className="mb-6 p-4 bg-red-500/10 border border-red-500/20 rounded-xl flex items-center gap-3 text-red-400 text-sm">
<AlertCircle className="w-5 h-5 flex-shrink-0" />
<p>{error}</p>
</div>
)}
<form action={handleForm} className="space-y-4">
<input type="hidden" name="slug" value={slug} />
<div className="space-y-1">
<label className="text-[10px] font-bold text-zinc-500 uppercase ml-1">Email</label>
<div className="relative">
<Mail className="absolute left-4 top-1/2 -translate-y-1/2 w-5 h-5 text-zinc-600" />
<input
name="email"
type="email"
required
placeholder="seu@email.com"
className="w-full pl-12 pr-4 py-3 bg-zinc-950 border border-zinc-800 rounded-xl text-white focus:border-emerald-500 transition-all outline-none"
/>
<div className="text-center mb-8 relative z-10">
<div className="w-20 h-20 flex items-center justify-center mx-auto mb-4 overflow-hidden">
{group.logoUrl ? (
<img src={group.logoUrl} alt={group.name} className="w-full h-full object-contain" />
) : (
<div className="w-full h-full rounded-2xl bg-zinc-800 flex items-center justify-center border border-white/5">
<Trophy className="w-10 h-10 text-zinc-500" />
</div>
)}
</div>
<h1 className="text-2xl font-black text-white uppercase tracking-tight">
{group.name || slug}
</h1>
<p className="text-zinc-500 text-[10px] font-bold uppercase tracking-[0.2em] mt-2">Área Administrativa</p>
</div>
<div className="space-y-1">
<label className="text-[10px] font-bold text-zinc-500 uppercase ml-1">Senha</label>
<div className="relative">
<Lock className="absolute left-4 top-1/2 -translate-y-1/2 w-5 h-5 text-zinc-600" />
<input
name="password"
type={showPassword ? 'text' : 'password'}
required
placeholder="••••••••"
className="w-full pl-12 pr-12 py-3 bg-zinc-950 border border-zinc-800 rounded-xl text-white focus:border-emerald-500 transition-all outline-none font-mono"
/>
<button
type="button"
onClick={() => setShowPassword(!showPassword)}
className="absolute right-4 top-1/2 -translate-y-1/2 text-zinc-600 hover:text-white"
>
{showPassword ? <EyeOff className="w-5 h-5" /> : <Eye className="w-5 h-5" />}
</button>
{error && (
<div className="mb-6 p-4 bg-red-500/10 border border-red-500/20 rounded-xl flex items-center gap-3 text-red-500 text-xs font-bold uppercase">
<AlertCircle className="w-5 h-5 flex-shrink-0" />
<p>{error}</p>
</div>
)}
<form action={handleForm} className="space-y-4">
<input type="hidden" name="slug" value={slug} />
<div className="space-y-1.5">
<label className="text-[10px] font-black text-muted uppercase ml-1 tracking-widest">Acesso do Gestor</label>
<div className="relative group">
<Mail className="absolute left-4 top-1/2 -translate-y-1/2 w-5 h-5 text-zinc-600 group-focus-within:text-primary transition-colors" />
<input
name="email"
type="email"
required
value={email}
onChange={(e) => setEmail(e.target.value)}
placeholder="seu@email.com"
className="w-full pl-12 pr-4 py-4 bg-zinc-950 border border-zinc-800 rounded-2xl text-white focus:border-primary transition-all outline-none text-sm font-medium"
/>
</div>
</div>
<div className="space-y-1.5">
<label className="text-[10px] font-black text-muted uppercase ml-1 tracking-widest">Senha Secreta</label>
<div className="relative group">
<Lock className="absolute left-4 top-1/2 -translate-y-1/2 w-5 h-5 text-zinc-600 group-focus-within:text-primary transition-colors" />
<input
name="password"
type={showPassword ? 'text' : 'password'}
required
placeholder="••••••••"
className="w-full pl-12 pr-12 py-4 bg-zinc-950 border border-zinc-800 rounded-2xl text-white focus:border-primary transition-all outline-none font-mono text-sm"
/>
<button
type="button"
onClick={() => setShowPassword(!showPassword)}
className="absolute right-4 top-1/2 -translate-y-1/2 text-zinc-600 hover:text-white transition-colors"
>
{showPassword ? <EyeOff className="w-5 h-5" /> : <Eye className="w-5 h-5" />}
</button>
</div>
</div>
<button
type="submit"
disabled={loading}
className="w-full py-4 bg-primary hover:bg-primary/90 text-white font-black rounded-2xl transition-all disabled:opacity-50 shadow-lg shadow-primary/20 flex items-center justify-center gap-3 mt-6 uppercase tracking-widest text-xs"
>
{loading ? (
<div className="w-5 h-5 border-2 border-white/30 border-t-white rounded-full animate-spin" />
) : (
<>
<LogIn className="w-5 h-5" />
Entrar no Painel
</>
)}
</button>
</form>
<div className="mt-8 text-center border-t border-zinc-800 pt-6">
<a href={`http://localhost`} className="text-[10px] text-zinc-600 hover:text-primary uppercase font-black tracking-[0.2em] transition-colors">
Voltar para TemFut
</a>
</div>
<button
type="submit"
disabled={loading}
className="w-full py-4 bg-emerald-500 hover:bg-emerald-400 text-zinc-950 font-black rounded-xl transition-all disabled:opacity-50"
>
{loading ? 'CARREGANDO...' : 'ENTRAR NO TIME'}
</button>
</form>
<div className="mt-8 text-center border-t border-zinc-800 pt-6">
<a href="http://localhost" className="text-xs text-zinc-600 hover:text-zinc-400 uppercase font-bold tracking-widest">
Voltar para TemFut
</a>
</div>
</div>
</div>
</ThemeWrapper>
)
}

View File

@@ -0,0 +1,159 @@
'use client'
import { useState } from 'react'
import { User, CreditCard, Shield, Crown, Trophy, Star } from 'lucide-react'
import { ProfileForm } from '@/components/ProfileForm'
interface ProfileClientProps {
group: any
leader: any
}
export function ProfileClient({ group, leader }: ProfileClientProps) {
const [activeTab, setActiveTab] = useState<'personal' | 'subscription'>('personal')
const planConfig = {
FREE: { name: 'Grátis', icon: Star, color: 'text-zinc-500', gradient: 'from-zinc-500/10 to-transparent' },
BASIC: { name: 'Básico', icon: Shield, color: 'text-blue-500', gradient: 'from-blue-500/10 to-transparent' },
PRO: { name: 'Pro Premium', icon: Crown, color: 'text-amber-500', gradient: 'from-amber-500/10 to-transparent' }
}
const currentPlan = planConfig[group.plan as keyof typeof planConfig] || planConfig.FREE
return (
<div className="grid grid-cols-1 md:grid-cols-12 gap-8">
{/* Sidebar Navigation (Tabs) */}
<aside className="md:col-span-4 space-y-4">
<div className="ui-card p-6 text-center shadow-xl relative overflow-hidden">
{/* Background Pattern */}
<div className={`absolute top-0 inset-x-0 h-1 bg-gradient-to-r ${currentPlan.gradient}`} />
<div className="w-20 h-20 bg-surface-raised rounded-full flex items-center justify-center mx-auto mb-4 border border-border mt-2">
<User className="w-10 h-10 text-muted" />
</div>
<h3 className="font-bold text-lg">{leader?.name || 'Administrador'}</h3>
<p className="text-[10px] text-primary font-black uppercase tracking-[0.2em] mt-1">Líder TemFut</p>
<div className="mt-6 pt-4 border-t border-border flex items-center justify-center gap-2">
<currentPlan.icon className={`w-4 h-4 ${currentPlan.color}`} />
<span className="text-[10px] font-black uppercase tracking-widest">{currentPlan.name}</span>
</div>
</div>
<div className="flex flex-col gap-2">
<button
onClick={() => setActiveTab('personal')}
className={`flex items-center gap-3 px-4 py-3 rounded-xl text-xs font-black uppercase tracking-widest transition-all text-left ${activeTab === 'personal'
? 'bg-primary/10 text-primary border border-primary/20 shadow-sm'
: 'text-muted hover:text-foreground hover:bg-surface-raised'
}`}
>
<User className="w-4 h-4" />
Dados Pessoais
</button>
<button
onClick={() => setActiveTab('subscription')}
className={`flex items-center gap-3 px-4 py-3 rounded-xl text-xs font-black uppercase tracking-widest transition-all text-left ${activeTab === 'subscription'
? 'bg-amber-500/10 text-amber-500 border border-amber-500/20 shadow-sm'
: 'text-muted hover:text-foreground hover:bg-surface-raised'
}`}
>
<CreditCard className="w-4 h-4" />
Assinatura & Pagamentos
</button>
</div>
</aside>
{/* Main Content */}
<main className="md:col-span-8">
{activeTab === 'personal' ? (
<div className="ui-card p-8 animate-in fade-in slide-in-from-right-4 duration-500 shadow-xl">
<div className="flex items-center gap-3 mb-8 pb-4 border-b border-border">
<div className="p-2 bg-primary/10 rounded-lg">
<User className="w-5 h-5 text-primary" />
</div>
<div>
<h4 className="font-bold">Informações do Atleta</h4>
<p className="text-xs text-muted">Ajuste como você será exibido nas peladas.</p>
</div>
</div>
{!leader ? (
<div className="py-12 text-center space-y-6">
<div className="w-16 h-16 bg-surface-raised rounded-full flex items-center justify-center mx-auto border border-dashed border-border">
<User className="w-8 h-8 text-muted" />
</div>
<div className="space-y-2">
<h5 className="font-bold">Perfil não encontrado</h5>
<p className="text-xs text-muted max-w-xs mx-auto">Parece que você ainda não tem um cadastro de atleta neste grupo.</p>
</div>
<button
onClick={async () => {
// Simple action to create the leader profile if missing
window.location.href = '/dashboard/players'
}}
className="ui-button px-6 h-10 text-xs"
>
Criar meu Perfil
</button>
</div>
) : (
<ProfileForm player={leader} />
)}
</div>
) : (
<div className="ui-card p-8 animate-in fade-in slide-in-from-right-4 duration-500 shadow-xl">
<div className="flex items-center gap-3 mb-8 pb-4 border-b border-border">
<div className="p-2 bg-amber-500/10 rounded-lg">
<CreditCard className="w-5 h-5 text-amber-500" />
</div>
<div>
<h4 className="font-bold text-amber-500 uppercase tracking-tight">Sua Assinatura</h4>
<p className="text-xs text-muted">Status do seu plano no TemFut.</p>
</div>
</div>
<div className="space-y-6">
<div className="p-8 bg-surface-raised rounded-3xl border border-border relative overflow-hidden group">
<div className="absolute top-0 right-0 w-48 h-48 bg-primary/5 blur-3xl rounded-full -mr-24 -mt-24 group-hover:bg-primary/10 transition-colors" />
<div className="flex items-center justify-between relative z-10">
<div className="space-y-2">
<p className="text-[10px] font-black text-muted uppercase tracking-[0.4em]">Plano Atual</p>
<h5 className={`text-3xl font-black uppercase tracking-tighter ${currentPlan.color}`}>{currentPlan.name}</h5>
</div>
<div className={`w-16 h-16 rounded-2xl bg-surface border border-border flex items-center justify-center shadow-2xl`}>
<currentPlan.icon className={`w-8 h-8 ${currentPlan.color}`} />
</div>
</div>
<div className="mt-12 grid grid-cols-1 sm:grid-cols-2 gap-8 relative z-10">
<div className="space-y-1">
<p className="text-[9px] text-muted font-black uppercase tracking-widest">Próximo Vencimento</p>
<p className="text-sm font-black">{group.planExpiresAt ? new Date(group.planExpiresAt).toLocaleDateString() : 'Sem Expiração'}</p>
</div>
<div className="space-y-1">
<p className="text-[9px] text-muted font-black uppercase tracking-widest">Investimento</p>
<p className="text-sm font-black italic">{group.plan === 'FREE' ? 'Gratuito' : 'R$ 29,90 / mês'}</p>
</div>
</div>
<div className="mt-10 pt-6 border-t border-white/5 flex items-center justify-between relative z-10">
<span className="text-[10px] font-bold text-zinc-500 uppercase">Pelada: {group.name}</span>
<button className="text-[10px] font-black uppercase text-primary hover:underline transition-all">Alterar Plano</button>
</div>
</div>
<div className="p-4 bg-zinc-900/50 border border-zinc-800 rounded-xl flex items-center gap-3">
<div className="w-8 h-8 rounded-full bg-emerald-500/10 flex items-center justify-center">
<Shield className="w-4 h-4 text-emerald-500" />
</div>
<span className="text-xs text-zinc-500 font-medium">Sua infraestrutura está replicada em alta disponibilidade.</span>
</div>
</div>
</div>
)}
</main>
</div>
)
}

View File

@@ -0,0 +1,121 @@
'use client'
import { useState } from 'react'
import { Star, Save, Check, Loader2 } from 'lucide-react'
import { updatePlayer } from '@/actions/player'
import { clsx } from 'clsx'
export function ProfileForm({ player }: { player: any }) {
const [isSaving, setIsSaving] = useState(false)
const [success, setSuccess] = useState(false)
const [formData, setFormData] = useState({
name: player?.name || '',
position: player?.position || 'MEI',
level: player?.level || 3,
number: player?.number || ''
})
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault()
if (!player?.id) return
setIsSaving(true)
try {
await updatePlayer(player.id, {
name: formData.name,
position: formData.position,
level: formData.level,
number: formData.number ? parseInt(formData.number.toString()) : null
})
setSuccess(true)
setTimeout(() => setSuccess(false), 3000)
} catch (error) {
console.error(error)
} finally {
setIsSaving(false)
}
}
return (
<form onSubmit={handleSubmit} className="space-y-6">
<div className="grid grid-cols-1 sm:grid-cols-2 gap-6">
<div className="ui-form-field">
<label className="text-[10px] font-black text-muted uppercase tracking-widest ml-1">Nome de Exibição</label>
<input
type="text"
value={formData.name}
onChange={(e) => setFormData(prev => ({ ...prev, name: e.target.value }))}
className="ui-input h-12"
placeholder="Nome como aparecerá na ficha"
/>
</div>
<div className="ui-form-field">
<label className="text-[10px] font-black text-muted uppercase tracking-widest ml-1">Posição Preferida</label>
<select
value={formData.position}
onChange={(e) => setFormData(prev => ({ ...prev, position: e.target.value }))}
className="ui-input h-12 bg-surface"
>
<option value="GOL">Goleiro</option>
<option value="ZAG">Zagueiro</option>
<option value="LAT">Lateral</option>
<option value="MEI">Meio-Campo</option>
<option value="ATA">Atacante</option>
</select>
</div>
<div className="ui-form-field">
<label className="text-[10px] font-black text-muted uppercase tracking-widest ml-1">Número da Camisa</label>
<input
type="number"
value={formData.number}
onChange={(e) => setFormData(prev => ({ ...prev, number: e.target.value }))}
className="ui-input h-12"
placeholder="Ex: 10"
/>
</div>
<div className="ui-form-field">
<label className="text-[10px] font-black text-muted uppercase tracking-widest ml-1">Nível Técnico (Estrelas)</label>
<div className="flex items-center gap-2 h-12 px-4 bg-surface-raised border border-border rounded-md">
{[1, 2, 3, 4, 5].map((star) => (
<button
key={star}
type="button"
onClick={() => setFormData(prev => ({ ...prev, level: star }))}
className="focus:outline-none transition-transform active:scale-90"
>
<Star
className={clsx(
"w-5 h-5 transition-colors",
star <= formData.level ? "text-primary fill-primary" : "text-zinc-700 hover:text-zinc-500"
)}
/>
</button>
))}
</div>
</div>
</div>
<div className="pt-6 flex justify-end">
<button
type="submit"
disabled={isSaving}
className="ui-button px-10 h-12 min-w-[180px]"
>
{isSaving ? (
<Loader2 className="w-4 h-4 animate-spin" />
) : success ? (
<>
<Check className="w-4 h-4" /> Salvo!
</>
) : (
<>
<Save className="w-4 h-4" /> Salvar Perfil
</>
)}
</button>
</div>
</form>
)
}

View File

@@ -1,21 +1,23 @@
'use client'
import { useState } from 'react'
import { MapPin, Palette } from 'lucide-react'
import { MapPin, Palette, Briefcase } from 'lucide-react'
import { clsx } from 'clsx'
import { motion, AnimatePresence } from 'framer-motion'
interface SettingsTabsProps {
branding: React.ReactNode
arenas: React.ReactNode
sponsors: React.ReactNode
}
export function SettingsTabs({ branding, arenas }: SettingsTabsProps) {
const [activeTab, setActiveTab] = useState<'branding' | 'arenas'>('branding')
export function SettingsTabs({ branding, arenas, sponsors }: SettingsTabsProps) {
const [activeTab, setActiveTab] = useState<'branding' | 'arenas' | 'sponsors'>('branding')
const tabs = [
{ id: 'branding', label: 'Identidade Visual', icon: Palette },
{ id: 'arenas', label: 'Locais & Arenas', icon: MapPin },
{ id: 'sponsors', label: 'Patrocínios', icon: Briefcase },
] as const
return (
@@ -62,6 +64,17 @@ export function SettingsTabs({ branding, arenas }: SettingsTabsProps) {
{arenas}
</motion.div>
)}
{activeTab === 'sponsors' && (
<motion.div
key="sponsors"
initial={{ opacity: 0, y: 10 }}
animate={{ opacity: 1, y: 0 }}
exit={{ opacity: 0, y: -10 }}
transition={{ duration: 0.2 }}
>
{sponsors}
</motion.div>
)}
</AnimatePresence>
</div>
</div>

View File

@@ -10,7 +10,8 @@ import {
Home,
Search,
Banknote,
LogOut
LogOut,
Eye
} from 'lucide-react'
import { clsx } from 'clsx'
@@ -23,6 +24,7 @@ export function Sidebar({ group }: { group: any }) {
{ name: 'Partidas', href: '/dashboard/matches', icon: Calendar },
{ name: 'Jogadores', href: '/dashboard/players', icon: Users },
{ name: 'Financeiro', href: '/dashboard/financial', icon: Banknote },
{ name: 'Agenda Pública', href: '/agenda', icon: Eye },
{ name: 'Configurações', href: '/dashboard/settings', icon: Settings },
]
@@ -84,8 +86,11 @@ export function Sidebar({ group }: { group: any }) {
<span>Sair</span>
</button>
<div className="flex items-center gap-3 px-2">
<div className="w-8 h-8 rounded-full bg-surface-raised border border-border flex items-center justify-center overflow-hidden">
<Link
href="/dashboard/profile"
className="flex items-center gap-3 px-2 py-2 hover:bg-surface-raised rounded-xl transition-all group"
>
<div className="w-8 h-8 rounded-full bg-surface-raised border border-border flex items-center justify-center overflow-hidden group-hover:border-primary/50 transition-colors">
{group.logoUrl ? (
<img src={group.logoUrl} alt="" className="w-full h-full object-cover" />
) : (
@@ -93,10 +98,10 @@ export function Sidebar({ group }: { group: any }) {
)}
</div>
<div className="overflow-hidden">
<p className="text-xs font-semibold truncate">{group.name}</p>
<p className="text-[10px] text-muted truncate">Plano Free</p>
<p className="text-xs font-semibold truncate group-hover:text-primary transition-colors">{group.name}</p>
<p className="text-[10px] text-muted truncate">Meu Perfil</p>
</div>
</div>
</Link>
</div>
</aside>
)

View File

@@ -0,0 +1,184 @@
'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 type { Sponsor } from '@prisma/client'
import { DeleteConfirmationModal } from '@/components/DeleteConfirmationModal'
interface SponsorsManagerProps {
groupId: string
sponsors: Sponsor[]
}
export function SponsorsManager({ groupId, sponsors }: SponsorsManagerProps) {
const [isPending, startTransition] = useTransition()
const [filePreview, setFilePreview] = useState<string | null>(null)
const [deleteModal, setDeleteModal] = useState<{
isOpen: boolean
sponsorId: string | null
isDeleting: boolean
}>({
isOpen: false,
sponsorId: null,
isDeleting: false
})
const handleDelete = (id: string) => {
setDeleteModal({
isOpen: true,
sponsorId: id,
isDeleting: false
})
}
const confirmDelete = () => {
if (!deleteModal.sponsorId) return
setDeleteModal(prev => ({ ...prev, isDeleting: true }))
startTransition(async () => {
try {
await deleteSponsor(deleteModal.sponsorId!)
setDeleteModal({ isOpen: false, sponsorId: null, isDeleting: false })
} catch (error) {
console.error(error)
alert('Erro ao excluir patrocinador.')
setDeleteModal(prev => ({ ...prev, isDeleting: false }))
}
})
}
return (
<div className="ui-card p-8 space-y-8">
<header>
<h3 className="font-semibold text-lg flex items-center gap-2">
<Briefcase className="w-5 h-5 text-primary" />
Patrocinadores
</h3>
<p className="text-muted text-sm">
Adicione os parceiros que apoiam o seu futebol para dar destaque nas capas exclusivas.
</p>
</header>
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
{sponsors.map((sponsor) => (
<div key={sponsor.id} className="flex items-center justify-between p-4 rounded-lg border border-border bg-surface-raised/50 group hover:border-primary/50 transition-colors">
<div className="flex items-center gap-4">
<div className="w-12 h-12 bg-surface rounded-lg overflow-hidden flex items-center justify-center border border-white/5">
{sponsor.logoUrl ? (
<img src={sponsor.logoUrl} alt={sponsor.name} className="w-full h-full object-cover" />
) : (
<ImageIcon className="w-5 h-5 text-muted opacity-30" />
)}
</div>
<div>
<p className="font-bold text-foreground uppercase text-xs tracking-widest">{sponsor.name}</p>
<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>
))}
{sponsors.length === 0 && (
<div className="col-span-full text-center py-10 px-4 border border-dashed border-border rounded-xl bg-surface/50">
<Briefcase className="w-8 h-8 text-muted mx-auto mb-3 opacity-50" />
<p className="text-muted text-sm uppercase font-bold tracking-widest">Nenhum patrocinador ainda</p>
</div>
)}
</div>
<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)
})
}} id="sponsor-form" className="pt-8 mt-8 border-t border-border">
<input type="hidden" name="groupId" value={groupId} />
<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"
placeholder="Ex: Pizzaria do Vale"
className="ui-input w-full"
required
/>
</div>
<div className="ui-form-field md:col-span-5">
<label className="text-label ml-1">Logo do Patrocinador</label>
<div className="relative group">
<input
type="file"
name="logo"
accept="image/*"
className="hidden"
id="sponsor-logo-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="sponsor-logo-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-xs text-muted truncate">
{filePreview ? 'Alterar Logo selecionada' : 'Selecionar imagem da logo...'}
</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" />
) : (
<Plus className="w-4 h-4 mr-2" />
)}
Adicionar
</button>
</div>
</div>
</form>
<DeleteConfirmationModal
isOpen={deleteModal.isOpen}
onClose={() => setDeleteModal({ isOpen: false, sponsorId: null, isDeleting: false })}
onConfirm={confirmDelete}
isDeleting={deleteModal.isDeleting}
title="Excluir Patrocinador?"
description="Tem certeza que deseja remover este patrocinador? Ele deixará de aparecer nas novas capas geradas."
confirmText="Sim, remover"
/>
</div>
)
}

View File

@@ -11,11 +11,24 @@ export function ThemeWrapper({ primaryColor, children }: ThemeWrapperProps) {
useEffect(() => {
if (primaryColor) {
document.documentElement.style.setProperty('--primary-color', primaryColor)
// Update other derived colors if necessary
// For example, if we needed to convert hex to HSL for other variables
}
}, [primaryColor])
return children || null
const hexToRgb = (hex: string) => {
const result = /^#?([a-f\d]{2})([a-f\d]{2})([a-f\d]{2})$/i.exec(hex);
return result ? `${parseInt(result[1], 16)}, ${parseInt(result[2], 16)}, ${parseInt(result[3], 16)}` : '16, 185, 129';
};
return (
<>
<style dangerouslySetInnerHTML={{
__html: `
:root {
${primaryColor ? `--primary-color: ${primaryColor};` : ''}
${primaryColor ? `--primary-rgb: ${hexToRgb(primaryColor)};` : ''}
}
`}} />
{children || null}
</>
)
}

View File

@@ -1,7 +1,7 @@
import { NextResponse } from 'next/server'
import type { NextRequest } from 'next/server'
export function middleware(request: NextRequest) {
export function proxy(request: NextRequest) {
const url = request.nextUrl
const hostname = request.headers.get('host') || 'localhost'
const pathname = url.pathname
@@ -14,8 +14,13 @@ export function middleware(request: NextRequest) {
if (hostnameNoPort === 'admin.localhost' || hostnameNoPort === 'admin.temfut.com') {
const isAdminSession = request.cookies.has('admin_session')
// Ignora arquivos estáticos e API
if (pathname.startsWith('/_next') || pathname.startsWith('/api') || pathname.startsWith('/static')) {
// Ignora arquivos estáticos, API e Storage
if (
pathname.startsWith('/_next') ||
pathname.startsWith('/api') ||
pathname.startsWith('/static') ||
pathname.startsWith('/storage')
) {
return NextResponse.next()
}

View File

@@ -0,0 +1,374 @@
/**
* MatchCardCanvas Engine v7.1 - "TACTICAL SLANT REFINED"
* FIXED: Slanted accents, translucent tags, and star level indicators.
*/
interface RenderData {
groupName: string;
logoUrl?: string;
teamName: string;
teamColor: string;
day: string;
month: string;
time: string;
location: string;
players: Array<{
name: string;
number?: number | string;
position: string;
level: number;
}>;
sponsors?: Array<{
name: string;
logoUrl?: string;
}>;
drawSeed: string;
options: {
showNumbers: boolean;
showPositions: boolean;
showStars: boolean;
showSponsors: boolean;
};
}
const loadImg = (url: string): Promise<HTMLImageElement> => {
return new Promise((resolve, reject) => {
const img = new Image();
img.crossOrigin = 'anonymous';
img.src = url;
img.onload = () => resolve(img);
img.onerror = (e) => reject(e);
});
};
const formatName = (name: string) => {
const parts = name.trim().split(/\s+/);
if (parts.length <= 1) return name;
return `${parts[0]} ${parts[1][0]}.`;
};
const getPosColor = (pos: string, opacity: number = 1) => {
const p = pos.toUpperCase();
let hex = '#6b7280';
if (p.includes('GOL')) hex = '#fbbf24'; // Amber
else if (p.includes('ZAG') || p.includes('FIX') || p.includes('DEF') || p.includes('LAT') || p.includes('ALA')) hex = '#ef4444'; // Red
else if (p.includes('VOL') || p.includes('MEI')) hex = '#3b82f6'; // Blue
else if (p.includes('ATA') || p.includes('PIV')) hex = '#10b981'; // Green
if (opacity === 1) return hex;
// Simple hex to rgba conversion for the engine
const r = parseInt(hex.slice(1, 3), 16);
const g = parseInt(hex.slice(3, 5), 16);
const b = parseInt(hex.slice(5, 7), 16);
return `rgba(${r}, ${g}, ${b}, ${opacity})`;
};
const removeWhiteBackground = (img: HTMLImageElement): HTMLCanvasElement => {
const canvas = document.createElement('canvas');
canvas.width = img.width;
canvas.height = img.height;
const ctx = canvas.getContext('2d');
if (!ctx) return canvas;
ctx.drawImage(img, 0, 0);
const imageData = ctx.getImageData(0, 0, canvas.width, canvas.height);
const data = imageData.data;
for (let i = 0; i < data.length; i += 4) {
// If the pixel is very close to white, make it transparent
if (data[i] > 230 && data[i + 1] > 230 && data[i + 2] > 230) {
data[i + 3] = 0;
}
}
ctx.putImageData(imageData, 0, 0);
return canvas;
};
// Global control for concurrency
let lastRenderTimestamp: Record<string, number> = {};
export async function renderMatchCard(canvas: HTMLCanvasElement, data: RenderData) {
const ctx = canvas.getContext('2d');
if (!ctx) return;
const canvasId = canvas.id || 'default-canvas';
const currentRenderTime = Date.now();
lastRenderTimestamp[canvasId] = currentRenderTime;
const W = 1080;
const H = 1920;
canvas.width = W;
canvas.height = H;
// --- 1. CLEAN DARK BACKGROUND ---
ctx.fillStyle = '#050505';
ctx.fillRect(0, 0, W, H);
// --- 2. ASYNC ASSETS LOADING ---
let logoImg: HTMLImageElement | null = null;
let cleanedLogo: HTMLCanvasElement | HTMLImageElement | null = null;
try {
if (data.logoUrl) {
logoImg = await loadImg(data.logoUrl);
// MAGIC: Process the logo to remove white square background
cleanedLogo = removeWhiteBackground(logoImg);
}
} catch (e) { }
if (lastRenderTimestamp[canvasId] !== currentRenderTime) return;
// --- JERSEY BACKGROUND ELEMENT (Using cleaned logo) ---
drawJersey(ctx, W - 450, H * 0.4, 800, data.teamColor, cleanedLogo);
// --- 3. THE HEADER (STABLE) ---
const margin = 80;
const logoSize = 180;
const headerY = 100;
if (logoImg) {
ctx.save();
drawRoundRect(ctx, margin, headerY, logoSize, logoSize, 40);
ctx.fillStyle = '#fff'; ctx.fill();
ctx.beginPath();
drawRoundRect(ctx, margin + 10, headerY + 10, logoSize - 20, logoSize - 20, 30);
ctx.clip();
ctx.drawImage(logoImg, margin + 10, headerY + 10, logoSize - 20, logoSize - 20);
ctx.restore();
}
const textX = margin + logoSize + 40;
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);
ctx.fillStyle = '#ffffff';
ctx.font = '900 32px Inter, sans-serif';
ctx.letterSpacing = '8px';
ctx.fillText(data.groupName.toUpperCase(), textX, headerY + 95);
ctx.fillStyle = 'rgba(255,255,255,0.4)';
ctx.font = '900 24px Inter, sans-serif';
ctx.letterSpacing = '5px';
ctx.fillText(`${data.day} ${data.month.toUpperCase()}${data.time}${data.location.toUpperCase()}`, textX, headerY + 145);
ctx.textBaseline = 'alphabetic';
// --- 4. THE LINEUP (ELITE INLINE) ---
// Sophisticated Section Title
ctx.save();
ctx.textAlign = 'left';
ctx.textBaseline = 'middle';
// Thin accent line
ctx.strokeStyle = 'rgba(255,255,255,0.15)';
ctx.lineWidth = 1;
ctx.beginPath();
ctx.moveTo(margin, 385);
ctx.lineTo(margin + 120, 385);
ctx.stroke();
ctx.fillStyle = '#fff';
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.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 rowSlant = 42;
data.players.forEach((p, i) => {
if (listY > H - 350) return;
ctx.save();
ctx.translate(margin, listY);
const cardW = W - (margin * 2);
// A. Slanted Beam Background
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.fill();
// B. Slanted Color Accent
const accentWidth = 18;
ctx.beginPath();
ctx.moveTo(cardW - rowSlant - accentWidth, 0);
ctx.lineTo(cardW - rowSlant, 0);
ctx.lineTo(cardW, itemH);
ctx.lineTo(cardW - accentWidth, itemH);
ctx.closePath();
ctx.fillStyle = data.teamColor;
ctx.fill();
// --- INLINE FLOW ---
let flowX = 50;
ctx.textBaseline = 'middle';
// 1. Number (Sleek)
if (data.options.showNumbers) {
ctx.textAlign = 'left';
ctx.fillStyle = data.teamColor;
ctx.font = 'italic 900 64px Inter, sans-serif';
const n = String(p.number || (i + 1)).padStart(2, '0');
ctx.fillText(n, flowX, itemH / 2);
flowX += 130;
}
// 2. Name
const pName = formatName(p.name).toUpperCase();
ctx.textAlign = 'left';
ctx.fillStyle = '#fff';
ctx.font = 'italic 900 50px Inter, sans-serif';
ctx.fillText(pName, flowX, itemH / 2);
const nW = ctx.measureText(pName).width;
flowX += nW + 50;
// 3. Level Stars (Actual Stars)
if (data.options.showStars) {
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.fill();
}
flowX += 200;
}
// 4. Position Tag (Glass Tag)
if (data.options.showPositions) {
const posT = p.position.toUpperCase();
const posC = getPosColor(p.position, 0.2);
const borC = getPosColor(p.position, 0.8);
ctx.font = '900 20px Inter, sans-serif';
const tw = ctx.measureText(posT).width + 30;
const th = 38;
ctx.fillStyle = posC;
drawRoundRect(ctx, flowX, (itemH / 2) - (th / 2), tw, th, 8);
ctx.fill();
ctx.strokeStyle = borC;
ctx.lineWidth = 1;
ctx.stroke();
ctx.fillStyle = '#fff';
ctx.textAlign = 'center';
ctx.fillText(posT, flowX + (tw / 2), itemH / 2 + 1);
}
ctx.restore();
listY += itemH + spacing;
});
// --- 5. FOOTER (SPONSORS) ---
if (data.options.showSponsors && data.sponsors && data.sponsors.length > 0) {
let footerY = H - 320;
ctx.textAlign = 'center';
ctx.fillStyle = 'rgba(255,255,255,0.2)';
ctx.font = '900 24px Inter, sans-serif';
ctx.letterSpacing = '12px';
ctx.fillText('PATROCINADORES', W / 2, footerY);
footerY += 80;
const spW = 320;
const logoH_max = 140;
const totalW = data.sponsors.length * spW;
let startX = (W - totalW) / 2;
for (const s of data.sponsors) {
try {
if (s.logoUrl) {
const spImg = await loadImg(s.logoUrl);
if (lastRenderTimestamp[canvasId] !== currentRenderTime) return;
const aspect = spImg.width / spImg.height;
let dW = logoH_max * aspect;
let dH = logoH_max;
if (dW > spW - 60) { dW = spW - 60; dH = dW / aspect; }
ctx.drawImage(spImg, startX + (spW - dW) / 2, footerY + (logoH_max - dH) / 2, dW, dH);
}
} catch (e) { }
startX += spW;
}
}
}
function drawStar(ctx: CanvasRenderingContext2D, cx: number, cy: number, spans: number, points: number, innerRadius: number) {
let rotation = Math.PI / 2 * 3;
let step = Math.PI / points;
ctx.beginPath();
ctx.moveTo(cx, cy - spans);
for (let i = 0; i < points; i++) {
let x = cx + Math.cos(rotation) * spans;
let y = cy + Math.sin(rotation) * spans;
ctx.lineTo(x, y);
rotation += step;
x = cx + Math.cos(rotation) * innerRadius;
y = cy + Math.sin(rotation) * innerRadius;
ctx.lineTo(x, y);
rotation += step;
}
ctx.lineTo(cx, cy - spans);
ctx.closePath();
}
function drawRoundRect(ctx: CanvasRenderingContext2D, x: number, y: number, w: number, h: number, r: number) {
if (w < 2 * r) r = w / 2;
if (h < 2 * r) r = h / 2;
ctx.beginPath();
ctx.moveTo(x + r, y);
ctx.arcTo(x + w, y, x + w, y + h, r);
ctx.arcTo(x + w, y + h, x, y + h, r);
ctx.arcTo(x, y + h, x, y, r);
ctx.arcTo(x, y, x + w, y, r);
ctx.closePath();
}
function drawJersey(ctx: CanvasRenderingContext2D, x: number, y: number, w: number, color: string, logoImg?: CanvasImageSource | null) {
const h = w * 1.5;
ctx.save();
ctx.translate(x, y);
ctx.rotate(15 * Math.PI / 180);
ctx.beginPath();
ctx.strokeStyle = color;
ctx.lineWidth = 14;
ctx.globalAlpha = 0.08;
// 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);
ctx.stroke();
// Neck detail
ctx.beginPath();
ctx.arc(w * 0.5, 0, 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.restore();
}
ctx.restore();
}