feat: implement financial recurrence, privacy settings, and premium DateTimePicker overhaul
This commit is contained in:
7
package-lock.json
generated
7
package-lock.json
generated
@@ -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",
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
@@ -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 {
|
||||
|
||||
@@ -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!')
|
||||
}
|
||||
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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 }
|
||||
}
|
||||
|
||||
@@ -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 }
|
||||
}
|
||||
|
||||
@@ -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
41
src/actions/sponsor.ts
Normal 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')
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -33,6 +33,7 @@ export default async function MatchesPage() {
|
||||
<MatchHistory
|
||||
matches={group?.matches || []}
|
||||
players={group?.players || []}
|
||||
groupName={group?.name}
|
||||
/>
|
||||
</section>
|
||||
</div>
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
26
src/app/(dashboard)/dashboard/profile/page.tsx
Normal file
26
src/app/(dashboard)/dashboard/profile/page.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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>
|
||||
|
||||
|
||||
@@ -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
136
src/app/agenda/page.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
275
src/app/atualizacoes/page.tsx
Normal file
275
src/app/atualizacoes/page.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
@@ -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)
|
||||
|
||||
@@ -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'
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -29,5 +29,5 @@ export default async function LoginPage() {
|
||||
redirect('/dashboard')
|
||||
}
|
||||
|
||||
return <PeladaLoginPage slug={slug} groupName={group.name} />
|
||||
return <PeladaLoginPage slug={slug} group={group} />
|
||||
}
|
||||
|
||||
@@ -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 */}
|
||||
|
||||
@@ -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} />
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
|
||||
349
src/components/DateTimePicker.tsx
Normal file
349
src/components/DateTimePicker.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
@@ -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 }))}
|
||||
|
||||
102
src/components/FinancialSettingsModal.tsx
Normal file
102
src/components/FinancialSettingsModal.tsx
Normal 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 já arrecadado e a meta.
|
||||
Quando desativado, os atletas verão apenas a lista de quem já 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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
159
src/components/ProfileClient.tsx
Normal file
159
src/components/ProfileClient.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
121
src/components/ProfileForm.tsx
Normal file
121
src/components/ProfileForm.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
|
||||
184
src/components/SponsorsManager.tsx
Normal file
184
src/components/SponsorsManager.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
@@ -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}
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
|
||||
374
src/utils/MatchCardCanvas.ts
Normal file
374
src/utils/MatchCardCanvas.ts
Normal 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();
|
||||
}
|
||||
Reference in New Issue
Block a user