feat: add error handling to player creation and update gitignore for agent

This commit is contained in:
Erik Silva
2026-01-24 14:18:40 -03:00
commit 416bd83ea7
93 changed files with 16861 additions and 0 deletions

10
.agent/memories.md Normal file
View File

@@ -0,0 +1,10 @@
# Project Memories & Security Notes
## Security
- **Admin Seed Route**: The route `/api/admin/seed` MUST remain restricted to `development` environment only. It allows creating a Super Admin without authentication.
- **Middleware Protection**: Admin API routes (`/api/admin/*`) are protected by `admin_session` cookie verification. Failed verification returns 401 JSON.
## Repetitivas
- Sempre suba o container apos alteracaoes!
- Sempre verifique o terminal atrás de problemas de problemas no terminal

8
.dockerignore Normal file
View File

@@ -0,0 +1,8 @@
node_modules
.next
.git
.env*
.DS_Store
README.md
Dockerfile
docker-compose.yml

10
.env.example Normal file
View File

@@ -0,0 +1,10 @@
# Database
DATABASE_URL="postgresql://postgres:postgres@localhost:5432/temfut?schema=public"
# NextAuth
NEXTAUTH_URL="http://temfut.localhost"
NEXTAUTH_SECRET="your-secret-here"
# App
PORT=3000
SKIP_ENV_VALIDATION=true

48
.gitignore vendored Normal file
View File

@@ -0,0 +1,48 @@
# See https://help.github.com/articles/ignoring-files/ for more about ignoring files.
# dependencies
/node_modules
/.pnp
.pnp.*
.yarn/*
!.yarn/patches
!.yarn/plugins
!.yarn/releases
!.yarn/versions
# testing
/coverage
# next.js
/.next/
/out/
# production
/build
# misc
.DS_Store
*.pem
# debug
npm-debug.log*
yarn-debug.log*
yarn-error.log*
.pnpm-debug.log*
# env files
.env
.env.local
.env.development.local
.env.test.local
.env.production.local
!.env.example
# vercel
.vercel
# typescript
*.tsbuildinfo
# Agent memory
!.agent

29
Dockerfile Normal file
View File

@@ -0,0 +1,29 @@
FROM node:20-slim
WORKDIR /app
# Install system dependencies
RUN apt-get update -y && apt-get install -y openssl ca-certificates && rm -rf /var/lib/apt/lists/*
# Copy package files
COPY package.json package-lock.json* ./
# Install dependencies
RUN npm ci
# Copy source
COPY . .
# Generate Prisma
ENV DATABASE_URL="postgresql://invalid:invalid@localhost:5432/invalid"
RUN npx prisma generate
# Build
ENV NEXT_TELEMETRY_DISABLED=1
RUN npm run build
# Expose
EXPOSE 3000
ENV PORT=3000
CMD ["npm", "start"]

63
README.md Normal file
View File

@@ -0,0 +1,63 @@
# TemFut - Gestão de Pelada
A plataforma definitiva para organizar grupos, sortear times equilibrados e manter o histórico do seu esquadrão.
## Tecnologias
- **Frontend**: Next.js 15 (App Router)
- **Database**: PostgreSQL with Prisma ORM
- **Styling**: Tailwind CSS, Framer Motion, Lucide React
- **Infrastructure**: Docker & Nginx
## Estrutura de Domínios
| Domínio | Descrição |
|---------|-----------|
| `temfut.localhost` ou `localhost` | App principal do TemFut |
| `admin.localhost` | Painel administrativo (gerencia todas as peladas) |
| `[slug].localhost` | Subdomínio de cada pelada (ex: `amigos.localhost`) |
## Como Rodar Localmente (Docker)
### 1. Requisitos
- Docker Desktop (Windows/Mac/Linux)
### 2. Configuração de Host
Adicione as seguintes linhas ao seu arquivo de hosts:
- **Windows**: `C:\Windows\System32\drivers\etc\hosts`
- **Mac/Linux**: `/etc/hosts`
```text
127.0.0.1 localhost
127.0.0.1 temfut.localhost
127.0.0.1 admin.localhost
# Adicione slugs de peladas conforme necessário:
127.0.0.1 amigos.localhost
127.0.0.1 pelada-quarta.localhost
```
### 3. Subir os Containers
```bash
docker-compose up -d --build
```
### 4. Sincronizar Banco de Dados
```bash
docker-compose exec app npx prisma db push
```
### 5. Acessar a Aplicação
- **App Principal**: [http://temfut.localhost](http://temfut.localhost)
- **Admin Panel**: [http://admin.localhost](http://admin.localhost)
- **Criar Pelada**: [http://temfut.localhost/create](http://temfut.localhost/create)
## Desenvolvimento sem Docker
1. Instale as dependências: `npm install`
2. Configure o `.env` (use o `.env.example` como base)
3. Inicie o PostgreSQL local ou use o do Docker: `docker-compose up -d db`
4. Gere o Prisma Client: `npx prisma generate`
5. Rodar app: `npm run dev`
---
Desenvolvido com ❤️ pela equipe TemFut.

76
docker-compose.yml Normal file
View File

@@ -0,0 +1,76 @@
version: '3.8'
services:
traefik:
image: traefik:v2.10
volumes:
- ./traefik.yml:/etc/traefik/traefik.yml:ro
- ./dynamic.yml:/etc/traefik/dynamic.yml:ro
ports:
- "80:80"
- "8080:8080"
app:
build:
context: .
dockerfile: Dockerfile
restart: always
command: npm run dev
environment:
- DATABASE_URL=postgresql://postgres:postgres@db:5432/temfut?schema=public
- NEXTAUTH_URL=http://localhost
- NEXTAUTH_SECRET=changeme
- SKIP_ENV_VALIDATION=true
- MINIO_ENDPOINT=minio
- MINIO_PORT=9000
- MINIO_ACCESS_KEY=temfut_admin
- MINIO_SECRET_KEY=temfut_secret_2026
- MINIO_BUCKET=temfut
- MINIO_USE_SSL=false
volumes:
- ./src:/app/src
- ./public:/app/public
- ./prisma:/app/prisma
- ./next.config.ts:/app/next.config.ts
- ./tsconfig.json:/app/tsconfig.json
- ./package.json:/app/package.json
- ./.env:/app/.env
depends_on:
db:
condition: service_healthy
minio:
condition: service_started
minio:
image: minio/minio
restart: always
command: server /data --console-address ":9001"
environment:
MINIO_ROOT_USER: temfut_admin
MINIO_ROOT_PASSWORD: temfut_secret_2026
volumes:
- minio-data:/data
ports:
- "9000:9000"
- "9001:9001"
db:
image: postgres:15-alpine
restart: always
environment:
POSTGRES_USER: postgres
POSTGRES_PASSWORD: postgres
POSTGRES_DB: temfut
volumes:
- db-data:/var/lib/postgresql/data
ports:
- "5432:5432"
healthcheck:
test: [ "CMD-SHELL", "pg_isready -U postgres -d temfut" ]
interval: 5s
timeout: 5s
retries: 5
volumes:
db-data:
minio-data:

17
dynamic.yml Normal file
View File

@@ -0,0 +1,17 @@
http:
routers:
app-router:
rule: "HostRegexp(`{sub:.+}.localhost`) || Host(`localhost`)"
service: app-service
entryPoints: ["web"]
services:
app-service:
loadBalancer:
servers:
- url: "http://app:3000"
minio-service:
loadBalancer:
servers:
- url: "http://minio:9000"

18
eslint.config.mjs Normal file
View File

@@ -0,0 +1,18 @@
import { defineConfig, globalIgnores } from "eslint/config";
import nextVitals from "eslint-config-next/core-web-vitals";
import nextTs from "eslint-config-next/typescript";
const eslintConfig = defineConfig([
...nextVitals,
...nextTs,
// Override default ignores of eslint-config-next.
globalIgnores([
// Default ignores of eslint-config-next:
".next/**",
"out/**",
"build/**",
"next-env.d.ts",
]),
]);
export default eslintConfig;

6
next-env.d.ts vendored Normal file
View File

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

18
next.config.ts Normal file
View File

@@ -0,0 +1,18 @@
import type { NextConfig } from "next";
const nextConfig: NextConfig = {
typescript: {
ignoreBuildErrors: true,
},
// Proxy para o Minio - Isso resolve o problema de DNS e AdBlock
async rewrites() {
return [
{
source: '/storage/:path*',
destination: 'http://minio:9000/:path*',
},
]
},
};
export default nextConfig;

7486
package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

47
package.json Normal file
View File

@@ -0,0 +1,47 @@
{
"name": "temfut",
"version": "0.1.0",
"private": true,
"scripts": {
"dev": "next dev",
"build": "next build",
"start": "next start",
"lint": "eslint"
},
"dependencies": {
"@headlessui/react": "^2.2.9",
"@prisma/client": "^6.2.1",
"@supabase/ssr": "^0.8.0",
"@supabase/supabase-js": "^2.91.0",
"@types/cookie": "^0.6.0",
"bcryptjs": "^3.0.3",
"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",
"react": "19.2.3",
"react-dom": "19.2.3",
"tailwind-merge": "^3.4.0",
"uuid": "^13.0.0"
},
"devDependencies": {
"@tailwindcss/postcss": "^4",
"@types/bcryptjs": "^2.4.6",
"@types/minio": "^7.1.0",
"@types/node": "^20",
"@types/react": "^19",
"@types/react-dom": "^19",
"@types/uuid": "^10.0.0",
"eslint": "^9",
"eslint-config-next": "16.1.3",
"prisma": "^6.2.1",
"tailwindcss": "^4",
"typescript": "^5"
},
"prisma": {
"seed": "npx tsx prisma/seed.ts"
}
}

7
postcss.config.mjs Normal file
View File

@@ -0,0 +1,7 @@
const config = {
plugins: {
"@tailwindcss/postcss": {},
},
};
export default config;

View File

@@ -0,0 +1,89 @@
-- CreateEnum
CREATE TYPE "MatchStatus" AS ENUM ('IN_PROGRESS', 'COMPLETED');
-- CreateTable
CREATE TABLE "Group" (
"id" TEXT NOT NULL,
"name" TEXT NOT NULL,
"logoUrl" TEXT,
"primaryColor" TEXT NOT NULL DEFAULT '#000000',
"secondaryColor" TEXT NOT NULL DEFAULT '#ffffff',
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "Group_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "Player" (
"id" TEXT NOT NULL,
"name" TEXT NOT NULL,
"number" INTEGER NOT NULL,
"position" TEXT NOT NULL DEFAULT 'MEI',
"level" INTEGER NOT NULL DEFAULT 3,
"groupId" TEXT NOT NULL,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "Player_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "Sponsor" (
"id" TEXT NOT NULL,
"name" TEXT NOT NULL,
"logoUrl" TEXT,
"groupId" TEXT NOT NULL,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "Sponsor_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "Match" (
"id" TEXT NOT NULL,
"date" TIMESTAMP(3) NOT NULL,
"status" "MatchStatus" NOT NULL DEFAULT 'IN_PROGRESS',
"groupId" TEXT NOT NULL,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "Match_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "Team" (
"id" TEXT NOT NULL,
"name" TEXT NOT NULL,
"color" TEXT NOT NULL,
"matchId" TEXT NOT NULL,
CONSTRAINT "Team_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "TeamPlayer" (
"id" TEXT NOT NULL,
"teamId" TEXT NOT NULL,
"playerId" TEXT NOT NULL,
CONSTRAINT "TeamPlayer_pkey" PRIMARY KEY ("id")
);
-- CreateIndex
CREATE UNIQUE INDEX "Player_number_groupId_key" ON "Player"("number", "groupId");
-- AddForeignKey
ALTER TABLE "Player" ADD CONSTRAINT "Player_groupId_fkey" FOREIGN KEY ("groupId") REFERENCES "Group"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "Sponsor" ADD CONSTRAINT "Sponsor_groupId_fkey" FOREIGN KEY ("groupId") REFERENCES "Group"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "Match" ADD CONSTRAINT "Match_groupId_fkey" FOREIGN KEY ("groupId") REFERENCES "Group"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "Team" ADD CONSTRAINT "Team_matchId_fkey" FOREIGN KEY ("matchId") REFERENCES "Match"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "TeamPlayer" ADD CONSTRAINT "TeamPlayer_teamId_fkey" FOREIGN KEY ("teamId") REFERENCES "Team"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "TeamPlayer" ADD CONSTRAINT "TeamPlayer_playerId_fkey" FOREIGN KEY ("playerId") REFERENCES "Player"("id") ON DELETE RESTRICT ON UPDATE CASCADE;

View File

@@ -0,0 +1,15 @@
/*
Warnings:
- A unique constraint covering the columns `[email]` on the table `Group` will be added. If there are existing duplicate values, this will fail.
*/
-- AlterTable
ALTER TABLE "Group" ADD COLUMN "email" TEXT,
ADD COLUMN "password" TEXT;
-- AlterTable
ALTER TABLE "Player" ALTER COLUMN "number" DROP NOT NULL;
-- CreateIndex
CREATE UNIQUE INDEX "Group_email_key" ON "Group"("email");

View File

@@ -0,0 +1,24 @@
-- AlterTable
ALTER TABLE "Match" ADD COLUMN "drawSeed" TEXT,
ADD COLUMN "location" TEXT,
ADD COLUMN "maxPlayers" INTEGER;
-- CreateTable
CREATE TABLE "Attendance" (
"id" TEXT NOT NULL,
"playerId" TEXT NOT NULL,
"matchId" TEXT NOT NULL,
"status" TEXT NOT NULL DEFAULT 'CONFIRMED',
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "Attendance_pkey" PRIMARY KEY ("id")
);
-- CreateIndex
CREATE UNIQUE INDEX "Attendance_playerId_matchId_key" ON "Attendance"("playerId", "matchId");
-- AddForeignKey
ALTER TABLE "Attendance" ADD CONSTRAINT "Attendance_playerId_fkey" FOREIGN KEY ("playerId") REFERENCES "Player"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "Attendance" ADD CONSTRAINT "Attendance_matchId_fkey" FOREIGN KEY ("matchId") REFERENCES "Match"("id") ON DELETE RESTRICT ON UPDATE CASCADE;

View File

@@ -0,0 +1,10 @@
-- AlterEnum
-- This migration adds more than one value to an enum.
-- With PostgreSQL versions 11 and earlier, this is not possible
-- in a single migration. This can be worked around by creating
-- multiple migrations, each migration adding only one value to
-- the enum.
ALTER TYPE "MatchStatus" ADD VALUE 'SCHEDULED';
ALTER TYPE "MatchStatus" ADD VALUE 'CANCELED';

View File

@@ -0,0 +1,2 @@
-- AlterTable
ALTER TABLE "Match" ALTER COLUMN "status" SET DEFAULT 'SCHEDULED';

View File

@@ -0,0 +1,17 @@
-- DropForeignKey
ALTER TABLE "Attendance" DROP CONSTRAINT "Attendance_matchId_fkey";
-- DropForeignKey
ALTER TABLE "Team" DROP CONSTRAINT "Team_matchId_fkey";
-- DropForeignKey
ALTER TABLE "TeamPlayer" DROP CONSTRAINT "TeamPlayer_teamId_fkey";
-- AddForeignKey
ALTER TABLE "Attendance" ADD CONSTRAINT "Attendance_matchId_fkey" FOREIGN KEY ("matchId") REFERENCES "Match"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "Team" ADD CONSTRAINT "Team_matchId_fkey" FOREIGN KEY ("matchId") REFERENCES "Match"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "TeamPlayer" ADD CONSTRAINT "TeamPlayer_teamId_fkey" FOREIGN KEY ("teamId") REFERENCES "Team"("id") ON DELETE CASCADE ON UPDATE CASCADE;

View File

@@ -0,0 +1,3 @@
-- AlterTable
ALTER TABLE "Match" ADD COLUMN "isRecurring" BOOLEAN NOT NULL DEFAULT false,
ADD COLUMN "recurrenceInterval" TEXT;

View File

@@ -0,0 +1,2 @@
-- AlterTable
ALTER TABLE "Match" ADD COLUMN "recurrenceEndDate" TIMESTAMP(3);

View File

@@ -0,0 +1,3 @@
# Please do not edit this file manually
# It should be added in your version-control system (e.g., Git)
provider = "postgresql"

206
prisma/schema.prisma Normal file
View File

@@ -0,0 +1,206 @@
generator client {
provider = "prisma-client-js"
binaryTargets = ["native", "linux-musl-openssl-3.0.x", "debian-openssl-3.0.x"]
}
datasource db {
provider = "postgresql"
url = env("DATABASE_URL")
}
// Super Admin da plataforma TemFut (acessa admin.localhost)
model Admin {
id String @id @default(cuid())
email String @unique
password String
name String
role AdminRole @default(ADMIN)
createdAt DateTime @default(now())
}
enum AdminRole {
SUPER_ADMIN // Acesso total
ADMIN // Acesso de leitura
}
model Group {
id String @id @default(cuid())
slug String @unique @default(cuid()) // Para subdomínios (ex: flamengo.temfut.com)
name String
logoUrl String?
primaryColor String @default("#000000")
secondaryColor String @default("#ffffff")
createdAt DateTime @default(now())
email String? @unique
password String?
plan Plan @default(FREE)
planExpiresAt DateTime?
matches Match[]
players Player[]
sponsors Sponsor[]
arenas Arena[]
financialEvents FinancialEvent[]
pixKey String?
pixName String?
status GroupStatus @default(ACTIVE)
}
enum GroupStatus {
ACTIVE
FROZEN
}
enum Plan {
FREE
BASIC
PRO
}
model Player {
id String @id @default(cuid())
name String
number Int?
position String @default("MEI")
level Int @default(3)
groupId String
createdAt DateTime @default(now())
group Group @relation(fields: [groupId], references: [id])
teams TeamPlayer[]
attendances Attendance[]
payments Payment[]
@@unique([number, groupId])
}
model Attendance {
id String @id @default(cuid())
playerId String
player Player @relation(fields: [playerId], references: [id])
matchId String
match Match @relation(fields: [matchId], references: [id], onDelete: Cascade)
status String @default("CONFIRMED") // CONFIRMED, CANCELED
createdAt DateTime @default(now())
@@unique([playerId, matchId])
}
model Match {
id String @id @default(cuid())
date DateTime
location String?
arenaId String?
arena Arena? @relation(fields: [arenaId], references: [id])
maxPlayers Int?
drawSeed String?
status MatchStatus @default(SCHEDULED)
groupId String
createdAt DateTime @default(now())
group Group @relation(fields: [groupId], references: [id])
teams Team[]
attendances Attendance[]
isRecurring Boolean @default(false)
recurrenceInterval String? // 'WEEKLY'
recurrenceEndDate DateTime?
}
model Team {
id String @id @default(cuid())
name String
color String
matchId String
match Match @relation(fields: [matchId], references: [id], onDelete: Cascade)
players TeamPlayer[]
}
model TeamPlayer {
id String @id @default(cuid())
teamId String
playerId String
player Player @relation(fields: [playerId], references: [id])
team Team @relation(fields: [teamId], references: [id], onDelete: Cascade)
}
enum MatchStatus {
SCHEDULED
IN_PROGRESS
COMPLETED
CANCELED
}
model Sponsor {
id String @id @default(cuid())
name String
logoUrl String?
groupId String
createdAt DateTime @default(now())
group Group @relation(fields: [groupId], references: [id])
}
model Arena {
id String @id @default(cuid())
name String
address String?
groupId String
group Group @relation(fields: [groupId], references: [id])
matches Match[]
createdAt DateTime @default(now())
}
model FinancialEvent {
id String @id @default(cuid())
title String
description String?
totalAmount Float? // Meta de arrecadação (opcional)
pricePerPerson Float? // Valor por pessoa
dueDate DateTime
type FinancialEventType @default(MONTHLY_FEE)
status FinancialEventStatus @default(OPEN)
groupId String
group Group @relation(fields: [groupId], references: [id])
payments Payment[]
createdAt DateTime @default(now())
isRecurring Boolean @default(false)
recurrenceInterval String? // 'MONTHLY', 'WEEKLY'
recurrenceEndDate DateTime?
}
model Payment {
id String @id @default(cuid())
financialEventId String
financialEvent FinancialEvent @relation(fields: [financialEventId], references: [id], onDelete: Cascade)
playerId String
player Player @relation(fields: [playerId], references: [id])
amount Float
status PaymentStatus @default(PENDING)
paidAt DateTime?
method String? // PIX, CASH
createdAt DateTime @default(now())
@@unique([financialEventId, playerId])
}
enum FinancialEventType {
MONTHLY_FEE
EXTRA_EVENT
CONTRIBUTION
}
enum FinancialEventStatus {
OPEN
CLOSED
CANCELED
}
enum PaymentStatus {
PENDING
PAID
WAIVED
}

47
prisma/seed.ts Normal file
View File

@@ -0,0 +1,47 @@
import { PrismaClient } from '@prisma/client'
import bcrypt from 'bcryptjs'
const prisma = new PrismaClient()
async function main() {
const password = await bcrypt.hash('Android@2020', 12)
// 1. Criar Admin
await prisma.admin.upsert({
where: { email: 'admin@temfut.com' },
update: {},
create: {
email: 'admin@temfut.com',
name: 'Super Admin',
password: password,
role: 'SUPER_ADMIN'
}
})
// 2. Criar Pelada de Exemplo
await prisma.group.upsert({
where: { email: 'erik@idealpages.com.br' },
update: {},
create: {
name: 'Fut de Quarta',
slug: 'futdequarta',
email: 'erik@idealpages.com.br',
password: password,
status: 'ACTIVE',
plan: 'PRO',
primaryColor: '#10b981',
secondaryColor: '#000000'
}
})
console.log('Seed concluído com sucesso!')
}
main()
.catch((e) => {
console.error(e)
process.exit(1)
})
.finally(async () => {
await prisma.$disconnect()
})

1
public/file.svg Normal file
View File

@@ -0,0 +1 @@
<svg fill="none" viewBox="0 0 16 16" xmlns="http://www.w3.org/2000/svg"><path d="M14.5 13.5V5.41a1 1 0 0 0-.3-.7L9.8.29A1 1 0 0 0 9.08 0H1.5v13.5A2.5 2.5 0 0 0 4 16h8a2.5 2.5 0 0 0 2.5-2.5m-1.5 0v-7H8v-5H3v12a1 1 0 0 0 1 1h8a1 1 0 0 0 1-1M9.5 5V2.12L12.38 5zM5.13 5h-.62v1.25h2.12V5zm-.62 3h7.12v1.25H4.5zm.62 3h-.62v1.25h7.12V11z" clip-rule="evenodd" fill="#666" fill-rule="evenodd"/></svg>

After

Width:  |  Height:  |  Size: 391 B

1
public/globe.svg Normal file
View File

@@ -0,0 +1 @@
<svg fill="none" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16"><g clip-path="url(#a)"><path fill-rule="evenodd" clip-rule="evenodd" d="M10.27 14.1a6.5 6.5 0 0 0 3.67-3.45q-1.24.21-2.7.34-.31 1.83-.97 3.1M8 16A8 8 0 1 0 8 0a8 8 0 0 0 0 16m.48-1.52a7 7 0 0 1-.96 0H7.5a4 4 0 0 1-.84-1.32q-.38-.89-.63-2.08a40 40 0 0 0 3.92 0q-.25 1.2-.63 2.08a4 4 0 0 1-.84 1.31zm2.94-4.76q1.66-.15 2.95-.43a7 7 0 0 0 0-2.58q-1.3-.27-2.95-.43a18 18 0 0 1 0 3.44m-1.27-3.54a17 17 0 0 1 0 3.64 39 39 0 0 1-4.3 0 17 17 0 0 1 0-3.64 39 39 0 0 1 4.3 0m1.1-1.17q1.45.13 2.69.34a6.5 6.5 0 0 0-3.67-3.44q.65 1.26.98 3.1M8.48 1.5l.01.02q.41.37.84 1.31.38.89.63 2.08a40 40 0 0 0-3.92 0q.25-1.2.63-2.08a4 4 0 0 1 .85-1.32 7 7 0 0 1 .96 0m-2.75.4a6.5 6.5 0 0 0-3.67 3.44 29 29 0 0 1 2.7-.34q.31-1.83.97-3.1M4.58 6.28q-1.66.16-2.95.43a7 7 0 0 0 0 2.58q1.3.27 2.95.43a18 18 0 0 1 0-3.44m.17 4.71q-1.45-.12-2.69-.34a6.5 6.5 0 0 0 3.67 3.44q-.65-1.27-.98-3.1" fill="#666"/></g><defs><clipPath id="a"><path fill="#fff" d="M0 0h16v16H0z"/></clipPath></defs></svg>

After

Width:  |  Height:  |  Size: 1.0 KiB

1
public/next.svg Normal file
View File

@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 394 80"><path fill="#000" d="M262 0h68.5v12.7h-27.2v66.6h-13.6V12.7H262V0ZM149 0v12.7H94v20.4h44.3v12.6H94v21h55v12.6H80.5V0h68.7zm34.3 0h-17.8l63.8 79.4h17.9l-32-39.7 32-39.6h-17.9l-23 28.6-23-28.6zm18.3 56.7-9-11-27.1 33.7h17.8l18.3-22.7z"/><path fill="#000" d="M81 79.3 17 0H0v79.3h13.6V17l50.2 62.3H81Zm252.6-.4c-1 0-1.8-.4-2.5-1s-1.1-1.6-1.1-2.6.3-1.8 1-2.5 1.6-1 2.6-1 1.8.3 2.5 1a3.4 3.4 0 0 1 .6 4.3 3.7 3.7 0 0 1-3 1.8zm23.2-33.5h6v23.3c0 2.1-.4 4-1.3 5.5a9.1 9.1 0 0 1-3.8 3.5c-1.6.8-3.5 1.3-5.7 1.3-2 0-3.7-.4-5.3-1s-2.8-1.8-3.7-3.2c-.9-1.3-1.4-3-1.4-5h6c.1.8.3 1.6.7 2.2s1 1.2 1.6 1.5c.7.4 1.5.5 2.4.5 1 0 1.8-.2 2.4-.6a4 4 0 0 0 1.6-1.8c.3-.8.5-1.8.5-3V45.5zm30.9 9.1a4.4 4.4 0 0 0-2-3.3 7.5 7.5 0 0 0-4.3-1.1c-1.3 0-2.4.2-3.3.5-.9.4-1.6 1-2 1.6a3.5 3.5 0 0 0-.3 4c.3.5.7.9 1.3 1.2l1.8 1 2 .5 3.2.8c1.3.3 2.5.7 3.7 1.2a13 13 0 0 1 3.2 1.8 8.1 8.1 0 0 1 3 6.5c0 2-.5 3.7-1.5 5.1a10 10 0 0 1-4.4 3.5c-1.8.8-4.1 1.2-6.8 1.2-2.6 0-4.9-.4-6.8-1.2-2-.8-3.4-2-4.5-3.5a10 10 0 0 1-1.7-5.6h6a5 5 0 0 0 3.5 4.6c1 .4 2.2.6 3.4.6 1.3 0 2.5-.2 3.5-.6 1-.4 1.8-1 2.4-1.7a4 4 0 0 0 .8-2.4c0-.9-.2-1.6-.7-2.2a11 11 0 0 0-2.1-1.4l-3.2-1-3.8-1c-2.8-.7-5-1.7-6.6-3.2a7.2 7.2 0 0 1-2.4-5.7 8 8 0 0 1 1.7-5 10 10 0 0 1 4.3-3.5c2-.8 4-1.2 6.4-1.2 2.3 0 4.4.4 6.2 1.2 1.8.8 3.2 2 4.3 3.4 1 1.4 1.5 3 1.5 5h-5.8z"/></svg>

After

Width:  |  Height:  |  Size: 1.3 KiB

1
public/vercel.svg Normal file
View File

@@ -0,0 +1 @@
<svg fill="none" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 1155 1000"><path d="m577.3 0 577.4 1000H0z" fill="#fff"/></svg>

After

Width:  |  Height:  |  Size: 128 B

1
public/window.svg Normal file
View File

@@ -0,0 +1 @@
<svg fill="none" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16"><path fill-rule="evenodd" clip-rule="evenodd" d="M1.5 2.5h13v10a1 1 0 0 1-1 1h-11a1 1 0 0 1-1-1zM0 1h16v11.5a2.5 2.5 0 0 1-2.5 2.5h-11A2.5 2.5 0 0 1 0 12.5zm3.75 4.5a.75.75 0 1 0 0-1.5.75.75 0 0 0 0 1.5M7 4.75a.75.75 0 1 1-1.5 0 .75.75 0 0 1 1.5 0m1.75.75a.75.75 0 1 0 0-1.5.75.75 0 0 0 0 1.5" fill="#666"/></svg>

After

Width:  |  Height:  |  Size: 385 B

67
src/actions/arena.ts Normal file
View File

@@ -0,0 +1,67 @@
'use server'
import { prisma } from '@/lib/prisma'
import { getActiveGroup } from '@/lib/auth'
import { revalidatePath } from 'next/cache'
export async function createArena(formData: FormData) {
const group = await getActiveGroup()
if (!group) return { success: false, error: 'Unauthorized' }
const name = formData.get('name') as string
const address = formData.get('address') as string
if (!name) return { success: false, error: 'Nome é obrigatório' }
try {
await prisma.arena.create({
data: {
name,
address,
groupId: group.id
}
})
revalidatePath('/dashboard/settings')
revalidatePath('/dashboard/matches') // To update select list in match creation
return { success: true }
} catch (error) {
console.error('Error creating arena:', error)
return { success: false, error: 'Erro ao criar arena' }
}
}
export async function getArenas() {
const group = await getActiveGroup()
if (!group) return []
return await prisma.arena.findMany({
where: { groupId: group.id },
orderBy: { createdAt: 'desc' }
})
}
export async function deleteArena(id: string) {
const group = await getActiveGroup()
if (!group) return { success: false, error: 'Unauthorized' }
try {
// Ensure the arena belongs to the group
const arena = await prisma.arena.findFirst({
where: { id, groupId: group.id }
})
if (!arena) return { success: false, error: 'Arena not found' }
await prisma.arena.delete({
where: { id }
})
revalidatePath('/dashboard/settings')
revalidatePath('/dashboard/matches')
return { success: true }
} catch (error) {
console.error('Error deleting arena:', error)
return { success: false, error: 'Erro ao deletar arena' }
}
}

71
src/actions/attendance.ts Normal file
View File

@@ -0,0 +1,71 @@
'use server'
import { prisma } from '@/lib/prisma'
import { revalidatePath } from 'next/cache'
export async function confirmAttendance(matchId: string, playerId: string) {
// @ts-ignore
await prisma.attendance.upsert({
where: {
playerId_matchId: {
playerId,
matchId
}
},
create: {
playerId,
matchId,
status: 'CONFIRMED'
},
update: {
status: 'CONFIRMED'
}
})
revalidatePath(`/match/${matchId}/confirmacao`)
}
export async function cancelAttendance(matchId: string, playerId: string) {
// @ts-ignore
await prisma.attendance.update({
where: {
playerId_matchId: {
playerId,
matchId
}
},
data: {
status: 'CANCELED'
}
})
revalidatePath(`/match/${matchId}/confirmacao`)
}
export async function getMatchWithAttendance(matchId: string) {
return await prisma.match.findUnique({
where: { id: matchId },
include: {
group: {
include: {
players: true,
sponsors: true
}
},
attendances: {
include: {
player: true
}
},
teams: {
include: {
players: {
include: {
player: true
}
}
}
}
}
})
}

238
src/actions/finance.ts Normal file
View File

@@ -0,0 +1,238 @@
'use server'
import { prisma } from '@/lib/prisma'
import { getActiveGroup } from '@/lib/auth'
import { revalidatePath } from 'next/cache'
import { FinancialEventType } from '@prisma/client'
// --- Financial Events (Create, Delete, Update) ---
export async function createFinancialEvent(data: {
title: string
description?: string
totalAmount?: number
pricePerPerson?: number
dueDate: string
type: FinancialEventType
selectedPlayerIds: string[]
isRecurring?: boolean
recurrenceEndDate?: string
}) {
const group = await getActiveGroup()
if (!group) return { success: false, error: 'Unauthorized' }
if (!data.title || !data.dueDate) {
return { success: false, error: 'Campos obrigatórios faltando' }
}
const startDate = new Date(data.dueDate)
let endDate: Date
if (data.recurrenceEndDate) {
endDate = new Date(data.recurrenceEndDate)
} else {
// Default to same day if not recurring, or 6 months if recurring and no end date (to avoid infinite)
endDate = data.isRecurring
? new Date(startDate.getTime() + 180 * 24 * 60 * 60 * 1000)
: startDate
}
let currentDate = startDate
const createdEvents = []
try {
do {
const eventTitle = data.isRecurring
? `${data.title} - ${currentDate.toLocaleDateString('pt-BR', { month: 'long', year: 'numeric' })}`
: data.title
const event = await prisma.financialEvent.create({
data: {
title: eventTitle,
description: data.description,
totalAmount: data.totalAmount,
pricePerPerson: data.pricePerPerson,
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,
*/
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
}
return {
playerId,
amount
}
})
}
}
})
createdEvents.push(event)
if (data.isRecurring) {
// Add 1 month
const nextDate = new Date(currentDate)
nextDate.setMonth(nextDate.getMonth() + 1)
currentDate = nextDate
} else {
break
}
} while (data.isRecurring && currentDate <= endDate)
revalidatePath('/dashboard/financial')
return { success: true, count: createdEvents.length }
} catch (error) {
console.error('Error creating financial event:', error)
return { success: false, error: 'Erro ao criar evento financeiro' }
}
}
export async function deleteFinancialEvents(ids: string[]) {
const group = await getActiveGroup()
if (!group) return { success: false, error: 'Unauthorized' }
try {
await prisma.financialEvent.deleteMany({
where: {
id: { in: ids },
groupId: group.id
}
})
revalidatePath('/dashboard/financial')
return { success: true }
} catch (error) {
return { success: false, error: 'Erro ao deletar eventos' }
}
}
export async function deleteFinancialEvent(id: string) {
const group = await getActiveGroup()
if (!group) return { success: false, error: 'Unauthorized' }
try {
await prisma.financialEvent.delete({
where: { id, groupId: group.id }
})
revalidatePath('/dashboard/financial')
return { success: true }
} catch (error) {
return { success: false, error: 'Erro ao deletar evento' }
}
}
export async function getFinancialEvents() {
const group = await getActiveGroup()
if (!group) return []
const events = await prisma.financialEvent.findMany({
where: { groupId: group.id },
include: {
payments: {
include: {
player: true
}
}
},
orderBy: { dueDate: 'asc' }
})
return events.map(event => {
const totalExpected = event.payments.reduce((acc, p) => acc + p.amount, 0)
const totalPaid = event.payments
.filter(p => p.status === 'PAID')
.reduce((acc, p) => acc + p.amount, 0)
return {
...event,
stats: {
totalExpected,
totalPaid,
paidCount: event.payments.filter(p => p.status === 'PAID').length,
totalCount: event.payments.length
}
}
})
}
export async function getFinancialEventDetails(id: string) {
const group = await getActiveGroup()
if (!group) return null
return await prisma.financialEvent.findUnique({
where: { id, groupId: group.id },
include: {
payments: {
include: {
player: true
},
orderBy: {
player: {
name: 'asc'
}
}
}
}
})
}
// --- Payments ---
export async function markPaymentAsPaid(paymentId: string) {
try {
await prisma.payment.update({
where: { id: paymentId },
data: {
status: 'PAID',
paidAt: new Date(),
method: 'MANUAL'
}
})
revalidatePath('/dashboard/financial')
return { success: true }
} catch (error) {
return { success: false, error: 'Erro ao atualizar pagamento' }
}
}
export async function markPaymentAsPending(paymentId: string) {
try {
await prisma.payment.update({
where: { id: paymentId },
data: {
status: 'PENDING',
paidAt: null,
method: null
}
})
revalidatePath('/dashboard/financial')
return { success: true }
} catch (error) {
return { success: false, error: 'Erro ao atualizar pagamento' }
}
}
// --- Settings ---
export async function updatePixKey(pixKey: string) {
const group = await getActiveGroup()
if (!group) return { success: false }
await prisma.group.update({
where: { id: group.id },
data: { pixKey }
})
revalidatePath('/dashboard/settings')
return { success: true }
}

50
src/actions/group.ts Normal file
View File

@@ -0,0 +1,50 @@
'use server'
import { prisma } from '@/lib/prisma'
import { revalidatePath } from 'next/cache'
import { uploadFile } from '@/lib/upload'
import { cookies } from 'next/headers'
import { redirect } from 'next/navigation'
export async function createGroup(formData: FormData) {
const name = formData.get('name') as string
const primaryColor = formData.get('primaryColor') as string
const secondaryColor = formData.get('secondaryColor') as string
const logoFile = formData.get('logo') as File
let logoUrl = null
try {
if (logoFile && logoFile.size > 0) {
console.log('Logo file detected, starting upload...')
logoUrl = await uploadFile(logoFile)
}
} catch (error) {
console.error('SERVER ACTION ERROR (Logo Upload):', error)
// We continue so the group is created even without logo
}
const group = await prisma.group.create({
data: {
name,
primaryColor,
secondaryColor,
logoUrl,
},
})
const cookieStore = await cookies()
cookieStore.set('group_id', group.id, { path: '/' })
revalidatePath('/')
redirect('/dashboard')
}
export async function getGroups() {
return await prisma.group.findMany({
include: {
players: true,
sponsors: true,
},
})
}

230
src/actions/match.ts Normal file
View File

@@ -0,0 +1,230 @@
'use server'
import { prisma } from '@/lib/prisma'
import { revalidatePath } from 'next/cache'
import { MatchStatus } from '@prisma/client'
import { cookies } from 'next/headers'
export async function createMatch(
groupId: string,
date: string,
teamsData: any[],
status: MatchStatus = 'IN_PROGRESS',
location?: string,
maxPlayers?: number,
drawSeed?: string,
arenaId?: string
) {
const match = await prisma.match.create({
data: {
date: new Date(date),
groupId: groupId,
status: status,
// @ts-ignore
location: location,
arenaId: arenaId || null,
// @ts-ignore
maxPlayers: maxPlayers,
// @ts-ignore
drawSeed: drawSeed,
teams: {
create: teamsData.map(team => ({
name: team.name,
color: team.color,
players: {
create: team.players.map((p: any) => ({
playerId: p.id
}))
}
}))
}
}
})
revalidatePath('/dashboard/matches')
return match
}
// @ts-ignore
// @ts-ignore
export async function createScheduledMatch(
groupId: string,
date: string,
location: string,
maxPlayers: number,
isRecurring: boolean = false,
recurrenceInterval: string = 'WEEKLY',
recurrenceEndDate?: string,
arenaId?: string
) {
let finalGroupId = groupId
if (!finalGroupId) {
const cookieStore = await cookies()
finalGroupId = cookieStore.get('group_id')?.value || ''
}
if (!finalGroupId) throw new Error('Group ID is required')
const createdMatches = []
const startDate = new Date(date)
let endDate: Date
// If explicit end date, set to end of that day (23:59:59) so comparison works with match times
// We use simple string concatenation to force local time parsing instead of UTC defaults
if (recurrenceEndDate) {
endDate = new Date(`${recurrenceEndDate}T23:59:59`)
} else {
// Default to 4 weeks if infinite/not set
endDate = new Date(startDate.getTime() + 4 * 7 * 24 * 60 * 60 * 1000)
}
let currentDate = startDate
// Verify if arena exists to prevent FK errors (stale IDs after DB reset)
let validArenaId = null
if (arenaId) {
const arenaExists = await prisma.arena.findUnique({
where: { id: arenaId }
})
if (arenaExists) {
validArenaId = arenaId
}
}
// Always create at least the first one, loop while date <= endDate (if recurring)
// If not recurring, loop runs once
do {
const match = await prisma.match.create({
data: {
groupId: finalGroupId,
date: new Date(currentDate),
// @ts-ignore
location,
arenaId: validArenaId,
// @ts-ignore
maxPlayers,
status: 'SCHEDULED' as MatchStatus,
// @ts-ignore
isRecurring,
// @ts-ignore
recurrenceInterval: isRecurring ? recurrenceInterval : null,
// @ts-ignore
recurrenceEndDate: recurrenceEndDate ? endDate : null
}
})
createdMatches.push(match)
if (isRecurring && recurrenceInterval === 'WEEKLY') {
const nextDate = new Date(currentDate);
nextDate.setDate(nextDate.getDate() + 7);
currentDate = nextDate;
} else {
break // Exit if not recurring
}
} while (isRecurring && currentDate <= endDate)
revalidatePath('/dashboard/matches')
return createdMatches[0]
}
export async function updateMatchStatus(matchId: string, status: MatchStatus) {
const match = await prisma.match.update({
where: { id: matchId },
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
// @ts-ignore
if (status === 'COMPLETED' && match.isRecurring && match.recurrenceInterval === 'WEEKLY') {
const nextDate = new Date(match.date)
nextDate.setDate(nextDate.getDate() + 7)
// Check if we passed the end date
// @ts-ignore
if (match.recurrenceEndDate && nextDate > match.recurrenceEndDate) {
return
}
// Check if a match already exists for this group around this time (+- 1 day to be safe)
const existingNextMatch = await prisma.match.findFirst({
where: {
groupId: match.groupId,
status: 'SCHEDULED',
date: {
gte: new Date(nextDate.getTime() - 24 * 60 * 60 * 1000),
lte: new Date(nextDate.getTime() + 24 * 60 * 60 * 1000)
}
}
})
if (!existingNextMatch) {
await prisma.match.create({
data: {
groupId: match.groupId,
date: nextDate,
location: match.location,
arenaId: match.arenaId,
maxPlayers: match.maxPlayers,
status: 'SCHEDULED',
// @ts-ignore
isRecurring: true,
// @ts-ignore
recurrenceInterval: 'WEEKLY',
// @ts-ignore
recurrenceEndDate: match.recurrenceEndDate
}
})
}
}
revalidatePath('/dashboard/matches')
}
export async function deleteMatch(matchId: string) {
// In a transaction to ensure all related records are cleaned up
await prisma.$transaction([
// Delete team players first (relation to Team)
prisma.teamPlayer.deleteMany({
where: { team: { matchId: matchId } }
}),
// Delete teams
prisma.team.deleteMany({
where: { matchId: matchId }
}),
// Delete attendances
// @ts-ignore
prisma.attendance.deleteMany({
where: { matchId: matchId }
}),
// Finally delete the match
prisma.match.delete({
where: { id: matchId }
})
])
revalidatePath('/dashboard/matches')
}
export async function deleteMatches(matchIds: string[]) {
await prisma.$transaction([
// Delete team players
prisma.teamPlayer.deleteMany({
where: { team: { matchId: { in: matchIds } } }
}),
// Delete teams
prisma.team.deleteMany({
where: { matchId: { in: matchIds } }
}),
// Delete attendances
// @ts-ignore
prisma.attendance.deleteMany({
where: { matchId: { in: matchIds } }
}),
// Delete matches
prisma.match.deleteMany({
where: { id: { in: matchIds } }
})
])
revalidatePath('/dashboard/matches')
}

46
src/actions/player.ts Normal file
View File

@@ -0,0 +1,46 @@
'use server'
import { prisma } from '@/lib/prisma'
import { revalidatePath } from 'next/cache'
export async function addPlayer(groupId: string, name: string, level: number, number: number | null, position: string) {
// Check if number already exists in this group (only if number is provided)
if (number !== null) {
const existing = await prisma.player.findFirst({
where: {
groupId,
number
}
})
if (existing) {
throw new Error(`O número ${number} já está sendo usado por outro jogador deste grupo.`)
}
}
const player = await prisma.player.create({
data: {
name,
level,
groupId,
number,
position
},
})
revalidatePath('/dashboard/players')
return player
}
export async function deletePlayer(id: string) {
await prisma.player.delete({ where: { id } })
revalidatePath('/')
}
export async function deletePlayers(ids: string[]) {
await prisma.player.deleteMany({
where: {
id: { in: ids }
}
})
revalidatePath('/')
}

View File

@@ -0,0 +1,25 @@
import { getActiveGroup } from '@/lib/auth'
import { getFinancialEvents } from '@/actions/finance'
import { FinancialDashboard } from '@/components/FinancialDashboard'
export default async function FinancialPage() {
const group = await getActiveGroup()
if (!group) return null
// We fetch events and players
// getActiveGroup already includes players, so we can use that list for selection
const events = await getFinancialEvents()
return (
<div className="max-w-5xl mx-auto space-y-8 pb-12">
<header className="flex items-center justify-between">
<div className="space-y-2">
<h2 className="text-3xl font-bold tracking-tight">Financeiro</h2>
<p className="text-muted text-lg">Gerencie mensalidades, caixinha e arrecadações extras.</p>
</div>
</header>
<FinancialDashboard events={events} players={group.players} />
</div>
)
}

View File

@@ -0,0 +1,29 @@
import { getActiveGroup } from '@/lib/auth'
import { MatchFlow } from '@/components/MatchFlow'
import Link from 'next/link'
import { ChevronLeft } from 'lucide-react'
import { getArenas } from '@/actions/arena'
export default async function NewMatchPage() {
const group = await getActiveGroup()
const arenas = await getArenas()
return (
<div className="space-y-8">
<header className="flex items-center gap-4">
<Link
href="/dashboard/matches"
className="p-2 hover:bg-surface-raised rounded-lg transition-all border border-border"
>
<ChevronLeft className="w-5 h-5" />
</Link>
<div>
<h2 className="text-2xl font-bold tracking-tight">Nova Partida</h2>
<p className="text-muted text-sm">Configure os detalhes e realize o sorteio.</p>
</div>
</header>
<MatchFlow group={group} arenas={arenas} />
</div>
)
}

View File

@@ -0,0 +1,40 @@
import { getActiveGroup } from '@/lib/auth'
import { MatchHistory } from '@/components/MatchHistory'
import { Plus, Calendar } from 'lucide-react'
import Link from 'next/link'
export default async function MatchesPage() {
const group = await getActiveGroup() as any
return (
<div className="space-y-8">
<header className="flex flex-col md:flex-row md:items-center justify-between gap-6 pb-6 border-b border-border/50">
<div className="space-y-1">
<h2 className="text-2xl font-bold tracking-tight">Partidas</h2>
<p className="text-muted text-sm max-w-lg">Gerencie o histórico de sorteios e agende novos eventos.</p>
</div>
<div className="flex flex-col sm:flex-row gap-3 w-full md:w-auto">
<Link
href="/dashboard/matches/schedule"
className="ui-button-ghost justify-center w-full sm:w-auto h-12 sm:h-10"
>
<Calendar className="w-4 h-4" /> Agendar Evento
</Link>
<Link
href="/dashboard/matches/new"
className="ui-button justify-center w-full sm:w-auto h-12 sm:h-10 shadow-lg shadow-primary/20"
>
<Plus className="w-4 h-4" /> Sorteio Rápido
</Link>
</div>
</header>
<section>
<MatchHistory
matches={group?.matches || []}
players={group?.players || []}
/>
</section>
</div>
)
}

View File

@@ -0,0 +1,233 @@
'use client'
import React, { useState, useMemo } from 'react'
import { Calendar, MapPin, Users, ArrowRight, Trophy } from 'lucide-react'
import { createScheduledMatch } from '@/actions/match'
import { useRouter } from 'next/navigation'
import { getArenas } from '@/actions/arena'
import type { Arena } from '@prisma/client'
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
const router = useRouter()
const [date, setDate] = useState('')
const [location, setLocation] = useState('')
const [selectedArenaId, setSelectedArenaId] = useState('')
const [arenas, setArenas] = useState<Arena[]>([])
const [maxPlayers, setMaxPlayers] = useState('24')
const [isRecurring, setIsRecurring] = useState(false)
const [recurrenceEndDate, setRecurrenceEndDate] = useState('')
const [isSubmitting, setIsSubmitting] = useState(false)
React.useEffect(() => {
getArenas().then(setArenas)
}, [])
// Calculate preview dates
const previewDates = useMemo(() => {
if (!date || !isRecurring) return []
const dates: Date[] = []
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)
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)
}
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
}
return dates
}, [date, isRecurring, recurrenceEndDate])
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault()
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),
isRecurring,
'WEEKLY',
recurrenceEndDate || undefined,
selectedArenaId
)
router.push('/dashboard/matches')
} catch (error) {
console.error(error)
} finally {
setIsSubmitting(false)
}
}
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>
<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>
</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" />
<input
required={!selectedArenaId}
type="text"
placeholder={selectedArenaId ? "Detalhes do local (opcional)" : "Ex: Arena Central..."}
value={location}
onChange={(e) => setLocation(e.target.value)}
className="ui-input w-full pl-10"
/>
</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>
<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}
/>
<p className="text-[10px] text-muted mt-1.5 mb-4">
Deixe em branco para repetir indefinidamente.
</p>
{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>
)}
</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>
</div>
)
}

View File

@@ -0,0 +1,161 @@
import { getActiveGroup } from '@/lib/auth'
import { Trophy, Users, Calendar, ArrowUpRight, Activity, Banknote, Settings } from 'lucide-react'
import Link from 'next/link'
export default async function DashboardPage() {
const group = await getActiveGroup() as any
const stats = [
{ name: 'Partidas', value: group?.matches.length || 0, icon: Calendar, description: 'Total realizado' },
{ name: 'Jogadores', value: group?.players.length || 0, icon: Users, description: 'Membros do grupo' },
{ name: 'Times Sorteados', value: (group?.matches.length || 0) * 2, icon: Trophy, description: 'Escalações geradas' },
]
return (
<div className="space-y-8">
<header className="flex flex-col sm:flex-row sm:items-center justify-between gap-4">
<div>
<h2 className="text-2xl font-bold tracking-tight">Dashboard</h2>
<p className="text-muted text-sm">Resumo das atividades de <span className="text-foreground font-medium">{group?.name}</span>.</p>
</div>
<div className="flex gap-2">
<Link href="/dashboard/matches/new" className="ui-button whitespace-nowrap">
<Plus className="w-4 h-4" /> Novo Sorteio
</Link>
</div>
</header>
<div className="grid grid-cols-1 md:grid-cols-3 gap-5">
{stats.map((stat) => (
<div key={stat.name} className="ui-card p-6">
<div className="flex items-start justify-between">
<div>
<p className="text-xs font-medium text-muted uppercase tracking-wider mb-1">{stat.name}</p>
<p className="text-3xl font-bold">{stat.value}</p>
<p className="text-[11px] text-muted mt-2">{stat.description}</p>
</div>
<div className="p-2.5 rounded-lg bg-surface-raised border border-border">
<stat.icon className="w-5 h-5 text-primary" />
</div>
</div>
</div>
))}
</div>
<div className="grid grid-cols-1 lg:grid-cols-2 gap-8">
{/* Atividades Recentes */}
<section className="space-y-4">
<div className="flex items-center justify-between border-b border-border pb-4">
<h3 className="font-semibold flex items-center gap-2">
<Activity className="w-4 h-4 text-primary" />
Próximas Partidas
</h3>
<Link href="/dashboard/matches" className="text-xs font-medium text-primary hover:underline flex items-center gap-1">
Ver todas <ArrowUpRight className="w-3 h-3" />
</Link>
</div>
<div className="grid gap-3">
{group?.matches.length ? (
group.matches.slice(0, 3).map((match: any) => (
<div key={match.id} className="ui-card ui-card-hover p-4 flex items-center justify-between group">
<div className="flex items-center gap-4">
<div className="w-12 h-12 bg-surface-raised rounded-lg flex flex-col items-center justify-center border border-border group-hover:border-primary/50 transition-colors">
<p className="text-lg font-bold leading-none">{new Date(match.date).getDate()}</p>
<p className="text-[10px] text-muted uppercase font-bold">
{new Date(match.date).toLocaleDateString('pt-BR', { month: 'short' }).replace('.', '')}
</p>
</div>
<div>
<p className="font-medium text-sm">Pelada de {new Date(match.date).toLocaleDateString('pt-BR', { weekday: 'long' })}</p>
<p className="text-xs text-muted mt-0.5">
{match.status === 'SCHEDULED'
? `${match.attendances.filter((a: any) => a.status === 'CONFIRMED').length} confirmados`
: `${match.teams.length} times sorteados`}
</p>
</div>
</div>
<Link
href={`/dashboard/matches/${match.id}`}
className="p-2 text-muted hover:text-primary transition-all"
>
<ArrowUpRight className="w-4 h-4" />
</Link>
</div>
))
) : (
<div className="ui-card p-8 text-center border-dashed flex flex-col items-center justify-center">
<div className="w-12 h-12 rounded-full bg-surface-raised flex items-center justify-center mb-3">
<Calendar className="w-6 h-6 text-muted" />
</div>
<p className="text-sm font-medium">Nenhuma partida agendada</p>
<p className="text-xs text-muted mt-1 mb-4">Crie um sorteio para começar a organizar.</p>
<Link href="/dashboard/matches/new" className="text-xs text-primary font-bold hover:underline">
Criar agora
</Link>
</div>
)}
</div>
</section>
{/* Ações Rápidas */}
<section className="space-y-4">
<div className="flex items-center justify-between border-b border-border pb-4">
<h3 className="font-semibold flex items-center gap-2">
<Trophy className="w-4 h-4 text-primary" />
Ações Rápidas
</h3>
</div>
<div className="grid grid-cols-2 gap-3">
<Link href="/dashboard/players" className="ui-card p-4 hover:border-primary/50 transition-all group flex flex-col items-center text-center justify-center h-32 gap-3">
<div className="w-10 h-10 rounded-full bg-surface-raised flex items-center justify-center group-hover:bg-primary group-hover:text-background transition-colors">
<Users className="w-5 h-5" />
</div>
<span className="text-sm font-medium">Gerenciar Jogadores</span>
</Link>
<Link href="/dashboard/matches/new" className="ui-card p-4 hover:border-primary/50 transition-all group flex flex-col items-center text-center justify-center h-32 gap-3">
<div className="w-10 h-10 rounded-full bg-surface-raised flex items-center justify-center group-hover:bg-primary group-hover:text-background transition-colors">
<Calendar className="w-5 h-5" />
</div>
<span className="text-sm font-medium">Agendar Pelada</span>
</Link>
<Link href="/dashboard/financial" className="ui-card p-4 hover:border-primary/50 transition-all group flex flex-col items-center text-center justify-center h-32 gap-3">
<div className="w-10 h-10 rounded-full bg-surface-raised flex items-center justify-center group-hover:bg-primary group-hover:text-background transition-colors">
<Banknote className="w-5 h-5" />
</div>
<span className="text-sm font-medium">Financeiro</span>
</Link>
<Link href="/dashboard/settings" className="ui-card p-4 hover:border-primary/50 transition-all group flex flex-col items-center text-center justify-center h-32 gap-3">
<div className="w-10 h-10 rounded-full bg-surface-raised flex items-center justify-center group-hover:bg-primary group-hover:text-background transition-colors">
<Settings className="w-5 h-5" />
</div>
<span className="text-sm font-medium">Configurações</span>
</Link>
</div>
</section>
</div>
</div>
)
}
function Plus(props: any) {
return (
<svg
{...props}
xmlns="http://www.w3.org/2000/svg"
width="24"
height="24"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
>
<path d="M5 12h14" />
<path d="M12 5v14" />
</svg>
)
}

View File

@@ -0,0 +1,19 @@
import { getActiveGroup } from '@/lib/auth'
import { PlayersList } from '@/components/PlayersList'
export default async function PlayersPage() {
const group = await getActiveGroup()
return (
<div className="space-y-8">
<header className="pb-6 border-b border-border/50">
<h2 className="text-2xl font-bold tracking-tight">Jogadores</h2>
<p className="text-muted text-sm">Gerencie o elenco e o nível técnico dos atletas.</p>
</header>
<section>
<PlayersList group={group} />
</section>
</div>
)
}

View File

@@ -0,0 +1,39 @@
import { getActiveGroup } from '@/lib/auth'
import { SettingsForm } from '@/components/SettingsForm'
import { getArenas } from '@/actions/arena'
import { ArenasManager } from '@/components/ArenasManager'
import { SettingsTabs } from '@/components/SettingsTabs'
export default async function SettingsPage() {
const group = await getActiveGroup()
if (!group) return null
const arenas = await getArenas()
return (
<div className="max-w-4xl mx-auto space-y-8 pb-12">
<header className="space-y-2">
<h2 className="text-3xl font-bold tracking-tight">Configurações</h2>
<p className="text-muted text-lg">Gerencie a identidade visual e as preferências do seu grupo.</p>
</header>
<SettingsTabs
branding={
<SettingsForm
initialData={{
name: group.name,
slug: group.slug,
logoUrl: group.logoUrl,
primaryColor: group.primaryColor,
secondaryColor: group.secondaryColor,
pixKey: group.pixKey,
pixName: group.pixName
}}
/>
}
arenas={<ArenasManager arenas={arenas} />}
/>
</div>
)
}

View File

@@ -0,0 +1,28 @@
import { Sidebar } from '@/components/Sidebar'
import { getActiveGroup } from '@/lib/auth'
import { redirect } from 'next/navigation'
import { ThemeWrapper } from '@/components/ThemeWrapper'
import { MobileNav } from '@/components/MobileNav'
export default async function DashboardLayout({
children,
}: {
children: React.ReactNode
}) {
const group = await getActiveGroup()
if (!group) {
redirect('/')
}
return (
<div className="flex flex-col md:flex-row min-h-screen bg-background text-foreground">
<ThemeWrapper primaryColor={group.primaryColor} />
<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">
{children}
</main>
</div>
)
}

View File

@@ -0,0 +1,32 @@
import { headers } from 'next/headers'
import { redirect } from 'next/navigation'
import { PrismaClient } from '@prisma/client'
const prisma = new PrismaClient()
interface Props {
children: React.ReactNode
}
export default async function SubdomainLayout({ children }: Props) {
const headersList = await headers()
const slug = headersList.get('x-current-slug')
// If no slug, this is the main domain - render normally
if (!slug) {
return <>{children}</>
}
// Check if group exists
const group = await prisma.group.findUnique({
where: { slug },
select: { id: true, name: true }
})
// If group doesn't exist, redirect to not found page
if (!group) {
redirect('/not-found-pelada')
}
return <>{children}</>
}

386
src/app/LandingPage.tsx Normal file
View File

@@ -0,0 +1,386 @@
'use client'
import { motion } from 'framer-motion'
import {
Trophy,
Users,
Calendar,
BarChart3,
Zap,
Shield,
Star,
Crown,
Sparkles,
Check,
ArrowRight,
Smartphone,
Globe,
CreditCard,
MessageCircle
} from 'lucide-react'
const features = [
{
icon: Users,
title: 'Gestão de Jogadores',
description: 'Cadastre todos os jogadores com posição, nível e histórico de participação.'
},
{
icon: Calendar,
title: 'Agendamento de Partidas',
description: 'Crie partidas recorrentes e gerencie presenças automaticamente.'
},
{
icon: Zap,
title: 'Sorteio Inteligente',
description: 'Times equilibrados baseados no nível dos jogadores, sem time fraco.'
},
{
icon: BarChart3,
title: 'Estatísticas Completas',
description: 'Acompanhe frequência, pagamentos e histórico de cada jogador.'
},
{
icon: CreditCard,
title: 'Controle Financeiro',
description: 'Gerencie mensalidades, taxas e quem está em dia ou devendo.'
},
{
icon: Globe,
title: 'Subdomínio Exclusivo',
description: 'Sua pelada com URL própria: suapelada.temfut.com'
}
]
const plans = [
{
name: 'Grátis',
price: 'R$ 0',
period: '/mês',
description: 'Para peladas iniciantes',
icon: Star,
color: 'from-zinc-600 to-zinc-700',
borderColor: 'border-zinc-700',
features: [
'Até 20 jogadores',
'Até 4 partidas/mês',
'Sorteio de times',
'Controle de presença',
'Subdomínio personalizado'
],
notIncluded: [
'Controle financeiro',
'Estatísticas avançadas',
'Suporte prioritário'
],
cta: 'Começar Grátis',
popular: false
},
{
name: 'Básico',
price: 'R$ 19',
period: '/mês',
description: 'Para peladas organizadas',
icon: Sparkles,
color: 'from-blue-600 to-blue-700',
borderColor: 'border-blue-500',
features: [
'Até 50 jogadores',
'Partidas ilimitadas',
'Sorteio de times',
'Controle de presença',
'Subdomínio personalizado',
'Controle financeiro básico',
'Histórico de partidas'
],
notIncluded: [
'Estatísticas avançadas',
'Suporte prioritário'
],
cta: 'Assinar Básico',
popular: true
},
{
name: 'Pro',
price: 'R$ 49',
period: '/mês',
description: 'Para peladas profissionais',
icon: Crown,
color: 'from-amber-500 to-orange-600',
borderColor: 'border-amber-500',
features: [
'Jogadores ilimitados',
'Partidas ilimitadas',
'Sorteio avançado de times',
'Controle de presença',
'Subdomínio personalizado',
'Controle financeiro completo',
'Estatísticas avançadas',
'Múltiplos administradores',
'Suporte prioritário 24/7',
'Exportação de dados'
],
notIncluded: [],
cta: 'Assinar Pro',
popular: false
}
]
export default function LandingPage() {
return (
<div className="min-h-screen bg-zinc-950 text-white">
{/* Navbar */}
<nav className="fixed top-0 left-0 right-0 z-50 bg-zinc-950/80 backdrop-blur-xl border-b border-zinc-800/50">
<div className="max-w-7xl mx-auto px-6 py-4">
<div className="flex items-center justify-between">
<div className="flex items-center gap-3">
<div className="w-10 h-10 rounded-xl bg-gradient-to-br from-emerald-500 to-emerald-600 flex items-center justify-center">
<Trophy className="w-5 h-5 text-white" />
</div>
<span className="text-xl font-bold">TemFut</span>
</div>
<div className="hidden md:flex items-center gap-8">
<a href="#features" className="text-sm text-zinc-400 hover:text-white transition-colors">Recursos</a>
<a href="#pricing" className="text-sm text-zinc-400 hover:text-white transition-colors">Planos</a>
<a href="/login" className="text-sm text-zinc-400 hover:text-white transition-colors">Entrar</a>
<a href="/create" className="px-4 py-2 bg-emerald-500 hover:bg-emerald-600 text-white text-sm font-medium rounded-lg transition-colors">
Criar Pelada
</a>
</div>
</div>
</div>
</nav>
{/* Hero */}
<section className="pt-32 pb-20 px-6">
<div className="max-w-7xl mx-auto">
<motion.div
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
className="text-center max-w-4xl mx-auto"
>
<div className="inline-flex items-center gap-2 px-4 py-2 bg-emerald-500/10 border border-emerald-500/20 rounded-full text-emerald-400 text-sm mb-8">
<Zap className="w-4 h-4" />
A plataforma #1 para gestão de peladas
</div>
<h1 className="text-5xl md:text-7xl font-bold mb-6 leading-tight">
Organize sua{' '}
<span className="text-transparent bg-clip-text bg-gradient-to-r from-emerald-400 to-emerald-600">
Pelada
</span>
{' '}como um profissional
</h1>
<p className="text-xl text-zinc-400 mb-10 max-w-2xl mx-auto">
Chega de grupo de WhatsApp bagunçado. Gerencie jogadores,
sorteie times equilibrados e controle pagamentos em um lugar.
</p>
<div className="flex flex-col sm:flex-row gap-4 justify-center">
<a
href="/create"
className="inline-flex items-center justify-center gap-2 px-8 py-4 bg-gradient-to-r from-emerald-500 to-emerald-600 hover:from-emerald-600 hover:to-emerald-700 text-white font-semibold rounded-xl transition-all shadow-lg shadow-emerald-500/25"
>
Criar minha pelada grátis
<ArrowRight className="w-5 h-5" />
</a>
<a
href="#features"
className="inline-flex items-center justify-center gap-2 px-8 py-4 bg-zinc-800 hover:bg-zinc-700 text-white font-semibold rounded-xl transition-all"
>
Ver recursos
</a>
</div>
{/* Stats */}
<div className="grid grid-cols-3 gap-8 mt-16 max-w-2xl mx-auto">
<div className="text-center">
<p className="text-3xl md:text-4xl font-bold text-white">500+</p>
<p className="text-sm text-zinc-500">Peladas ativas</p>
</div>
<div className="text-center">
<p className="text-3xl md:text-4xl font-bold text-white">10k+</p>
<p className="text-sm text-zinc-500">Jogadores</p>
</div>
<div className="text-center">
<p className="text-3xl md:text-4xl font-bold text-white">50k+</p>
<p className="text-sm text-zinc-500">Partidas realizadas</p>
</div>
</div>
</motion.div>
</div>
</section>
{/* Features */}
<section id="features" className="py-20 px-6 bg-zinc-900/50">
<div className="max-w-7xl mx-auto">
<motion.div
initial={{ opacity: 0 }}
whileInView={{ opacity: 1 }}
viewport={{ once: true }}
className="text-center mb-16"
>
<h2 className="text-3xl md:text-4xl font-bold mb-4">
Tudo que você precisa para{' '}
<span className="text-emerald-400">organizar</span>
</h2>
<p className="text-zinc-400 max-w-2xl mx-auto">
Recursos pensados para quem leva a pelada a sério
</p>
</motion.div>
<div className="grid md:grid-cols-2 lg:grid-cols-3 gap-6">
{features.map((feature, index) => (
<motion.div
key={feature.title}
initial={{ opacity: 0, y: 20 }}
whileInView={{ opacity: 1, y: 0 }}
viewport={{ once: true }}
transition={{ delay: index * 0.1 }}
className="bg-zinc-900 border border-zinc-800 rounded-2xl p-6 hover:border-emerald-500/30 transition-colors"
>
<div className="w-12 h-12 rounded-xl bg-emerald-500/10 flex items-center justify-center mb-4">
<feature.icon className="w-6 h-6 text-emerald-400" />
</div>
<h3 className="text-lg font-semibold mb-2">{feature.title}</h3>
<p className="text-sm text-zinc-400">{feature.description}</p>
</motion.div>
))}
</div>
</div>
</section>
{/* Pricing */}
<section id="pricing" className="py-20 px-6">
<div className="max-w-7xl mx-auto">
<motion.div
initial={{ opacity: 0 }}
whileInView={{ opacity: 1 }}
viewport={{ once: true }}
className="text-center mb-16"
>
<h2 className="text-3xl md:text-4xl font-bold mb-4">
Planos para todos os{' '}
<span className="text-emerald-400">tamanhos</span>
</h2>
<p className="text-zinc-400 max-w-2xl mx-auto">
Comece grátis e evolua conforme sua pelada cresce
</p>
</motion.div>
<div className="grid md:grid-cols-3 gap-6 max-w-5xl mx-auto">
{plans.map((plan, index) => {
const Icon = plan.icon
return (
<motion.div
key={plan.name}
initial={{ opacity: 0, y: 20 }}
whileInView={{ opacity: 1, y: 0 }}
viewport={{ once: true }}
transition={{ delay: index * 0.1 }}
className={`relative bg-zinc-900 border rounded-2xl p-6 ${plan.borderColor} ${plan.popular ? 'ring-2 ring-blue-500' : ''}`}
>
{plan.popular && (
<div className="absolute -top-3 left-1/2 -translate-x-1/2 px-3 py-1 bg-blue-500 text-white text-xs font-medium rounded-full">
Mais popular
</div>
)}
<div className={`w-12 h-12 rounded-xl bg-gradient-to-br ${plan.color} flex items-center justify-center mb-4`}>
<Icon className="w-6 h-6 text-white" />
</div>
<h3 className="text-xl font-bold mb-1">{plan.name}</h3>
<p className="text-sm text-zinc-500 mb-4">{plan.description}</p>
<div className="flex items-baseline gap-1 mb-6">
<span className="text-4xl font-bold">{plan.price}</span>
<span className="text-zinc-500">{plan.period}</span>
</div>
<ul className="space-y-3 mb-6">
{plan.features.map((feature) => (
<li key={feature} className="flex items-center gap-2 text-sm">
<Check className="w-4 h-4 text-emerald-400 flex-shrink-0" />
<span>{feature}</span>
</li>
))}
{plan.notIncluded.map((feature) => (
<li key={feature} className="flex items-center gap-2 text-sm text-zinc-600">
<div className="w-4 h-4 flex items-center justify-center flex-shrink-0">
<div className="w-1 h-1 rounded-full bg-zinc-600" />
</div>
<span>{feature}</span>
</li>
))}
</ul>
<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'
}`}
>
{plan.cta}
</a>
</motion.div>
)
})}
</div>
</div>
</section>
{/* CTA */}
<section className="py-20 px-6">
<div className="max-w-4xl mx-auto">
<motion.div
initial={{ opacity: 0, scale: 0.95 }}
whileInView={{ opacity: 1, scale: 1 }}
viewport={{ once: true }}
className="bg-gradient-to-br from-emerald-600 to-emerald-700 rounded-3xl p-8 md:p-12 text-center"
>
<h2 className="text-3xl md:text-4xl font-bold mb-4">
Pronto para organizar sua pelada?
</h2>
<p className="text-emerald-100 mb-8 max-w-xl mx-auto">
Comece gratuitamente em menos de 2 minutos. Sem cartão de crédito.
</p>
<a
href="/create"
className="inline-flex items-center justify-center gap-2 px-8 py-4 bg-white text-emerald-600 font-semibold rounded-xl hover:bg-zinc-100 transition-colors"
>
Criar minha pelada agora
<ArrowRight className="w-5 h-5" />
</a>
</motion.div>
</div>
</section>
{/* Footer */}
<footer className="border-t border-zinc-800 py-12 px-6">
<div className="max-w-7xl mx-auto">
<div className="flex flex-col md:flex-row items-center justify-between gap-6">
<div className="flex items-center gap-3">
<div className="w-8 h-8 rounded-lg bg-gradient-to-br from-emerald-500 to-emerald-600 flex items-center justify-center">
<Trophy className="w-4 h-4 text-white" />
</div>
<span className="font-bold">TemFut</span>
</div>
<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="http://admin.localhost" className="hover:text-white transition-colors">Admin</a>
</div>
<p className="text-sm text-zinc-600">
© 2024 TemFut. Todos os direitos reservados.
</p>
</div>
</div>
</footer>
</div>
)
}

166
src/app/actions.ts Normal file
View File

@@ -0,0 +1,166 @@
'use server'
import { getActiveGroup } from '@/lib/auth'
import { prisma } from '@/lib/prisma'
import { uploadFile } from '@/lib/upload'
import { revalidatePath } from 'next/cache'
import { cookies } from 'next/headers'
import bcrypt from 'bcryptjs'
const SALT_ROUNDS = 12
export async function updateGroupSettings(formData: FormData) {
const group = await getActiveGroup()
if (!group) throw new Error('Unauthorized')
const name = formData.get('name') as string
// Slug is immutable intentionally
const primaryColor = formData.get('primaryColor') as string
const secondaryColor = formData.get('secondaryColor') as string
const pixKey = formData.get('pixKey') as string
const pixName = formData.get('pixName') as string
const logoFile = formData.get('logo') as File | null
let logoUrl = group.logoUrl
if (logoFile && logoFile.size > 0 && logoFile.name !== 'undefined') {
try {
logoUrl = await uploadFile(logoFile)
} catch (error) {
console.error("Upload failed", error)
}
}
try {
await prisma.group.update({
where: { id: group.id },
data: {
name,
// Slug NOT updated
primaryColor,
secondaryColor,
pixKey,
pixName,
logoUrl,
},
})
revalidatePath('/', 'layout')
return { success: true, slug: group.slug }
} catch (error: any) {
if (error.code === 'P2002') {
return { success: false, error: 'Este link já está em uso. Por favor escolha outro.' }
}
console.error("Update error:", error)
return { success: false, error: 'Erro ao salvar alterações.' }
}
}
export async function checkSlugAvailability(slug: string) {
if (!slug || slug.length < 3) return false
const reserved = ['www', 'api', 'admin', 'dashboard', 'app', 'temfut', 'create', 'setup', 'public', 'login']
if (reserved.includes(slug.toLowerCase())) return false
const count = await prisma.group.count({
where: { slug: slug }
})
return count === 0
}
export async function createGroup(formData: FormData) {
const name = formData.get('name') as string
const slug = (formData.get('slug') as string).toLowerCase().trim().replace(/[^a-z0-9-]/g, '')
const email = formData.get('email') as string
const password = formData.get('password') as string
const primaryColor = formData.get('primaryColor') as string
const secondaryColor = formData.get('secondaryColor') as string
const logoFile = formData.get('logo') as File | null
// Validations
if (!name || name.length < 3) {
return { success: false, error: 'Nome deve ter pelo menos 3 caracteres.' }
}
if (!slug || slug.length < 3) {
return { success: false, error: 'Link deve ter pelo menos 3 caracteres.' }
}
if (!email || !email.includes('@')) {
return { success: false, error: 'Email inválido.' }
}
if (!password || password.length < 6) {
return { success: false, error: 'Senha deve ter pelo menos 6 caracteres.' }
}
// Check if email already exists
const existingEmail = await prisma.group.findUnique({
where: { email }
})
if (existingEmail) {
return { success: false, error: 'Este email já está em uso por outra pelada.' }
}
// Check slug availability
if (!await checkSlugAvailability(slug)) {
return { success: false, error: 'Este link não está disponível.' }
}
// Hash password with bcrypt
const hashedPassword = await bcrypt.hash(password, SALT_ROUNDS)
let logoUrl = null
if (logoFile && logoFile.size > 0 && logoFile.name !== 'undefined') {
try {
logoUrl = await uploadFile(logoFile)
} catch (error) {
console.error("Upload failed", error)
// Continue without logo
}
}
try {
const group = await prisma.group.create({
data: {
name,
slug,
email,
password: hashedPassword, // Stored as bcrypt hash
primaryColor: primaryColor || '#10B981',
secondaryColor: secondaryColor || '#000000',
logoUrl,
}
})
// Set session cookie for auto-login
const cookieStore = await cookies()
cookieStore.set('group_session', JSON.stringify({
id: group.id,
slug: group.slug,
name: group.name,
email: group.email
}), {
httpOnly: true,
secure: process.env.NODE_ENV === 'production',
sameSite: 'lax',
maxAge: 60 * 60 * 24 * 30, // 30 days
path: '/',
domain: '.localhost' // Allow all subdomains
})
// Also set the old group_id cookie for backward compatibility
cookieStore.set('group_id', group.id, {
maxAge: 60 * 60 * 24 * 30,
path: '/',
domain: '.localhost'
})
return { success: true, slug: group.slug }
} catch (error: any) {
if (error.code === 'P2002') {
return { success: false, error: 'Este link ou email já está em uso.' }
}
console.error("Create error:", error)
return { success: false, error: 'Erro ao criar pelada.' }
}
}

98
src/app/actions/auth.ts Normal file
View File

@@ -0,0 +1,98 @@
'use server'
import { cookies } from 'next/headers'
import { prisma } from '@/lib/prisma'
import bcrypt from 'bcryptjs'
import { redirect } from 'next/navigation'
export async function loginPeladaAction(formData: FormData) {
const email = formData.get('email') as string
const password = formData.get('password') as string
const slug = formData.get('slug') as string
console.log('--- [ACTION LOGIN START] ---')
console.log(`Email: ${email}, Slug: ${slug}`)
let success = false;
try {
if (!email || !password) {
return { error: 'Preencha todos os campos.' }
}
const group = await prisma.group.findUnique({
where: { email }
})
if (!group) {
console.log(`[ACTION LOGIN] ERRO: Grupo não encontrado para o email ${email}`)
return { error: 'Credenciais inválidas.' }
}
if ((group as any).status === 'FROZEN') {
console.log(`[ACTION LOGIN] ERRO: Grupo ${group.slug} está CONGELADO.`)
return { error: 'Esta pelada está temporariamente suspensa. Entre em contato com o suporte.' }
}
console.log(`[ACTION LOGIN] Grupo encontrado: ${group.slug} (ID: ${group.id})`)
// Verifica se a pelada bate com o subdomínio atual
if (slug && group.slug.toLowerCase() !== slug.toLowerCase()) {
console.log(`[ACTION LOGIN] ERRO: Slug divergente. Browser: ${slug}, DB: ${group.slug}`)
return { error: 'Este usuário não pertence a esta pelada.' }
}
if (!group.password) {
console.log(`[ACTION LOGIN] ERRO: Grupo não tem senha definida no banco`)
return { error: 'Configure uma senha para este grupo primeiro.' }
}
const isValid = await bcrypt.compare(password, group.password)
if (!isValid) {
console.log(`[ACTION LOGIN] ERRO: Senha incorreta para ${email}`)
return { error: 'Senha incorreta.' }
}
const cookieStore = await cookies()
// Define a sessão
const sessionData = JSON.stringify({
id: group.id,
slug: group.slug,
email: group.email
})
console.log(`[ACTION LOGIN] Definindo cookies...`)
cookieStore.set('group_session', sessionData, {
httpOnly: true,
secure: process.env.NODE_ENV === 'production',
sameSite: 'lax',
maxAge: 60 * 60 * 24 * 30,
path: '/',
})
cookieStore.set('group_id', group.id, {
httpOnly: true,
secure: process.env.NODE_ENV === 'production',
sameSite: 'lax',
maxAge: 60 * 60 * 24 * 30,
path: '/',
})
console.log(`[ACTION LOGIN] SUCESSO! Redirecionando...`)
success = true;
} catch (e) {
// Se for um erro de redirecionamento do Next.js, precisamos deixar ele passar
if (e instanceof Error && e.message === 'NEXT_REDIRECT') {
throw e;
}
console.error('[ACTION LOGIN] ERRO CRÍTICO:', e)
return { error: 'Erro interno no servidor.' }
}
if (success) {
redirect('/dashboard')
}
}

View File

@@ -0,0 +1,134 @@
'use client'
import { useState } from 'react'
import { motion } from 'framer-motion'
import { Trophy, Mail, Lock, LogIn, AlertCircle } from 'lucide-react'
export default function AdminLogin() {
const [email, setEmail] = useState('')
const [password, setPassword] = useState('')
const [error, setError] = useState('')
const [loading, setLoading] = useState(false)
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault()
setError('')
setLoading(true)
try {
const res = await fetch('/api/admin/login', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ email, password })
})
const data = await res.json()
if (!res.ok) {
throw new Error(data.error || 'Erro ao fazer login')
}
// Redirect to admin dashboard
window.location.href = '/admin'
} catch (err: unknown) {
const message = err instanceof Error ? err.message : 'Erro ao fazer login'
setError(message)
} finally {
setLoading(false)
}
}
return (
<div className="min-h-screen bg-zinc-950 flex items-center justify-center p-4">
{/* Background gradient */}
<div className="fixed inset-0 bg-gradient-to-br from-emerald-950/20 via-zinc-950 to-zinc-950" />
<motion.div
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
className="relative w-full max-w-md"
>
{/* Card */}
<div className="bg-zinc-900 border border-zinc-800 rounded-2xl p-8 shadow-2xl">
{/* Header */}
<div className="text-center mb-8">
<div className="w-16 h-16 rounded-2xl bg-gradient-to-br from-emerald-500 to-emerald-600 flex items-center justify-center mx-auto mb-4">
<Trophy className="w-8 h-8 text-white" />
</div>
<h1 className="text-2xl font-bold text-white">TemFut Admin</h1>
<p className="text-sm text-zinc-500 mt-1">Acesso ao painel administrativo</p>
</div>
{/* Error */}
{error && (
<motion.div
initial={{ opacity: 0, y: -10 }}
animate={{ opacity: 1, y: 0 }}
className="mb-6 p-3 bg-red-500/10 border border-red-500/20 rounded-xl flex items-center gap-2 text-red-400 text-sm"
>
<AlertCircle className="w-4 h-4 flex-shrink-0" />
{error}
</motion.div>
)}
{/* Form */}
<form onSubmit={handleSubmit} className="space-y-4">
<div>
<label className="block text-sm font-medium text-zinc-400 mb-2">
Email
</label>
<div className="relative">
<Mail className="absolute left-3 top-1/2 -translate-y-1/2 w-5 h-5 text-zinc-500" />
<input
type="email"
value={email}
onChange={(e) => setEmail(e.target.value)}
placeholder="admin@temfut.com"
required
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 transition-all"
/>
</div>
</div>
<div>
<label className="block text-sm font-medium text-zinc-400 mb-2">
Senha
</label>
<div className="relative">
<Lock className="absolute left-3 top-1/2 -translate-y-1/2 w-5 h-5 text-zinc-500" />
<input
type="password"
value={password}
onChange={(e) => setPassword(e.target.value)}
placeholder="••••••••"
required
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 transition-all"
/>
</div>
</div>
<button
type="submit"
disabled={loading}
className="w-full py-3 bg-gradient-to-r from-emerald-500 to-emerald-600 hover:from-emerald-600 hover:to-emerald-700 text-white font-medium rounded-xl flex items-center justify-center gap-2 transition-all disabled:opacity-50 disabled:cursor-not-allowed"
>
{loading ? (
<div className="w-5 h-5 border-2 border-white border-t-transparent rounded-full animate-spin" />
) : (
<>
<LogIn className="w-5 h-5" />
Entrar
</>
)}
</button>
</form>
{/* Footer */}
<p className="text-center text-xs text-zinc-600 mt-6">
Acesso restrito a administradores da plataforma
</p>
</div>
</motion.div>
</div>
)
}

527
src/app/admin/page.tsx Normal file
View File

@@ -0,0 +1,527 @@
'use client'
import { useEffect, useState } from 'react'
import { motion } from 'framer-motion'
import {
Users,
Calendar,
Trophy,
Crown,
Star,
Sparkles,
Search,
Filter,
MoreVertical,
ExternalLink,
TrendingUp,
Clock,
Settings,
LogOut,
Snowflake,
ShieldCheck,
Trash2,
RotateCcw
} from 'lucide-react'
import { DeleteConfirmationModal } from '@/components/DeleteConfirmationModal'
interface Group {
id: string
name: string
slug: string
logoUrl: string | null
primaryColor: string
plan: 'FREE' | 'BASIC' | 'PRO'
status: 'ACTIVE' | 'FROZEN'
planExpiresAt: string | null
createdAt: string
_count: {
players: number
matches: number
}
}
interface Stats {
totalGroups: number
totalPlayers: number
totalMatches: number
planDistribution: {
FREE: number
BASIC: number
PRO: number
}
}
const planConfig = {
FREE: {
label: 'Grátis',
color: 'bg-zinc-600',
textColor: 'text-zinc-300',
icon: Star,
gradient: 'from-zinc-600 to-zinc-700'
},
BASIC: {
label: 'Básico',
color: 'bg-blue-600',
textColor: 'text-blue-300',
icon: Sparkles,
gradient: 'from-blue-600 to-blue-700'
},
PRO: {
label: 'Pro',
color: 'bg-amber-500',
textColor: 'text-amber-300',
icon: Crown,
gradient: 'from-amber-500 to-orange-600'
}
}
export default function AdminDashboard() {
const [groups, setGroups] = useState<Group[]>([])
const [stats, setStats] = useState<Stats | null>(null)
const [loading, setLoading] = useState(true)
const [searchTerm, setSearchTerm] = useState('')
const [filterPlan, setFilterPlan] = useState<string>('ALL')
// Delete Modal States
const [deleteModalOpen, setDeleteModalOpen] = useState(false)
const [groupToDelete, setGroupToDelete] = useState<Group | null>(null)
const [isDeleting, setIsDeleting] = useState(false)
// Status Modal States
const [statusModalOpen, setStatusModalOpen] = useState(false)
const [groupToToggle, setGroupToToggle] = useState<Group | null>(null)
const [isUpdatingStatus, setIsUpdatingStatus] = useState(false)
useEffect(() => {
fetchData()
}, [])
const fetchData = async () => {
try {
const response = await fetch('/api/admin/groups')
const data = await response.json()
setGroups(data.groups)
setStats(data.stats)
} catch (error) {
console.error('Failed to fetch groups:', error)
} finally {
setLoading(false)
}
}
const handleLogout = async () => {
try {
await fetch('/api/admin/logout', { method: 'POST' })
window.location.href = '/admin/login'
} catch (error) {
console.error('Logout error:', error)
}
}
const handleToggleStatusRequest = (group: Group) => {
setGroupToToggle(group)
setStatusModalOpen(true)
}
const executeToggleStatus = async () => {
if (!groupToToggle) return
setIsUpdatingStatus(true)
const newStatus = groupToToggle.status === 'ACTIVE' ? 'FROZEN' : 'ACTIVE'
try {
const res = await fetch(`/api/admin/groups/${groupToToggle.id}`, {
method: 'PATCH',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ status: newStatus })
})
if (res.ok) {
fetchData()
setStatusModalOpen(false)
setGroupToToggle(null)
} else {
alert('Erro ao atualizar status')
}
} catch (error) {
console.error('Error:', error)
} finally {
setIsUpdatingStatus(false)
}
}
const confirmDelete = async () => {
if (!groupToDelete) return
setIsDeleting(true)
try {
const res = await fetch(`/api/admin/groups/${groupToDelete.id}`, {
method: 'DELETE'
})
if (res.ok) {
fetchData()
setDeleteModalOpen(false)
setGroupToDelete(null)
} else {
alert('Erro ao excluir grupo')
}
} catch (error) {
console.error('Error:', error)
alert('Erro ao excluir grupo')
} finally {
setIsDeleting(false)
}
}
const handleDeleteClick = (group: Group) => {
setGroupToDelete(group)
setDeleteModalOpen(true)
}
const filteredGroups = groups.filter(group => {
const matchesSearch = group.name.toLowerCase().includes(searchTerm.toLowerCase()) ||
group.slug.toLowerCase().includes(searchTerm.toLowerCase())
const matchesPlan = filterPlan === 'ALL' || group.plan === filterPlan
return matchesSearch && matchesPlan
})
const formatDate = (dateString: string) => {
return new Date(dateString).toLocaleDateString('pt-BR', {
day: '2-digit',
month: 'short',
year: 'numeric'
})
}
if (loading) {
return (
<div className="min-h-screen bg-zinc-950 flex items-center justify-center">
<div className="flex flex-col items-center gap-4">
<div className="w-12 h-12 border-4 border-emerald-500 border-t-transparent rounded-full animate-spin" />
<p className="text-zinc-400 text-sm">Carregando dados...</p>
</div>
</div>
)
}
return (
<div className="min-h-screen bg-zinc-950 text-white">
{/* Header */}
<header className="border-b border-zinc-800 bg-zinc-900/50 backdrop-blur-xl sticky top-0 z-50">
<div className="max-w-7xl mx-auto px-6 py-4">
<div className="flex items-center justify-between">
<div className="flex items-center gap-3">
<div className="w-10 h-10 rounded-xl bg-gradient-to-br from-emerald-500 to-emerald-600 flex items-center justify-center">
<Trophy className="w-5 h-5 text-white" />
</div>
<div>
<h1 className="text-lg font-bold">TemFut Admin</h1>
<p className="text-xs text-zinc-500">Painel de Administração</p>
</div>
</div>
<div className="flex items-center gap-3">
<span className="px-3 py-1.5 rounded-full bg-emerald-500/10 text-emerald-400 text-xs font-medium">
Super Admin
</span>
<a
href="/admin/settings"
className="p-2 hover:bg-zinc-800 rounded-lg transition-colors"
title="Configurações"
>
<Settings className="w-5 h-5 text-zinc-400" />
</a>
<button
onClick={handleLogout}
className="p-2 hover:bg-red-500/10 rounded-lg transition-colors"
title="Sair"
>
<LogOut className="w-5 h-5 text-zinc-400 hover:text-red-400" />
</button>
</div>
</div>
</div>
</header>
<main className="max-w-7xl mx-auto px-6 py-8">
{/* Stats Cards */}
<div className="grid grid-cols-1 md:grid-cols-4 gap-4 mb-8">
<motion.div
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
className="bg-zinc-900 border border-zinc-800 rounded-2xl p-5"
>
<div className="flex items-center justify-between mb-3">
<div className="w-10 h-10 rounded-xl bg-emerald-500/10 flex items-center justify-center">
<Users className="w-5 h-5 text-emerald-400" />
</div>
<TrendingUp className="w-4 h-4 text-emerald-400" />
</div>
<p className="text-2xl font-bold">{stats?.totalGroups || 0}</p>
<p className="text-xs text-zinc-500">Grupos/Peladas</p>
</motion.div>
<motion.div
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
transition={{ delay: 0.1 }}
className="bg-zinc-900 border border-zinc-800 rounded-2xl p-5"
>
<div className="flex items-center justify-between mb-3">
<div className="w-10 h-10 rounded-xl bg-blue-500/10 flex items-center justify-center">
<Users className="w-5 h-5 text-blue-400" />
</div>
</div>
<p className="text-2xl font-bold">{stats?.totalPlayers || 0}</p>
<p className="text-xs text-zinc-500">Jogadores Totais</p>
</motion.div>
<motion.div
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
transition={{ delay: 0.2 }}
className="bg-zinc-900 border border-zinc-800 rounded-2xl p-5"
>
<div className="flex items-center justify-between mb-3">
<div className="w-10 h-10 rounded-xl bg-purple-500/10 flex items-center justify-center">
<Calendar className="w-5 h-5 text-purple-400" />
</div>
</div>
<p className="text-2xl font-bold">{stats?.totalMatches || 0}</p>
<p className="text-xs text-zinc-500">Partidas Realizadas</p>
</motion.div>
<motion.div
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
transition={{ delay: 0.3 }}
className="bg-zinc-900 border border-zinc-800 rounded-2xl p-5"
>
<div className="flex items-center justify-between mb-3">
<div className="w-10 h-10 rounded-xl bg-amber-500/10 flex items-center justify-center">
<Crown className="w-5 h-5 text-amber-400" />
</div>
</div>
<p className="text-2xl font-bold">{stats?.planDistribution.PRO || 0}</p>
<p className="text-xs text-zinc-500">Assinantes Pro</p>
</motion.div>
</div>
{/* Plan Distribution */}
<motion.div
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
transition={{ delay: 0.4 }}
className="bg-zinc-900 border border-zinc-800 rounded-2xl p-6 mb-8"
>
<h3 className="text-sm font-medium text-zinc-400 mb-4">Distribuição de Planos</h3>
<div className="flex gap-4">
{(['FREE', 'BASIC', 'PRO'] as const).map((plan) => {
const config = planConfig[plan]
const count = stats?.planDistribution[plan] || 0
const percentage = stats?.totalGroups ? Math.round((count / stats.totalGroups) * 100) : 0
const Icon = config.icon
return (
<div key={plan} className="flex-1 bg-zinc-800/50 rounded-xl p-4">
<div className="flex items-center gap-2 mb-2">
<div className={`w-8 h-8 rounded-lg bg-gradient-to-br ${config.gradient} flex items-center justify-center`}>
<Icon className="w-4 h-4 text-white" />
</div>
<span className="text-sm font-medium">{config.label}</span>
</div>
<p className="text-2xl font-bold">{count}</p>
<div className="mt-2 h-1.5 bg-zinc-700 rounded-full overflow-hidden">
<div
className={`h-full bg-gradient-to-r ${config.gradient} transition-all duration-500`}
style={{ width: `${percentage}%` }}
/>
</div>
<p className="text-xs text-zinc-500 mt-1">{percentage}% do total</p>
</div>
)
})}
</div>
</motion.div>
{/* Search and Filters */}
<div className="flex flex-col sm:flex-row gap-4 mb-6">
<div className="relative flex-1">
<Search className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-zinc-500" />
<input
type="text"
placeholder="Buscar por nome ou slug..."
value={searchTerm}
onChange={(e) => setSearchTerm(e.target.value)}
className="w-full pl-10 pr-4 py-2.5 bg-zinc-900 border border-zinc-800 rounded-xl text-sm text-white placeholder:text-zinc-600 focus:outline-none focus:ring-2 focus:ring-emerald-500/50 focus:border-emerald-500"
/>
</div>
<div className="flex items-center gap-2">
<Filter className="w-4 h-4 text-zinc-500" />
<select
value={filterPlan}
onChange={(e) => setFilterPlan(e.target.value)}
className="px-4 py-2.5 bg-zinc-900 border border-zinc-800 rounded-xl text-sm text-white focus:outline-none focus:ring-2 focus:ring-emerald-500/50"
>
<option value="ALL">Todos os Planos</option>
<option value="FREE">Grátis</option>
<option value="BASIC">Básico</option>
<option value="PRO">Pro</option>
</select>
</div>
</div>
{/* Groups Table */}
<motion.div
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
transition={{ delay: 0.5 }}
className="bg-zinc-900 border border-zinc-800 rounded-2xl overflow-hidden"
>
<div className="overflow-x-auto">
<table className="w-full">
<thead>
<tr className="border-b border-zinc-800">
<th className="text-left text-xs font-medium text-zinc-500 uppercase tracking-wider px-6 py-4">Grupo</th>
<th className="text-left text-xs font-medium text-zinc-500 uppercase tracking-wider px-6 py-4">Status</th>
<th className="text-left text-xs font-medium text-zinc-500 uppercase tracking-wider px-6 py-4">Plano</th>
<th className="text-center text-xs font-medium text-zinc-500 uppercase tracking-wider px-6 py-4">Dados</th>
<th className="text-left text-xs font-medium text-zinc-500 uppercase tracking-wider px-6 py-4">Criado em</th>
<th className="text-center text-xs font-medium text-zinc-500 uppercase tracking-wider px-6 py-4">Ações</th>
</tr>
</thead>
<tbody className="divide-y divide-zinc-800">
{filteredGroups.length === 0 ? (
<tr>
<td colSpan={6} className="px-6 py-12 text-center">
<div className="flex flex-col items-center gap-2">
<Users className="w-12 h-12 text-zinc-700" />
<p className="text-zinc-500 text-sm">Nenhum grupo encontrado</p>
</div>
</td>
</tr>
) : (
filteredGroups.map((group, index) => {
const config = planConfig[group.plan]
const PlanIcon = config.icon
return (
<motion.tr
key={group.id}
initial={{ opacity: 0, x: -20 }}
animate={{ opacity: 1, x: 0 }}
transition={{ delay: index * 0.05 }}
className="hover:bg-zinc-800/50 transition-colors"
>
<td className="px-6 py-4">
<div className="flex items-center gap-3">
<div
className="w-10 h-10 rounded-xl flex items-center justify-center text-white font-bold text-sm"
style={{ backgroundColor: group.primaryColor }}
>
{group.logoUrl ? (
<img src={group.logoUrl} alt={group.name} className="w-full h-full object-cover rounded-xl" />
) : (
group.name.charAt(0).toUpperCase()
)}
</div>
<div>
<p className="font-medium text-sm">{group.name}</p>
<p className="text-xs text-zinc-500">@{group.slug}</p>
</div>
</div>
</td>
<td className="px-6 py-4">
{group.status === 'ACTIVE' ? (
<span className="inline-flex items-center gap-1.5 px-2 py-0.5 rounded-full text-[10px] font-bold bg-emerald-500/10 text-emerald-500 border border-emerald-500/20">
<ShieldCheck className="w-3 h-3" /> ATIVO
</span>
) : (
<span className="inline-flex items-center gap-1.5 px-2 py-0.5 rounded-full text-[10px] font-bold bg-blue-500/10 text-blue-400 border border-blue-500/20">
<Snowflake className="w-3 h-3" /> CONGELADO
</span>
)}
</td>
<td className="px-6 py-4">
<span className={`inline-flex items-center gap-1.5 px-2.5 py-1 rounded-full text-xs font-medium ${config.color} text-white`}>
<PlanIcon className="w-3 h-3" />
{config.label}
</span>
</td>
<td className="px-6 py-4 text-center">
<div className="text-xs text-zinc-400">
<p>{group._count.players} Jog. / {group._count.matches} Part.</p>
</div>
</td>
<td className="px-6 py-4">
<div className="flex items-center gap-1.5 text-zinc-400 text-sm">
<Clock className="w-3.5 h-3.5" />
{formatDate(group.createdAt)}
</div>
</td>
<td className="px-6 py-4">
<div className="flex items-center justify-center gap-1">
<button
onClick={() => handleToggleStatusRequest(group)}
className={`p-2 rounded-lg transition-colors ${group.status === 'ACTIVE' ? 'hover:bg-blue-500/10 text-zinc-500 hover:text-blue-400' : 'hover:bg-emerald-500/10 text-blue-400 hover:text-emerald-500'}`}
title={group.status === 'ACTIVE' ? 'Congelar Acesso' : 'Reativar Acesso'}
>
{group.status === 'ACTIVE' ? <Snowflake className="w-4 h-4" /> : <RotateCcw className="w-4 h-4" />}
</button>
<button
onClick={() => handleDeleteClick(group)}
className="p-2 hover:bg-red-500/10 text-zinc-500 hover:text-red-500 rounded-lg transition-colors"
title="Excluir Pelada"
>
<Trash2 className="w-4 h-4" />
</button>
<a
href={`http://${group.slug}.localhost`}
target="_blank"
className="p-2 hover:bg-zinc-800 text-zinc-500 hover:text-white rounded-lg transition-colors"
title="Ver Pelada"
>
<ExternalLink className="w-4 h-4" />
</a>
</div>
</td>
</motion.tr>
)
})
)}
</tbody>
</table>
</div>
</motion.div>
{/* Footer Stats */}
<div className="mt-6 text-center text-xs text-zinc-600">
Exibindo {filteredGroups.length} de {groups.length} grupos
</div>
</main>
{/* Modals */}
<DeleteConfirmationModal
isOpen={deleteModalOpen}
onClose={() => setDeleteModalOpen(false)}
onConfirm={confirmDelete}
isDeleting={isDeleting}
title={`Excluir "${groupToDelete?.name}"?`}
description={`Você tem certeza que deseja excluir a pelada "${groupToDelete?.name}" permanentemente? Todos os jogadores, partidas e históricos serão apagados. Esta ação é irreversível.`}
confirmText="Sim, excluir permanentemente"
/>
<DeleteConfirmationModal
isOpen={statusModalOpen}
onClose={() => setStatusModalOpen(false)}
onConfirm={executeToggleStatus}
isDeleting={isUpdatingStatus}
title={groupToToggle?.status === 'ACTIVE' ? 'Congelar Pelada?' : 'Reativar Pelada?'}
description={groupToToggle?.status === 'ACTIVE'
? `Deseja realmente congelar o acesso da pelada "${groupToToggle?.name}"? O admin não conseguirá gerenciar o grupo até ser reativado.`
: `Deseja reativar o acesso da pelada "${groupToToggle?.name}" agora?`}
confirmText={groupToToggle?.status === 'ACTIVE' ? 'Sim, congelar' : 'Sim, reativar'}
/>
</div>
)
}

View File

@@ -0,0 +1,380 @@
'use client'
import { useState, useEffect } from 'react'
import { motion } from 'framer-motion'
import {
Settings,
User,
Mail,
Lock,
Save,
Eye,
EyeOff,
AlertCircle,
CheckCircle,
ArrowLeft,
LogOut,
Trophy
} from 'lucide-react'
interface AdminProfile {
id: string
email: string
name: string
role: string
}
export default function AdminSettings() {
const [profile, setProfile] = useState<AdminProfile | null>(null)
const [loading, setLoading] = useState(true)
const [saving, setSaving] = useState(false)
const [error, setError] = useState('')
const [success, setSuccess] = useState('')
// Profile form
const [name, setName] = useState('')
const [email, setEmail] = useState('')
// Password form
const [currentPassword, setCurrentPassword] = useState('')
const [newPassword, setNewPassword] = useState('')
const [confirmPassword, setConfirmPassword] = useState('')
const [showPasswords, setShowPasswords] = useState(false)
const [savingPassword, setSavingPassword] = useState(false)
const [passwordError, setPasswordError] = useState('')
const [passwordSuccess, setPasswordSuccess] = useState('')
useEffect(() => {
fetchProfile()
}, [])
const fetchProfile = async () => {
try {
const res = await fetch('/api/admin/profile')
if (!res.ok) throw new Error('Failed to fetch profile')
const data = await res.json()
setProfile(data)
setName(data.name)
setEmail(data.email)
} catch (err) {
setError('Erro ao carregar perfil')
} finally {
setLoading(false)
}
}
const handleSaveProfile = async (e: React.FormEvent) => {
e.preventDefault()
setSaving(true)
setError('')
setSuccess('')
try {
const res = await fetch('/api/admin/profile', {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ name, email })
})
const data = await res.json()
if (!res.ok) {
throw new Error(data.error || 'Erro ao salvar')
}
setSuccess('Perfil atualizado com sucesso!')
setProfile(prev => prev ? { ...prev, name, email } : null)
} catch (err: any) {
setError(err.message)
} finally {
setSaving(false)
}
}
const handleChangePassword = async (e: React.FormEvent) => {
e.preventDefault()
if (newPassword !== confirmPassword) {
setPasswordError('As senhas não coincidem')
return
}
if (newPassword.length < 6) {
setPasswordError('Nova senha deve ter pelo menos 6 caracteres')
return
}
setSavingPassword(true)
setPasswordError('')
setPasswordSuccess('')
try {
const res = await fetch('/api/admin/profile', {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
currentPassword,
newPassword
})
})
const data = await res.json()
if (!res.ok) {
throw new Error(data.error || 'Erro ao alterar senha')
}
setPasswordSuccess('Senha alterada com sucesso!')
setCurrentPassword('')
setNewPassword('')
setConfirmPassword('')
} catch (err: any) {
setPasswordError(err.message)
} finally {
setSavingPassword(false)
}
}
const handleLogout = async () => {
try {
await fetch('/api/admin/logout', { method: 'POST' })
window.location.href = '/admin/login'
} catch (err) {
console.error('Logout error:', err)
}
}
if (loading) {
return (
<div className="min-h-screen bg-zinc-950 flex items-center justify-center">
<div className="w-8 h-8 border-4 border-emerald-500 border-t-transparent rounded-full animate-spin" />
</div>
)
}
return (
<div className="min-h-screen bg-zinc-950 text-white">
{/* Header */}
<header className="border-b border-zinc-800 bg-zinc-900/50 backdrop-blur-xl sticky top-0 z-50">
<div className="max-w-4xl mx-auto px-6 py-4">
<div className="flex items-center justify-between">
<div className="flex items-center gap-4">
<a
href="/admin"
className="p-2 hover:bg-zinc-800 rounded-lg transition-colors"
>
<ArrowLeft className="w-5 h-5" />
</a>
<div className="flex items-center gap-3">
<div className="w-10 h-10 rounded-xl bg-gradient-to-br from-emerald-500 to-emerald-600 flex items-center justify-center">
<Trophy className="w-5 h-5 text-white" />
</div>
<div>
<h1 className="text-lg font-bold">Configurações</h1>
<p className="text-xs text-zinc-500">Gerencie seu perfil</p>
</div>
</div>
</div>
<button
onClick={handleLogout}
className="flex items-center gap-2 px-4 py-2 bg-red-500/10 hover:bg-red-500/20 text-red-400 rounded-lg transition-colors"
>
<LogOut className="w-4 h-4" />
Sair
</button>
</div>
</div>
</header>
<main className="max-w-4xl mx-auto px-6 py-8 space-y-8">
{/* Profile Form */}
<motion.div
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
className="bg-zinc-900 border border-zinc-800 rounded-2xl p-6"
>
<div className="flex items-center gap-3 mb-6">
<div className="w-10 h-10 rounded-xl bg-emerald-500/10 flex items-center justify-center">
<User className="w-5 h-5 text-emerald-400" />
</div>
<div>
<h2 className="font-semibold">Informações do Perfil</h2>
<p className="text-xs text-zinc-500">Atualize seu nome e email</p>
</div>
</div>
{error && (
<div className="mb-4 p-3 bg-red-500/10 border border-red-500/20 rounded-xl flex items-center gap-2 text-red-400 text-sm">
<AlertCircle className="w-4 h-4" />
{error}
</div>
)}
{success && (
<div className="mb-4 p-3 bg-emerald-500/10 border border-emerald-500/20 rounded-xl flex items-center gap-2 text-emerald-400 text-sm">
<CheckCircle className="w-4 h-4" />
{success}
</div>
)}
<form onSubmit={handleSaveProfile} className="space-y-4">
<div>
<label className="block text-sm font-medium text-zinc-400 mb-2">
Nome
</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={name}
onChange={(e) => setName(e.target.value)}
className="w-full pl-11 pr-4 py-3 bg-zinc-800 border border-zinc-700 rounded-xl text-white focus:outline-none focus:ring-2 focus:ring-emerald-500/50"
/>
</div>
</div>
<div>
<label className="block text-sm font-medium text-zinc-400 mb-2">
Email
</label>
<div className="relative">
<Mail className="absolute left-3 top-1/2 -translate-y-1/2 w-5 h-5 text-zinc-500" />
<input
type="email"
value={email}
onChange={(e) => setEmail(e.target.value)}
className="w-full pl-11 pr-4 py-3 bg-zinc-800 border border-zinc-700 rounded-xl text-white focus:outline-none focus:ring-2 focus:ring-emerald-500/50"
/>
</div>
</div>
<button
type="submit"
disabled={saving}
className="w-full py-3 bg-emerald-500 hover:bg-emerald-600 text-white font-medium rounded-xl flex items-center justify-center gap-2 transition-colors disabled:opacity-50"
>
{saving ? (
<div className="w-5 h-5 border-2 border-white border-t-transparent rounded-full animate-spin" />
) : (
<>
<Save className="w-5 h-5" />
Salvar Alterações
</>
)}
</button>
</form>
</motion.div>
{/* Password Form */}
<motion.div
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
transition={{ delay: 0.1 }}
className="bg-zinc-900 border border-zinc-800 rounded-2xl p-6"
>
<div className="flex items-center gap-3 mb-6">
<div className="w-10 h-10 rounded-xl bg-amber-500/10 flex items-center justify-center">
<Lock className="w-5 h-5 text-amber-400" />
</div>
<div>
<h2 className="font-semibold">Alterar Senha</h2>
<p className="text-xs text-zinc-500">Mantenha sua conta segura</p>
</div>
</div>
{passwordError && (
<div className="mb-4 p-3 bg-red-500/10 border border-red-500/20 rounded-xl flex items-center gap-2 text-red-400 text-sm">
<AlertCircle className="w-4 h-4" />
{passwordError}
</div>
)}
{passwordSuccess && (
<div className="mb-4 p-3 bg-emerald-500/10 border border-emerald-500/20 rounded-xl flex items-center gap-2 text-emerald-400 text-sm">
<CheckCircle className="w-4 h-4" />
{passwordSuccess}
</div>
)}
<form onSubmit={handleChangePassword} className="space-y-4">
<div>
<label className="block text-sm font-medium text-zinc-400 mb-2">
Senha Atual
</label>
<div className="relative">
<Lock className="absolute left-3 top-1/2 -translate-y-1/2 w-5 h-5 text-zinc-500" />
<input
type={showPasswords ? 'text' : 'password'}
value={currentPassword}
onChange={(e) => setCurrentPassword(e.target.value)}
placeholder="••••••••"
required
className="w-full pl-11 pr-12 py-3 bg-zinc-800 border border-zinc-700 rounded-xl text-white focus:outline-none focus:ring-2 focus:ring-emerald-500/50"
/>
<button
type="button"
onClick={() => setShowPasswords(!showPasswords)}
className="absolute right-3 top-1/2 -translate-y-1/2 text-zinc-500 hover:text-zinc-300"
>
{showPasswords ? <EyeOff className="w-5 h-5" /> : <Eye className="w-5 h-5" />}
</button>
</div>
</div>
<div>
<label className="block text-sm font-medium text-zinc-400 mb-2">
Nova Senha
</label>
<div className="relative">
<Lock className="absolute left-3 top-1/2 -translate-y-1/2 w-5 h-5 text-zinc-500" />
<input
type={showPasswords ? 'text' : 'password'}
value={newPassword}
onChange={(e) => setNewPassword(e.target.value)}
placeholder="Mínimo 6 caracteres"
required
className="w-full pl-11 pr-4 py-3 bg-zinc-800 border border-zinc-700 rounded-xl text-white focus:outline-none focus:ring-2 focus:ring-emerald-500/50"
/>
</div>
</div>
<div>
<label className="block text-sm font-medium text-zinc-400 mb-2">
Confirmar Nova Senha
</label>
<div className="relative">
<Lock className="absolute left-3 top-1/2 -translate-y-1/2 w-5 h-5 text-zinc-500" />
<input
type={showPasswords ? 'text' : 'password'}
value={confirmPassword}
onChange={(e) => setConfirmPassword(e.target.value)}
placeholder="••••••••"
required
className="w-full pl-11 pr-4 py-3 bg-zinc-800 border border-zinc-700 rounded-xl text-white focus:outline-none focus:ring-2 focus:ring-emerald-500/50"
/>
</div>
{newPassword && confirmPassword && newPassword !== confirmPassword && (
<p className="text-xs text-red-400 mt-1">As senhas não coincidem</p>
)}
</div>
<button
type="submit"
disabled={savingPassword}
className="w-full py-3 bg-amber-500 hover:bg-amber-600 text-white font-medium rounded-xl flex items-center justify-center gap-2 transition-colors disabled:opacity-50"
>
{savingPassword ? (
<div className="w-5 h-5 border-2 border-white border-t-transparent rounded-full animate-spin" />
) : (
<>
<Lock className="w-5 h-5" />
Alterar Senha
</>
)}
</button>
</form>
</motion.div>
</main>
</div>
)
}

View File

@@ -0,0 +1,100 @@
import { NextResponse } from 'next/server'
import { prisma } from '@/lib/prisma'
import { headers } from 'next/headers'
// Validação básica de que a requisição vem do admin
import { cookies } from 'next/headers'
// Validação mais robusta que checa HEADER (Middleware) ou COOKIE (Sessão Direta)
async function isAdmin() {
const headerStore = await headers()
const headerCheck = headerStore.get('x-admin-request') === 'true' || headerStore.get('X-Admin-Request') === 'true'
if (headerCheck) return true
// Fallback: Verifica se tem o cookie de sessão do admin
const cookieStore = await cookies()
return !!cookieStore.get('admin_session')
}
export async function DELETE(
request: Request,
{ params }: { params: { id: string } }
) {
if (!(await isAdmin())) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
}
try {
const { id } = await params
// Transação para limpar tudo relacionado ao grupo antes de deletar o grupo em si
await prisma.$transaction(async (tx) => {
// 1. Financeiro (Limpa Payments via Cascade primeiro)
await (tx as any).financialEvent.deleteMany({ where: { groupId: id } })
// 2. Limpar relações de partidas (Times e Jogadores de times)
const matches = await tx.match.findMany({
where: { groupId: id },
select: { id: true }
})
const matchIds = matches.map(m => m.id)
if (matchIds.length > 0) {
await tx.teamPlayer.deleteMany({
where: { team: { matchId: { in: matchIds } } }
})
await tx.team.deleteMany({
where: { matchId: { in: matchIds } }
})
// casting para evitar erro de tipo se o modelo não estiver syncado
await (tx as any).attendance.deleteMany({
where: { matchId: { in: matchIds } }
})
}
// 3. Deletar as partidas
await tx.match.deleteMany({ where: { groupId: id } })
// 4. Deletar Sponsors (Patrocinadores)
await (tx as any).sponsor.deleteMany({ where: { groupId: id } })
// 5. Deletar Arenas
await tx.arena.deleteMany({ where: { groupId: id } })
// 6. Deletar Jogadores do Grupo (Seguro agora)
await tx.player.deleteMany({ where: { groupId: id } })
// 7. Finalmente, deletar o Grupo
await tx.group.delete({ where: { id } })
})
return NextResponse.json({ success: true })
} catch (error) {
console.error('Delete group error:', error)
return NextResponse.json({ error: 'Falha ao excluir grupo' }, { status: 500 })
}
}
export async function PATCH(
request: Request,
{ params }: { params: { id: string } }
) {
if (!(await isAdmin())) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
}
try {
const { id } = await params
const { status } = await request.json()
await prisma.group.update({
where: { id },
data: { status } as any
})
return NextResponse.json({ success: true })
} catch (error) {
console.error('Update group error:', error)
return NextResponse.json({ error: 'Falha ao atualizar grupo' }, { status: 500 })
}
}

View File

@@ -0,0 +1,57 @@
import { NextResponse } from 'next/server'
import { prisma } from '@/lib/prisma'
export async function GET() {
try {
// Fetch all groups with counts
const groups = await prisma.group.findMany({
select: {
id: true,
name: true,
slug: true,
logoUrl: true,
primaryColor: true,
plan: true,
status: true,
planExpiresAt: true,
createdAt: true,
_count: {
select: {
players: true,
matches: true
}
}
},
orderBy: {
createdAt: 'desc'
}
})
// Calculate stats
const totalGroups = groups.length
const totalPlayers = groups.reduce((acc, g) => acc + g._count.players, 0)
const totalMatches = groups.reduce((acc, g) => acc + g._count.matches, 0)
const planDistribution = {
FREE: groups.filter(g => g.plan === 'FREE').length,
BASIC: groups.filter(g => g.plan === 'BASIC').length,
PRO: groups.filter(g => g.plan === 'PRO').length
}
return NextResponse.json({
groups,
stats: {
totalGroups,
totalPlayers,
totalMatches,
planDistribution
}
})
} catch (error) {
console.error('Error fetching groups:', error)
return NextResponse.json(
{ error: 'Failed to fetch groups' },
{ status: 500 }
)
}
}

View File

@@ -0,0 +1,71 @@
import { NextResponse } from 'next/server'
import { PrismaClient } from '@prisma/client'
import { cookies } from 'next/headers'
import bcrypt from 'bcryptjs'
const prisma = new PrismaClient()
export async function POST(request: Request) {
try {
const { email, password } = await request.json()
if (!email || !password) {
return NextResponse.json(
{ error: 'Email e senha são obrigatórios' },
{ status: 400 }
)
}
// Find admin by email
const admin = await prisma.admin.findUnique({
where: { email }
})
if (!admin) {
return NextResponse.json(
{ error: 'Credenciais inválidas' },
{ status: 401 }
)
}
// Verify password with bcrypt
const isValidPassword = await bcrypt.compare(password, admin.password)
if (!isValidPassword) {
return NextResponse.json(
{ error: 'Credenciais inválidas' },
{ status: 401 }
)
}
// Set auth cookie
const cookieStore = await cookies()
cookieStore.set('admin_session', JSON.stringify({
id: admin.id,
email: admin.email,
name: admin.name,
role: admin.role
}), {
httpOnly: true,
secure: process.env.NODE_ENV === 'production',
sameSite: 'lax',
maxAge: 60 * 60 * 24 * 7 // 7 days
})
return NextResponse.json({
success: true,
admin: {
id: admin.id,
email: admin.email,
name: admin.name,
role: admin.role
}
})
} catch (error) {
console.error('Login error:', error)
return NextResponse.json(
{ error: 'Erro interno do servidor' },
{ status: 500 }
)
}
}

View File

@@ -0,0 +1,19 @@
import { NextResponse } from 'next/server'
import { cookies } from 'next/headers'
export async function POST() {
try {
const cookieStore = await cookies()
// Remove admin session cookie
cookieStore.delete('admin_session')
return NextResponse.json({ success: true })
} catch (error) {
console.error('Logout error:', error)
return NextResponse.json(
{ error: 'Erro ao fazer logout' },
{ status: 500 }
)
}
}

View File

@@ -0,0 +1,160 @@
import { NextResponse } from 'next/server'
import { prisma } from '@/lib/prisma'
import { cookies } from 'next/headers'
import bcrypt from 'bcryptjs'
const SALT_ROUNDS = 12
// Get logged admin from cookie
async function getLoggedAdmin() {
const cookieStore = await cookies()
const sessionCookie = cookieStore.get('admin_session')
if (!sessionCookie) return null
try {
return JSON.parse(sessionCookie.value)
} catch {
return null
}
}
// GET - Get admin profile
export async function GET() {
try {
const session = await getLoggedAdmin()
if (!session) {
return NextResponse.json(
{ error: 'Não autenticado' },
{ status: 401 }
)
}
const admin = await prisma.admin.findUnique({
where: { id: session.id },
select: { id: true, email: true, name: true, role: true }
})
if (!admin) {
return NextResponse.json(
{ error: 'Admin não encontrado' },
{ status: 404 }
)
}
return NextResponse.json(admin)
} catch (error) {
console.error('Profile fetch error:', error)
return NextResponse.json(
{ error: 'Erro interno' },
{ status: 500 }
)
}
}
// PUT - Update admin profile
export async function PUT(request: Request) {
try {
const session = await getLoggedAdmin()
if (!session) {
return NextResponse.json(
{ error: 'Não autenticado' },
{ status: 401 }
)
}
const body = await request.json()
const { name, email, currentPassword, newPassword } = body
const admin = await prisma.admin.findUnique({
where: { id: session.id }
})
if (!admin) {
return NextResponse.json(
{ error: 'Admin não encontrado' },
{ status: 404 }
)
}
// If changing password
if (currentPassword && newPassword) {
// Verify current password
const isValid = await bcrypt.compare(currentPassword, admin.password)
if (!isValid) {
return NextResponse.json(
{ error: 'Senha atual incorreta' },
{ status: 400 }
)
}
if (newPassword.length < 6) {
return NextResponse.json(
{ error: 'Nova senha deve ter pelo menos 6 caracteres' },
{ status: 400 }
)
}
// Hash new password
const hashedPassword = await bcrypt.hash(newPassword, SALT_ROUNDS)
await prisma.admin.update({
where: { id: session.id },
data: { password: hashedPassword }
})
return NextResponse.json({ success: true, message: 'Senha alterada com sucesso' })
}
// Update name/email
const updateData: { name?: string; email?: string } = {}
if (name) updateData.name = name
if (email && email !== admin.email) {
// Check if email is already in use
const existing = await prisma.admin.findUnique({ where: { email } })
if (existing) {
return NextResponse.json(
{ error: 'Este email já está em uso' },
{ status: 400 }
)
}
updateData.email = email
}
if (Object.keys(updateData).length > 0) {
const updated = await prisma.admin.update({
where: { id: session.id },
data: updateData,
select: { id: true, email: true, name: true, role: true }
})
// Update session cookie
const cookieStore = await cookies()
cookieStore.set('admin_session', JSON.stringify({
id: updated.id,
email: updated.email,
name: updated.name,
role: updated.role
}), {
httpOnly: true,
secure: process.env.NODE_ENV === 'production',
sameSite: 'lax',
maxAge: 60 * 60 * 24 * 7
})
return NextResponse.json(updated)
}
return NextResponse.json({ success: true })
} catch (error) {
console.error('Profile update error:', error)
return NextResponse.json(
{ error: 'Erro ao atualizar perfil' },
{ status: 500 }
)
}
}

View File

@@ -0,0 +1,54 @@
import { NextResponse } from 'next/server'
import { PrismaClient } from '@prisma/client'
import bcrypt from 'bcryptjs'
const prisma = new PrismaClient()
const SALT_ROUNDS = 12
// Seed inicial de admin - DELETE AFTER FIRST USE!
export async function GET() {
try {
// Check if admin already exists
const existingAdmin = await prisma.admin.findFirst()
if (existingAdmin) {
return NextResponse.json({
message: 'Admin já existe. Delete esta rota após o setup inicial.',
admin: {
email: existingAdmin.email,
name: existingAdmin.name
}
})
}
// Hash password
const hashedPassword = await bcrypt.hash('admin123', SALT_ROUNDS)
// Create default super admin
const admin = await prisma.admin.create({
data: {
email: 'admin@temfut.com',
password: hashedPassword, // Securely hashed
name: 'Super Admin',
role: 'SUPER_ADMIN'
}
})
return NextResponse.json({
success: true,
message: 'Super Admin criado com sucesso!',
credentials: {
email: admin.email,
password: 'admin123',
warning: 'MUDE A SENHA IMEDIATAMENTE! Esta é a única vez que você verá a senha em texto plano.'
}
})
} catch (error) {
console.error('Seed error:', error)
return NextResponse.json(
{ error: 'Erro ao criar admin' },
{ status: 500 }
)
}
}

View File

@@ -0,0 +1,76 @@
import { NextResponse } from 'next/server'
import { PrismaClient } from '@prisma/client'
import { cookies } from 'next/headers'
const prisma = new PrismaClient()
export async function POST(request: Request) {
try {
const { email, password } = await request.json()
if (!email || !password) {
return NextResponse.json(
{ error: 'Email e senha são obrigatórios' },
{ status: 400 }
)
}
// Find group by email
const group = await prisma.group.findUnique({
where: { email }
})
if (!group) {
return NextResponse.json(
{ error: 'Credenciais inválidas' },
{ status: 401 }
)
}
// Check if group has password set
if (!group.password) {
return NextResponse.json(
{ error: 'Este grupo não possui senha configurada' },
{ status: 401 }
)
}
// Simple password check (in production, use bcrypt!)
if (group.password !== password) {
return NextResponse.json(
{ error: 'Credenciais inválidas' },
{ status: 401 }
)
}
// Set auth cookie
const cookieStore = await cookies()
cookieStore.set('group_session', JSON.stringify({
id: group.id,
slug: group.slug,
name: group.name,
email: group.email
}), {
httpOnly: true,
secure: process.env.NODE_ENV === 'production',
sameSite: 'lax',
maxAge: 60 * 60 * 24 * 7 // 7 days
})
return NextResponse.json({
success: true,
group: {
id: group.id,
slug: group.slug,
name: group.name
},
redirectUrl: `http://${group.slug}.localhost/dashboard`
})
} catch (error) {
console.error('Login error:', error)
return NextResponse.json(
{ error: 'Erro interno do servidor' },
{ status: 500 }
)
}
}

View File

@@ -0,0 +1,12 @@
import { cookies } from 'next/headers'
import { NextResponse } from 'next/server'
export async function POST() {
const cookieStore = await cookies()
// Remove group session cookies
cookieStore.delete('group_session')
cookieStore.delete('group_id')
return NextResponse.json({ success: true })
}

View File

@@ -0,0 +1,61 @@
import { NextResponse } from 'next/server'
import { PrismaClient } from '@prisma/client'
import { cookies } from 'next/headers'
import bcrypt from 'bcryptjs'
const prisma = new PrismaClient()
export async function POST(request: Request) {
try {
const body = await request.json()
const { email, password, slug } = body
if (!email || !password) {
return NextResponse.json({ error: 'Email e senha são obrigatórios' }, { status: 400 })
}
const group = await prisma.group.findUnique({ where: { email } })
if (!group || !group.password) {
return NextResponse.json({ error: 'Credenciais inválidas' }, { status: 401 })
}
// Verifica se o slug bate (se estiver logando via subdomínio)
if (slug && group.slug.toLowerCase() !== slug.toLowerCase()) {
return NextResponse.json({ error: 'Este usuário não pertence a esta pelada' }, { status: 401 })
}
const isValid = await bcrypt.compare(password, group.password)
if (!isValid) {
return NextResponse.json({ error: 'Senha incorreta' }, { status: 401 })
}
const cookieStore = await cookies()
// ESTRATÉGIA: Não definimos 'domain'.
// Em localhost, isso faz o cookie ficar preso ao host exato (ex: futdequarta.localhost)
const options = {
httpOnly: true,
secure: process.env.NODE_ENV === 'production',
sameSite: 'lax' as const,
maxAge: 60 * 60 * 24 * 30,
path: '/',
}
cookieStore.set('group_session', JSON.stringify({
id: group.id,
slug: group.slug,
email: group.email
}), options)
cookieStore.set('group_id', group.id, options)
console.log(`[LOGIN] Sucesso para: ${group.slug} (${email})`)
return NextResponse.json({ success: true })
} catch (e: any) {
console.error('[LOGIN ERR]', e)
return NextResponse.json({ error: 'Erro interno' }, { status: 500 })
}
}

View File

@@ -0,0 +1,28 @@
import { NextResponse } from 'next/server'
import { PrismaClient } from '@prisma/client'
const prisma = new PrismaClient()
export async function GET(request: Request) {
try {
const { searchParams } = new URL(request.url)
const slug = searchParams.get('slug')
if (!slug) {
return NextResponse.json({ exists: false })
}
const group = await prisma.group.findUnique({
where: { slug },
select: { id: true, name: true }
})
return NextResponse.json({
exists: !!group,
group: group ? { id: group.id, name: group.name } : null
})
} catch (error) {
console.error('Error checking group:', error)
return NextResponse.json({ exists: false, error: 'Database error' })
}
}

407
src/app/create/page.tsx Normal file
View File

@@ -0,0 +1,407 @@
'use client'
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 { checkSlugAvailability, createGroup } from '@/app/actions'
export default function CreateTeamWizard() {
const [step, setStep] = useState(1)
const [formData, setFormData] = useState({
name: '',
slug: '',
email: '',
password: '',
confirmPassword: '',
primaryColor: '#10B981',
secondaryColor: '#000000',
logo: null as File | null
})
const [slugAvailable, setSlugAvailable] = useState<boolean | null>(null)
const [isCheckingSlug, setIsCheckingSlug] = useState(false)
const [previewLogo, setPreviewLogo] = useState<string | null>(null)
const [isSubmitting, setIsSubmitting] = useState(false)
const [error, setError] = useState<string | null>(null)
const [showPassword, setShowPassword] = useState(false)
// Auto-generate slug from name
useEffect(() => {
if (step === 1 && formData.name && !formData.slug) {
const autoSlug = formData.name.toLowerCase().trim().replace(/[^a-z0-9]/g, '-').replace(/-+/g, '-')
setFormData(prev => ({ ...prev, slug: autoSlug }))
}
}, [formData.name, step])
// Debounce check slug
useEffect(() => {
if (formData.slug.length < 3) {
setSlugAvailable(null)
return
}
const timer = setTimeout(async () => {
setIsCheckingSlug(true)
const available = await checkSlugAvailability(formData.slug)
setSlugAvailable(available)
setIsCheckingSlug(false)
}, 500)
return () => clearTimeout(timer)
}, [formData.slug])
const canProceed = () => {
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 4: return true // Logo and colors are optional
default: return false
}
}
const handleNext = () => {
if (!canProceed()) return
setStep(prev => prev + 1)
}
const handleBack = () => {
setStep(prev => prev - 1)
}
const handleLogoChange = (e: React.ChangeEvent<HTMLInputElement>) => {
const file = e.target.files?.[0]
if (file) {
setFormData(prev => ({ ...prev, logo: file }))
const reader = new FileReader()
reader.onload = () => setPreviewLogo(reader.result as string)
reader.readAsDataURL(file)
}
}
const handleSubmit = async () => {
if (formData.password !== formData.confirmPassword) {
setError('As senhas não coincidem')
return
}
setIsSubmitting(true)
setError(null)
const data = new FormData()
data.append('name', formData.name)
data.append('slug', formData.slug)
data.append('email', formData.email)
data.append('password', formData.password)
data.append('primaryColor', formData.primaryColor)
data.append('secondaryColor', formData.secondaryColor)
if (formData.logo) {
data.append('logo', formData.logo)
}
const res = await createGroup(data)
if (res.success) {
// Redirect to the new pelada's dashboard
setTimeout(() => {
window.location.href = `http://${res.slug}.localhost/dashboard`
}, 500)
} else {
setError(res.error || 'Erro ao criar pelada')
setIsSubmitting(false)
}
}
const totalSteps = 4
return (
<div className="min-h-screen bg-zinc-950 flex items-center justify-center p-4">
{/* Background */}
<div className="fixed inset-0 bg-gradient-to-br from-emerald-950/20 via-zinc-950 to-zinc-950" />
<motion.div
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
className="relative w-full max-w-lg"
>
{/* Card */}
<div className="bg-zinc-900 border border-zinc-800 rounded-2xl p-8 shadow-2xl">
{/* Header */}
<div className="text-center mb-8">
<div className="w-14 h-14 rounded-2xl bg-gradient-to-br from-emerald-500 to-emerald-600 flex items-center justify-center mx-auto mb-4">
<Trophy className="w-7 h-7 text-white" />
</div>
<h1 className="text-2xl font-bold text-white">Criar sua Pelada</h1>
<p className="text-sm text-zinc-500 mt-1">Passo {step} de {totalSteps}</p>
</div>
{/* Progress bar */}
<div className="h-1 bg-zinc-800 rounded-full mb-8">
<motion.div
className="h-full bg-emerald-500 rounded-full"
initial={{ width: 0 }}
animate={{ width: `${(step / totalSteps) * 100}%` }}
/>
</div>
{/* Error */}
{error && (
<motion.div
initial={{ opacity: 0, y: -10 }}
animate={{ opacity: 1, y: 0 }}
className="mb-6 p-3 bg-red-500/10 border border-red-500/20 rounded-xl text-red-400 text-sm"
>
{error}
</motion.div>
)}
{/* Steps */}
<AnimatePresence mode="wait">
{/* Step 1: Nome da Pelada */}
{step === 1 && (
<motion.div
key="step1"
initial={{ opacity: 0, x: 20 }}
animate={{ opacity: 1, x: 0 }}
exit={{ opacity: 0, x: -20 }}
className="space-y-4"
>
<div>
<label className="block text-sm font-medium text-zinc-400 mb-2">
Nome da sua pelada
</label>
<input
type="text"
value={formData.name}
onChange={(e) => setFormData(prev => ({ ...prev, name: e.target.value }))}
placeholder="Ex: Pelada de Quarta"
className="w-full px-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>
</motion.div>
)}
{/* Step 2: Slug */}
{step === 2 && (
<motion.div
key="step2"
initial={{ opacity: 0, x: 20 }}
animate={{ opacity: 1, x: 0 }}
exit={{ opacity: 0, x: -20 }}
className="space-y-4"
>
<div>
<label className="block text-sm font-medium text-zinc-400 mb-2">
Link exclusivo da sua pelada
</label>
<div className="relative">
<input
type="text"
value={formData.slug}
onChange={(e) => setFormData(prev => ({
...prev,
slug: e.target.value.toLowerCase().replace(/[^a-z0-9-]/g, '')
}))}
placeholder="pelada-de-quarta"
className="w-full px-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 pr-20"
/>
<span className="absolute right-4 top-1/2 -translate-y-1/2 text-zinc-500 text-sm">
.localhost
</span>
</div>
<div className="mt-2 h-5 flex items-center">
{isCheckingSlug && (
<span className="text-xs text-zinc-500 flex items-center gap-1">
<Loader2 className="w-3 h-3 animate-spin" /> Verificando...
</span>
)}
{!isCheckingSlug && slugAvailable === true && (
<span className="text-xs text-emerald-400 flex items-center gap-1">
<Check className="w-3 h-3" /> Disponível!
</span>
)}
{!isCheckingSlug && slugAvailable === false && (
<span className="text-xs text-red-400 flex items-center gap-1">
<X className="w-3 h-3" /> está em uso
</span>
)}
</div>
</div>
</motion.div>
)}
{/* Step 3: Email e Senha */}
{step === 3 && (
<motion.div
key="step3"
initial={{ opacity: 0, x: 20 }}
animate={{ opacity: 1, x: 0 }}
exit={{ opacity: 0, x: -20 }}
className="space-y-4"
>
<div>
<label className="block text-sm font-medium text-zinc-400 mb-2">
Seu email (para login)
</label>
<div className="relative">
<Mail className="absolute left-3 top-1/2 -translate-y-1/2 w-5 h-5 text-zinc-500" />
<input
type="email"
value={formData.email}
onChange={(e) => setFormData(prev => ({ ...prev, email: e.target.value }))}
placeholder="seu@email.com"
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>
</div>
<div>
<label className="block text-sm font-medium text-zinc-400 mb-2">
Senha (mínimo 6 caracteres)
</label>
<div className="relative">
<Lock className="absolute left-3 top-1/2 -translate-y-1/2 w-5 h-5 text-zinc-500" />
<input
type={showPassword ? 'text' : 'password'}
value={formData.password}
onChange={(e) => setFormData(prev => ({ ...prev, password: e.target.value }))}
placeholder="••••••••"
className="w-full pl-11 pr-12 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"
/>
<button
type="button"
onClick={() => setShowPassword(!showPassword)}
className="absolute right-3 top-1/2 -translate-y-1/2 text-zinc-500 hover:text-zinc-300"
>
{showPassword ? <EyeOff className="w-5 h-5" /> : <Eye className="w-5 h-5" />}
</button>
</div>
</div>
<div>
<label className="block text-sm font-medium text-zinc-400 mb-2">
Confirmar senha
</label>
<div className="relative">
<Lock className="absolute left-3 top-1/2 -translate-y-1/2 w-5 h-5 text-zinc-500" />
<input
type={showPassword ? 'text' : 'password'}
value={formData.confirmPassword}
onChange={(e) => setFormData(prev => ({ ...prev, confirmPassword: e.target.value }))}
placeholder="••••••••"
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>
{formData.password && formData.confirmPassword && formData.password !== formData.confirmPassword && (
<p className="text-xs text-red-400 mt-1">As senhas não coincidem</p>
)}
</div>
</motion.div>
)}
{/* Step 4: Logo e Cores */}
{step === 4 && (
<motion.div
key="step4"
initial={{ opacity: 0, x: 20 }}
animate={{ opacity: 1, x: 0 }}
exit={{ opacity: 0, x: -20 }}
className="space-y-6"
>
<div>
<label className="block text-sm font-medium text-zinc-400 mb-2">
Logo (opcional)
</label>
<label className="flex flex-col items-center justify-center w-full h-32 border-2 border-dashed border-zinc-700 rounded-xl cursor-pointer hover:border-emerald-500/50 transition-colors bg-zinc-800/50">
{previewLogo ? (
<img src={previewLogo} alt="Preview" className="w-20 h-20 object-cover rounded-xl" />
) : (
<div className="flex flex-col items-center text-zinc-500">
<Upload className="w-8 h-8 mb-2" />
<span className="text-sm">Clique para upload</span>
</div>
)}
<input type="file" className="hidden" accept="image/*" onChange={handleLogoChange} />
</label>
</div>
<div className="grid grid-cols-2 gap-4">
<div>
<label className="block text-sm font-medium text-zinc-400 mb-2">
Cor primária
</label>
<div className="flex items-center gap-2">
<input
type="color"
value={formData.primaryColor}
onChange={(e) => setFormData(prev => ({ ...prev, primaryColor: e.target.value }))}
className="w-12 h-10 rounded-lg border border-zinc-700 cursor-pointer"
/>
<span className="text-sm text-zinc-500">{formData.primaryColor}</span>
</div>
</div>
<div>
<label className="block text-sm font-medium text-zinc-400 mb-2">
Cor secundária
</label>
<div className="flex items-center gap-2">
<input
type="color"
value={formData.secondaryColor}
onChange={(e) => setFormData(prev => ({ ...prev, secondaryColor: e.target.value }))}
className="w-12 h-10 rounded-lg border border-zinc-700 cursor-pointer"
/>
<span className="text-sm text-zinc-500">{formData.secondaryColor}</span>
</div>
</div>
</div>
</motion.div>
)}
</AnimatePresence>
{/* Buttons */}
<div className="flex gap-3 mt-8">
{step > 1 && (
<button
onClick={handleBack}
className="flex-1 py-3 bg-zinc-800 hover:bg-zinc-700 text-white font-medium rounded-xl flex items-center justify-center gap-2 transition-colors"
>
<ChevronLeft className="w-5 h-5" />
Voltar
</button>
)}
{step < totalSteps ? (
<button
onClick={handleNext}
disabled={!canProceed()}
className="flex-1 py-3 bg-gradient-to-r from-emerald-500 to-emerald-600 hover:from-emerald-600 hover:to-emerald-700 text-white font-medium rounded-xl flex items-center justify-center gap-2 transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
>
Continuar
<ArrowRight className="w-5 h-5" />
</button>
) : (
<button
onClick={handleSubmit}
disabled={isSubmitting}
className="flex-1 py-3 bg-gradient-to-r from-emerald-500 to-emerald-600 hover:from-emerald-600 hover:to-emerald-700 text-white font-medium rounded-xl flex items-center justify-center gap-2 transition-colors disabled:opacity-50"
>
{isSubmitting ? (
<>
<Loader2 className="w-5 h-5 animate-spin" />
Criando...
</>
) : (
<>
<Check className="w-5 h-5" />
Criar Pelada
</>
)}
</button>
)}
</div>
</div>
</motion.div>
</div>
)
}

BIN
src/app/favicon.ico Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 25 KiB

View File

@@ -0,0 +1,155 @@
import { prisma } from '@/lib/prisma'
import { notFound } from 'next/navigation'
import { Check, X, AlertCircle, Users } from 'lucide-react'
import { CopyButton } from '@/components/CopyButton'
// 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({
where: { id },
include: {
group: {
select: {
name: true,
logoUrl: true,
pixKey: true,
pixName: true,
primaryColor: true
}
},
payments: {
include: {
player: {
select: {
name: true,
position: true
}
}
},
orderBy: {
player: {
name: 'asc'
}
}
}
}
})
}
export default async function PublicFinancialReport(props: { params: Promise<{ id: string }> }) {
const params = await props.params;
const event = await getPublicEventData(params.id)
if (!event) return notFound()
const totalExpected = event.payments.reduce((acc: number, p: any) => acc + p.amount, 0)
const totalPaid = event.payments
.filter((p: any) => p.status === 'PAID')
.reduce((acc: number, p: any) => acc + p.amount, 0)
const paidCount = event.payments.filter((p: any) => p.status === 'PAID').length
const progress = totalExpected > 0 ? (totalPaid / totalExpected) * 100 : 0
return (
<div className="min-h-screen bg-background text-foreground font-sans pb-20">
{/* Header */}
<div className="bg-surface-raised border-b border-border p-6 text-center space-y-4">
{event.group.logoUrl && (
<img src={event.group.logoUrl} alt="Logo" className="w-16 h-16 rounded-2xl mx-auto object-cover border border-white/10" />
)}
<div>
<h1 className="text-2xl font-black tracking-tight">{event.group.name}</h1>
<p className="text-muted text-sm uppercase tracking-widest mt-1">Relatório Financeiro</p>
</div>
</div>
<div className="max-w-md mx-auto p-6 space-y-8">
{/* Event Info */}
<div className="text-center space-y-2">
<span className={`inline-block px-3 py-1 rounded-full text-[10px] font-bold uppercase tracking-wider ${event.type === 'MONTHLY_FEE' ? 'bg-blue-500/10 text-blue-500' : 'bg-orange-500/10 text-orange-500'}`}>
{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>
</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>
</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="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>
{/* Pix Key if available */}
{event.group.pixKey && (
<div className="ui-card p-6 space-y-3 bg-surface-raised">
<p className="text-xs font-bold text-muted uppercase tracking-wider text-center">Chave Pix para Pagamento</p>
<div className="flex items-center gap-2 bg-background p-2 pr-2 rounded-lg border border-border">
<div className="flex-1 font-mono text-center text-sm truncate select-all px-2">
{event.group.pixKey}
</div>
<CopyButton text={event.group.pixKey} />
</div>
{event.group.pixName && (
<div className="text-center py-2 border-t border-dashed border-border mt-2">
<p className="text-[10px] text-muted uppercase font-bold tracking-widest mb-1">Titular</p>
<p className="text-sm font-medium">{event.group.pixName}</p>
</div>
)}
<p className="text-[10px] text-center text-muted mt-2">A confirmação no sistema é feita manualmente por um administrador.</p>
</div>
)}
{/* List */}
<div className="space-y-4">
<h3 className="font-bold text-lg flex items-center gap-2">
<Users className="w-5 h-5 text-primary" />
Lista de Adesão
</h3>
<div className="grid gap-2">
{event.payments.map((payment: any) => (
<div key={payment.id} className="flex items-center justify-between p-3 rounded-xl border bg-surface/50 border-border">
<div className="flex items-center gap-3">
<div className={`w-8 h-8 rounded-full flex items-center justify-center text-xs font-bold ${payment.status === 'PAID' ? 'bg-green-500 text-black' : 'bg-surface-raised text-muted'
}`}>
{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>
<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'
: 'bg-red-500/10 text-red-500'
}`}>
{payment.status === 'PAID' ? 'PAGO' : 'PENDENTE'}
</span>
</div>
))}
</div>
</div>
<footer className="text-center pt-8 border-t border-border">
<p className="text-xs text-muted font-bold tracking-widest uppercase">Powered by TemFut</p>
</footer>
</div>
</div>
)
}

119
src/app/globals.css Normal file
View File

@@ -0,0 +1,119 @@
@import "tailwindcss";
@theme {
--color-background: #000000;
--color-foreground: #ffffff;
--color-border: #1f1f1f;
--color-surface: #0a0a0a;
--color-surface-raised: #141414;
--color-primary: var(--primary-color);
/* Emerald 500 */
--color-primary-soft: color-mix(in srgb, var(--primary-color), transparent 90%);
--color-muted: #737373;
--font-sans: 'Inter', -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
--font-mono: 'JetBrains Mono', 'Fira Code', monospace;
}
:root {
--background: 0 0% 0%;
--foreground: 0 0% 100%;
--primary: 158 82% 39%;
--border: 0 0% 12%;
--primary-color: #10b981;
}
@layer base {
body {
@apply bg-background text-foreground antialiased;
font-family: var(--font-sans);
letter-spacing: -0.01em;
}
button,
a,
select,
input[type="radio"],
input[type="checkbox"],
label {
@apply cursor-pointer;
}
* {
@apply border-border;
}
}
@layer components {
.ui-card {
@apply bg-surface border border-border rounded-lg transition-all duration-200;
}
.ui-card-hover {
@apply hover:bg-surface-raised hover:border-white/20 cursor-pointer;
}
.ui-input {
@apply bg-transparent border border-border rounded-md px-4 py-2 text-sm focus:outline-none focus:ring-1 focus:ring-primary focus:border-primary transition-all placeholder:text-muted;
}
.ui-button {
@apply bg-foreground text-background cursor-pointer font-medium py-2 px-6 rounded-md hover:bg-foreground/90 active:scale-[0.98] transition-all flex items-center justify-center gap-2 disabled:opacity-50 text-sm;
}
.ui-button-ghost {
@apply bg-transparent border border-border text-foreground cursor-pointer font-medium py-2 px-6 rounded-md hover:bg-white/5 active:scale-[0.98] transition-all flex items-center justify-center gap-2 text-sm;
}
.text-title {
@apply text-3xl font-semibold tracking-tight;
}
.text-label {
@apply text-xs font-bold uppercase tracking-wider text-muted;
}
.badge {
@apply px-2 py-0.5 rounded-full text-[10px] font-medium border;
}
.badge-primary {
@apply bg-primary/10 text-primary border-primary/20;
}
.badge-muted {
@apply bg-white/5 text-muted border-white/10;
}
.ui-form-field {
@apply flex flex-col gap-3;
}
}
/* Custom Scrollbar */
::-webkit-scrollbar {
width: 6px;
}
::-webkit-scrollbar-track {
@apply bg-transparent;
}
::-webkit-scrollbar-thumb {
@apply bg-border rounded-full hover:bg-white/20;
}
/* Date Picker Customization for Dark Mode */
::-webkit-calendar-picker-indicator {
filter: invert(1);
cursor: pointer;
opacity: 0.4;
transition: 0.2s;
}
::-webkit-calendar-picker-indicator:hover {
opacity: 1;
}

27
src/app/layout.tsx Normal file
View File

@@ -0,0 +1,27 @@
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.",
};
export default function RootLayout({
children,
}: Readonly<{
children: React.ReactNode;
}>) {
return (
<html lang="pt-BR">
<body className={`${inter.variable} font-sans antialiased`}>
{children}
</body>
</html>
);
}

33
src/app/login/page.tsx Normal file
View File

@@ -0,0 +1,33 @@
import { headers } from 'next/headers'
import { prisma } from '@/lib/prisma'
import PeladaLoginPage from '@/components/PeladaLoginPage'
import PeladaNotFound from '../not-found-pelada/page'
import { redirect } from 'next/navigation'
import { getActiveGroup } from '@/lib/auth'
export default async function LoginPage() {
const headerStore = await headers()
const slug = headerStore.get('x-current-slug')
// Se acessou /login sem subdomínio válido -> 404
if (!slug || slug === 'localhost') {
return <PeladaNotFound />
}
// Valida se a pelada existe
const group = await prisma.group.findUnique({
where: { slug: slug.toLowerCase() }
})
if (!group) {
return <PeladaNotFound />
}
// Se já estiver logado, manda pro dashboard
const activeGroup = await getActiveGroup()
if (activeGroup && activeGroup.slug.toLowerCase() === slug.toLowerCase()) {
redirect('/dashboard')
}
return <PeladaLoginPage slug={slug} groupName={group.name} />
}

View File

@@ -0,0 +1,329 @@
'use client'
import React, { useState, useEffect, useMemo } from 'react'
import { Trophy, Calendar, MapPin, Users, Check, X, Star, Shuffle, ArrowRight, Search, Shield, Target, Zap, ChevronRight, User } from 'lucide-react'
import { getMatchWithAttendance, confirmAttendance, cancelAttendance } from '@/actions/attendance'
import { motion, AnimatePresence } from 'framer-motion'
import { clsx } from 'clsx'
import { useParams } from 'next/navigation'
import { ThemeWrapper } from '@/components/ThemeWrapper'
export default function ConfirmationPage() {
const { id } = useParams() as { id: string }
const [match, setMatch] = useState<any>(null)
const [loading, setLoading] = useState(true)
const [searchQuery, setSearchQuery] = useState('')
const [isProcessing, setIsProcessing] = useState(false)
const [showSuccess, setShowSuccess] = useState(false)
useEffect(() => {
loadMatch()
}, [id])
const loadMatch = async () => {
const data = await getMatchWithAttendance(id)
setMatch(data)
setLoading(false)
}
const handleConfirm = async (playerId: string) => {
setIsProcessing(true)
try {
await confirmAttendance(id, playerId)
await loadMatch()
setShowSuccess(true)
setTimeout(() => setShowSuccess(false), 3000)
} catch (error) {
console.error(error)
} finally {
setIsProcessing(false)
}
}
const getInitials = (name: string) => {
return name.split(' ').filter(n => n.length > 0).map(n => n[0]).join('').toUpperCase().slice(0, 2)
}
const unconfirmedPlayers = useMemo(() => {
if (!match) return []
return match.group.players.filter((p: any) =>
!match.attendances.find((a: any) => a.playerId === p.id && a.status === 'CONFIRMED')
).filter((p: any) =>
p.name.toLowerCase().includes(searchQuery.toLowerCase())
).sort((a: any, b: any) => a.name.localeCompare(b.name))
}, [match, searchQuery])
const confirmedPlayers = useMemo(() => {
if (!match) return []
return match.attendances
.filter((a: any) => a.status === 'CONFIRMED')
.sort((a: any, b: any) => new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime())
}, [match])
if (loading) return (
<div className="min-h-screen bg-background flex items-center justify-center p-6">
<div className="w-10 h-10 border-2 border-primary border-t-transparent rounded-full animate-spin shadow-[0_0_15px_rgba(16,185,129,0.2)]" />
</div>
)
if (!match) return (
<div className="min-h-screen bg-background flex flex-col items-center justify-center p-6 text-center">
<h1 className="text-xl font-bold mb-2">Evento não encontrado</h1>
<p className="text-muted text-sm">O link pode estar expirado ou incorreto.</p>
</div>
)
return (
<div className="min-h-screen bg-background text-foreground font-sans selection:bg-primary selection:text-background">
<ThemeWrapper primaryColor={match.group.primaryColor} />
{/* Header / Banner */}
<div className="bg-surface-raised border-b border-border p-8 overflow-hidden relative">
<div className="absolute top-0 right-0 w-80 h-80 bg-primary/10 blur-[100px] rounded-full -mr-40 -mt-40 animate-pulse" />
<div className="absolute bottom-0 left-0 w-40 h-40 bg-primary/5 blur-[80px] rounded-full -ml-20 -mb-20" />
<div className="max-w-md mx-auto relative z-10 flex flex-col items-center text-center space-y-4">
<motion.div
initial={{ scale: 0.8, opacity: 0 }}
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" />
</motion.div>
<div className="space-y-1">
<span className="text-[10px] font-black text-primary uppercase tracking-[0.4em]">Convite Oficial</span>
<h1 className="text-3xl font-black tracking-tighter uppercase">{match.group.name}</h1>
</div>
<div className="flex flex-wrap items-center justify-center gap-4 text-muted text-xs font-bold uppercase tracking-widest bg-surface/50 backdrop-blur-md px-4 py-2 rounded-full border border-white/5">
<div className="flex items-center gap-2">
<Calendar className="w-3.5 h-3.5 text-primary" />
{new Date(match.date).toLocaleDateString('pt-BR', { day: 'numeric', month: 'short' })}
</div>
<div className="w-1 h-1 rounded-full bg-border" />
{match.location && (
<div className="flex items-center gap-2">
<MapPin className="w-3.5 h-3.5 text-primary" />
{match.location}
</div>
)}
</div>
</div>
</div>
<main className="max-w-md mx-auto p-6 space-y-10">
{/* Success Toast */}
<AnimatePresence>
{showSuccess && (
<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-primary text-background 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">
<Check className="w-4 h-4" />
</div>
Presença confirmada com sucesso!
</motion.div>
)}
</AnimatePresence>
{/* Draw Results (If done) */}
{(match.status === 'IN_PROGRESS' || match.status === 'COMPLETED') && (
<section className="space-y-6 animate-in fade-in duration-1000">
<div className="flex items-center justify-between">
<h3 className="text-xs font-black uppercase tracking-[0.2em] text-primary flex items-center gap-2">
<Shuffle className="w-4 h-4" /> Escalacões Geradas
</h3>
<div className="px-3 py-1 bg-surface-raised text-primary border border-primary/20 rounded-lg text-[10px] font-mono font-bold tracking-tighter">
SEED: {match.drawSeed || 'TRANS-1'}
</div>
</div>
<div className="space-y-4">
{match.teams.map((team: any, i: number) => (
<div key={i} className="ui-card overflow-hidden border-white/5 shadow-lg">
<div className="h-1.5 w-full" style={{ backgroundColor: team.color }} />
<div className="p-5 bg-surface-raised/30">
<div className="flex items-center justify-between mb-4">
<h4 className="text-sm font-black uppercase tracking-tight">{team.name}</h4>
<span className="text-[10px] font-bold text-muted bg-surface px-2 py-0.5 rounded border border-border">{team.players.length} Atletas</span>
</div>
<div className="space-y-2.5">
{team.players.map((tp: any, j: number) => (
<div key={j} className="flex items-center justify-between text-xs py-1 border-b border-white/5 last:border-0 opacity-90">
<div className="flex items-center gap-3">
<span className="font-mono text-[11px] w-6 text-primary font-black text-center">
{tp.player.number !== null ? tp.player.number : getInitials(tp.player.name)}
</span>
<span className="font-bold tracking-tight uppercase text-[11px]">{tp.player.name}</span>
</div>
<span className="text-[9px] font-black uppercase text-muted bg-background px-1.5 py-0.5 rounded">{tp.player.position}</span>
</div>
))}
</div>
</div>
</div>
))}
</div>
</section>
)}
{/* Selection Box */}
{match.status === 'SCHEDULED' && (
<section className="space-y-6 animate-in fade-in slide-in-from-bottom-6 duration-1000">
<div className="flex items-center justify-between">
<h3 className="text-xs font-black uppercase tracking-[0.2em] text-primary flex items-center gap-2">
<User className="w-4 h-4" /> Quem é você?
</h3>
<span className="text-[10px] font-bold text-muted bg-surface px-2 py-0.5 rounded border border-border">
{unconfirmedPlayers.length} Disponíveis
</span>
</div>
<div className="space-y-4">
<div className="relative group">
<Search className="absolute left-4 top-1/2 -translate-y-1/2 w-4 h-4 text-muted group-focus-within:text-primary transition-colors" />
<input
type="text"
placeholder="Buscar seu nome na lista..."
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
className="ui-input w-full pl-11 h-14 bg-surface-raised border-primary/10 text-sm font-bold placeholder:font-medium"
/>
</div>
<div className="grid gap-2 max-h-[400px] overflow-y-auto pr-1 custom-scrollbar">
<AnimatePresence mode='popLayout'>
{unconfirmedPlayers.map((p: any) => (
<motion.button
layout
initial={{ opacity: 0, scale: 0.98 }}
animate={{ opacity: 1, scale: 1 }}
exit={{ opacity: 0, scale: 0.98 }}
key={p.id}
onClick={() => handleConfirm(p.id)}
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">
{p.number !== null ? p.number : getInitials(p.name)}
</div>
<div className="space-y-0.5">
<p className="text-xs font-black uppercase tracking-tight">{p.name}</p>
<div className="flex items-center gap-2">
<span className="text-[9px] font-black text-muted uppercase tracking-widest">{p.position}</span>
<div className="flex gap-0.5">
{[...Array(5)].map((_, i) => (
<Star key={i} className={clsx(
"w-2 h-2",
i < p.level ? "text-primary fill-primary" : "text-border fill-transparent"
)} />
))}
</div>
</div>
</div>
</div>
<div className="w-8 h-8 rounded-full border border-border flex items-center justify-center group-hover:border-primary/50 transition-all">
<ChevronRight className="w-4 h-4 text-muted group-hover:text-primary group-hover:translate-x-0.5 transition-all" />
</div>
</motion.button>
))}
</AnimatePresence>
{unconfirmedPlayers.length === 0 && (
<div className="py-10 text-center space-y-3 opacity-50">
<Search className="w-8 h-8 mx-auto text-muted" />
<p className="text-xs font-bold uppercase tracking-widest text-muted">Nenhum atleta encontrado</p>
</div>
)}
</div>
</div>
</section>
)}
{/* Confirmed List */}
<section className="space-y-6">
<div className="flex items-center justify-between">
<h3 className="text-xs font-black uppercase tracking-[0.2em] text-muted flex items-center gap-2">
<Check className="w-4 h-4 text-primary" /> Lista de Presença
</h3>
<div className="text-[10px] font-black text-primary px-3 py-1 bg-primary/5 rounded-full border border-primary/20 shadow-inner">
{confirmedPlayers.length} / {match.maxPlayers || '∞'} CONFIRMADOS
</div>
</div>
<div className="grid gap-3">
<AnimatePresence mode='popLayout'>
{confirmedPlayers.map((a: any) => (
<motion.div
layout
initial={{ opacity: 0, x: -10 }}
animate={{ opacity: 1, x: 0 }}
key={a.id}
className="ui-card p-4 flex items-center justify-between bg-surface-raised/30 border-white/5 relative overflow-hidden group"
>
<div className="absolute top-0 right-0 w-20 h-20 bg-primary/5 blur-2xl rounded-full -mr-10 -mt-10 pointer-events-none" />
<div className="flex items-center gap-4 relative z-10">
<div className="w-10 h-10 rounded-xl bg-primary/10 border border-primary/20 flex items-center justify-center font-black text-[11px] text-primary">
{getInitials(a.player.name)}
</div>
<div>
<p className="text-xs font-black uppercase tracking-tight">{a.player.name}</p>
<div className="flex items-center gap-2 mt-0.5 text-[9px] font-bold text-muted uppercase tracking-widest">
{a.player.position}
<span className="w-1 h-1 rounded-full bg-border" />
<div className="flex gap-0.5">
{[...Array(5)].map((_, i) => (
<Star key={i} className={clsx(
"w-2 h-2",
i < a.player.level ? "text-primary fill-primary" : "text-border fill-transparent"
)} />
))}
</div>
</div>
</div>
</div>
<div className="flex items-center gap-2">
<div className="px-2 py-0.5 bg-primary/10 rounded text-[8px] font-black text-primary uppercase tracking-widest border border-primary/20">
OK
</div>
</div>
</motion.div>
))}
</AnimatePresence>
{confirmedPlayers.length === 0 && (
<div className="ui-card p-12 text-center border-dashed border-border/50 opacity-40">
<Users className="w-8 h-8 mx-auto mb-3 text-muted" />
<p className="text-[10px] font-bold uppercase tracking-widest text-muted">Aguardando a primeira confirmação...</p>
</div>
)}
</div>
</section>
</main>
{/* Footer */}
<footer className="max-w-md mx-auto p-12 text-center space-y-6">
<div className="h-px bg-gradient-to-r from-transparent via-border to-transparent w-full" />
<div className="space-y-4">
<p className="text-[10px] text-muted font-black uppercase tracking-[0.4em]">
Developed by <span className="text-foreground">TEMFUT PRO</span>
</p>
<div className="flex items-center justify-center gap-3">
<div className="w-10 h-10 bg-surface-raised border border-border rounded-xl flex items-center justify-center shadow-lg">
<Trophy className="w-4 h-4 text-primary" />
</div>
<div className="text-left">
<p className="text-[10px] font-black uppercase tracking-widest">Sorteio Transparente</p>
<p className="text-[9px] font-bold text-muted uppercase">Tecnologia Provably Fair</p>
</div>
</div>
</div>
</footer>
</div>
)
}

View File

@@ -0,0 +1,58 @@
'use client'
import { motion } from 'framer-motion'
import { AlertTriangle, Home, Plus } from 'lucide-react'
export default function PeladaNotFound() {
return (
<div className="min-h-screen bg-zinc-950 flex items-center justify-center p-4">
{/* Background gradient */}
<div className="fixed inset-0 bg-gradient-to-br from-red-950/10 via-zinc-950 to-zinc-950" />
<motion.div
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
className="relative text-center max-w-md"
>
{/* Icon */}
<div className="w-20 h-20 rounded-2xl bg-red-500/10 border border-red-500/20 flex items-center justify-center mx-auto mb-6">
<AlertTriangle className="w-10 h-10 text-red-400" />
</div>
{/* Title */}
<h1 className="text-3xl font-bold text-white mb-3">
Pelada não encontrada
</h1>
{/* Description */}
<p className="text-zinc-400 mb-8">
O grupo que você está procurando não existe ou foi removido.
Verifique o endereço e tente novamente.
</p>
{/* Actions */}
<div className="flex flex-col sm:flex-row gap-3 justify-center">
<a
href="http://localhost"
className="inline-flex items-center justify-center gap-2 px-6 py-3 bg-zinc-800 hover:bg-zinc-700 text-white font-medium rounded-xl transition-colors"
>
<Home className="w-5 h-5" />
Voltar ao início
</a>
<a
href="http://localhost/create"
className="inline-flex items-center justify-center gap-2 px-6 py-3 bg-gradient-to-r from-emerald-500 to-emerald-600 hover:from-emerald-600 hover:to-emerald-700 text-white font-medium rounded-xl transition-colors"
>
<Plus className="w-5 h-5" />
Criar nova pelada
</a>
</div>
{/* Footer */}
<p className="text-xs text-zinc-600 mt-8">
Erro 404 - Grupo não existe no sistema
</p>
</motion.div>
</div>
)
}

37
src/app/page.tsx Normal file
View File

@@ -0,0 +1,37 @@
import { headers } from 'next/headers'
import { prisma } from '@/lib/prisma'
import LandingPage from './LandingPage'
import PeladaNotFound from './not-found-pelada/page'
import PeladaLoginPage from '@/components/PeladaLoginPage'
import { redirect } from 'next/navigation'
import { getActiveGroup } from '@/lib/auth'
export default async function Home() {
const headerStore = await headers()
const slug = headerStore.get('x-current-slug')
// Se NÃO tem subdomínio (Ex: http://localhost) -> Mostra site institucional
if (!slug || slug === 'localhost') {
return <LandingPage />
}
// Se TEM subdomínio (Ex: http://futdequarta.localhost)
const group = await prisma.group.findUnique({
where: { slug: slug.toLowerCase() }
})
// Pelada não existe no banco
if (!group) {
return <PeladaNotFound />
}
// Verifica se o usuário já está logado NESTA pelada específica
const activeGroup = await getActiveGroup()
if (activeGroup && activeGroup.slug.toLowerCase() === slug.toLowerCase()) {
redirect('/dashboard')
}
// Caso contrário, mostra a tela de login da pelada
return <PeladaLoginPage slug={slug} groupName={group.name} />
}

View File

@@ -0,0 +1,143 @@
'use client'
import { useState, useTransition } from 'react'
import { createArena, deleteArena } from '@/actions/arena'
import { MapPin, Plus, Trash2, Loader2, Navigation } from 'lucide-react'
import type { Arena } from '@prisma/client'
import { DeleteConfirmationModal } from '@/components/DeleteConfirmationModal'
interface ArenasManagerProps {
arenas: Arena[]
}
export function ArenasManager({ arenas }: ArenasManagerProps) {
const [isPending, startTransition] = useTransition()
const [deleteModal, setDeleteModal] = useState<{
isOpen: boolean
arenaId: string | null
isDeleting: boolean
}>({
isOpen: false,
arenaId: null,
isDeleting: false
})
const handleDelete = (id: string) => {
setDeleteModal({
isOpen: true,
arenaId: id,
isDeleting: false
})
}
const confirmDelete = () => {
if (!deleteModal.arenaId) return
setDeleteModal(prev => ({ ...prev, isDeleting: true }))
startTransition(async () => {
try {
await deleteArena(deleteModal.arenaId!)
setDeleteModal({ isOpen: false, arenaId: null, isDeleting: false })
} catch (error) {
console.error(error)
alert('Erro ao excluir local.')
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">
<MapPin className="w-5 h-5 text-primary" />
Locais / Arenas
</h3>
<p className="text-muted text-sm">
Cadastre os locais onde as partidas acontecem para facilitar o agendamento.
</p>
</header>
<div className="space-y-3">
{arenas.map((arena) => (
<div key={arena.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-start gap-3">
<div className="p-2 bg-surface rounded-lg text-muted group-hover:text-primary transition-colors">
<Navigation className="w-4 h-4" />
</div>
<div>
<p className="font-medium text-foreground">{arena.name}</p>
{arena.address && <p className="text-sm text-muted">{arena.address}</p>}
</div>
</div>
<button
onClick={() => handleDelete(arena.id)}
disabled={isPending}
className="p-2 text-muted hover:text-destructive hover:bg-destructive/10 rounded-lg transition-colors opacity-0 group-hover:opacity-100 focus:opacity-100"
title="Excluir local"
>
<Trash2 className="w-4 h-4" />
</button>
</div>
))}
{arenas.length === 0 && (
<div className="text-center py-8 px-4 border border-dashed border-border rounded-xl bg-surface/50">
<MapPin className="w-8 h-8 text-muted mx-auto mb-3 opacity-50" />
<p className="text-muted">Nenhum local cadastrado ainda.</p>
</div>
)}
</div>
<form action={(formData) => {
startTransition(async () => {
await createArena(formData)
const form = document.getElementById('arena-form') as HTMLFormElement
form?.reset()
})
}} id="arena-form" className="pt-6 mt-6 border-t border-border">
<div className="flex flex-col md:flex-row gap-4 items-end">
<div className="ui-form-field flex-1">
<label className="text-label ml-1">Nome do Local</label>
<input
name="name"
placeholder="Ex: Arena do Zé"
className="ui-input w-full"
required
/>
</div>
<div className="ui-form-field flex-[1.5]">
<label className="text-label ml-1">Endereço (Opcional)</label>
<input
name="address"
placeholder="Rua das Flores, 123"
className="ui-input w-full"
/>
</div>
<button
type="submit"
disabled={isPending}
className="ui-button h-[42px] px-6 whitespace-nowrap"
>
{isPending ? (
<Loader2 className="w-4 h-4 animate-spin mr-2" />
) : (
<Plus className="w-4 h-4 mr-2" />
)}
Adicionar
</button>
</div>
</form>
<DeleteConfirmationModal
isOpen={deleteModal.isOpen}
onClose={() => setDeleteModal({ isOpen: false, arenaId: null, isDeleting: false })}
onConfirm={confirmDelete}
isDeleting={deleteModal.isDeleting}
title="Excluir Local?"
description="Tem certeza que deseja excluir este local? Partidas agendadas neste local manterão o nome, mas o local não estará mais disponível para novos agendamentos."
confirmText="Sim, excluir"
/>
</div>
)
}

View File

@@ -0,0 +1,24 @@
'use client'
import { useState } from 'react'
import { Copy, Check } from 'lucide-react'
export function CopyButton({ text, label = 'Copiar' }: { text: string, label?: string }) {
const [copied, setCopied] = useState(false)
const handleCopy = () => {
navigator.clipboard.writeText(text)
setCopied(true)
setTimeout(() => setCopied(false), 2000)
}
return (
<button
onClick={handleCopy}
className="p-2 hover:bg-surface-raised rounded-md transition-colors text-primary active:scale-95"
title={label}
>
{copied ? <Check className="w-4 h-4" /> : <Copy className="w-4 h-4" />}
</button>
)
}

View File

@@ -0,0 +1,266 @@
'use client'
import { useState } from 'react'
import { createFinancialEvent } from '@/actions/finance'
import type { FinancialEventType } from '@prisma/client'
import { Loader2, Plus, Users, Calculator } from 'lucide-react'
import { useRouter } from 'next/navigation'
interface CreateFinanceEventModalProps {
isOpen: boolean
onClose: () => void
players: any[]
}
export function CreateFinanceEventModal({ isOpen, onClose, players }: CreateFinanceEventModalProps) {
const router = useRouter()
const [isPending, setIsPending] = useState(false)
const [step, setStep] = useState(1) // 1 = Details, 2 = Players
// Form State
const [type, setType] = useState<FinancialEventType>('MONTHLY_FEE')
const [title, setTitle] = useState('')
const [dueDate, setDueDate] = useState('')
const [priceMode, setPriceMode] = useState<'FIXED' | 'TOTAL'>('FIXED') // Fixed per person or Total to split
const [amount, setAmount] = useState('')
const [isRecurring, setIsRecurring] = useState(false)
const [recurrenceEndDate, setRecurrenceEndDate] = useState('')
const [selectedPlayers, setSelectedPlayers] = useState<string[]>(players.map(p => p.id)) // Default select all
if (!isOpen) return null
const handleSubmit = async () => {
setIsPending(true)
try {
const numAmount = parseFloat(amount.replace(',', '.'))
const result = await createFinancialEvent({
title: title || (type === 'MONTHLY_FEE' ? 'Mensalidade' : 'Evento'),
type,
dueDate,
selectedPlayerIds: selectedPlayers,
pricePerPerson: priceMode === 'FIXED' ? numAmount : undefined,
totalAmount: priceMode === 'TOTAL' ? numAmount : undefined,
isRecurring: isRecurring,
recurrenceEndDate: recurrenceEndDate
})
if (!result.success) {
alert(result.error)
return
}
onClose()
router.refresh()
} catch (error) {
console.error(error)
} finally {
setIsPending(false)
}
}
const togglePlayer = (id: string) => {
setSelectedPlayers(prev =>
prev.includes(id) ? prev.filter(p => p !== id) : [...prev, id]
)
}
const selectAll = () => setSelectedPlayers(players.map(p => p.id))
const selectNone = () => setSelectedPlayers([])
return (
<div className="fixed inset-0 z-50 flex items-center justify-center p-4 bg-black/80 backdrop-blur-sm animate-in fade-in duration-200">
<div className="bg-surface border border-border rounded-xl w-full max-w-lg shadow-2xl overflow-hidden flex flex-col max-h-[90vh]">
<div className="p-6 border-b border-border">
<h3 className="text-lg font-bold">Novo Evento Financeiro</h3>
<p className="text-sm text-muted">Crie mensalidades, churrascos ou arrecadações.</p>
</div>
<div className="flex-1 overflow-y-auto p-6 space-y-6">
{step === 1 ? (
<div className="space-y-4">
<div className="grid grid-cols-2 gap-2 p-1 bg-surface-raised rounded-lg border border-border">
<button
onClick={() => setType('MONTHLY_FEE')}
className={`py-2 text-sm font-bold rounded-md transition-all ${type === 'MONTHLY_FEE' ? 'bg-primary text-background' : 'hover:bg-white/5 text-muted'}`}
>
Mensalidade
</button>
<button
onClick={() => setType('EXTRA_EVENT')}
className={`py-2 text-sm font-bold rounded-md transition-all ${type === 'EXTRA_EVENT' ? 'bg-primary text-background' : 'hover:bg-white/5 text-muted'}`}
>
Churrasco/Evento
</button>
</div>
<div className="ui-form-field">
<label className="text-label ml-1">Título</label>
<input
value={title}
onChange={e => setTitle(e.target.value)}
placeholder={type === 'MONTHLY_FEE' ? "Ex: Mensalidade Janeiro" : "Ex: Churrasco de Fim de Ano"}
className="ui-input w-full"
/>
</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]"
/>
</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>
{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>
)}
</div>
)}
<div className="space-y-2">
<label className="text-label ml-1">Valor</label>
<div className="flex bg-surface-raised rounded-lg overflow-hidden border border-border">
<div className="flex flex-col border-r border-border">
<button
onClick={() => setPriceMode('FIXED')}
className={`px-3 py-2 text-xs font-bold ${priceMode === 'FIXED' ? 'bg-primary/20 text-primary' : 'text-muted hover:bg-white/5'}`}
>
Fixo (Por Pessoa)
</button>
<button
onClick={() => setPriceMode('TOTAL')}
className={`px-3 py-2 text-xs font-bold ${priceMode === 'TOTAL' ? 'bg-primary/20 text-primary' : 'text-muted hover:bg-white/5'}`}
>
Rateio (Total)
</button>
</div>
<div className="flex-1 flex items-center px-4">
<span className="text-muted font-bold mr-2">R$</span>
<input
type="number"
value={amount}
onChange={e => setAmount(e.target.value)}
placeholder="0,00"
className="bg-transparent border-0 ring-0 focus:ring-0 w-full text-lg font-bold outline-none"
/>
</div>
</div>
{priceMode === 'TOTAL' && selectedPlayers.length > 0 && amount && (
<p className="text-xs text-secondary ml-1 flex items-center gap-1">
<Calculator className="w-3 h-3" />
Aproximadamente <span className="font-bold">R$ {(parseFloat(amount) / selectedPlayers.length).toFixed(2)}</span> por pessoa
</p>
)}
</div>
</div>
) : (
<div className="space-y-4">
<div className="flex items-center justify-between">
<h4 className="text-sm font-bold uppercase tracking-wider text-muted">Selecionar Pagantes</h4>
<div className="flex gap-2">
<button onClick={selectAll} className="text-xs text-primary hover:underline">Todos</button>
<button onClick={selectNone} className="text-xs text-muted hover:underline">Nenhum</button>
</div>
</div>
<div className="grid grid-cols-2 gap-2 max-h-[300px] overflow-y-auto pr-2 custom-scrollbar">
{players.map(player => (
<button
key={player.id}
onClick={() => togglePlayer(player.id)}
className={`flex items-center gap-3 p-2 rounded-lg border text-left transition-all ${selectedPlayers.includes(player.id)
? 'bg-primary/10 border-primary shadow-sm'
: 'bg-surface border-border hover:border-white/20 opacity-60 hover:opacity-100'
}`}
>
<div className={`w-8 h-8 rounded-full flex items-center justify-center text-xs font-bold ${selectedPlayers.includes(player.id) ? 'bg-primary text-background' : 'bg-surface-raised'
}`}>
{player.name.substring(0, 2).toUpperCase()}
</div>
<span className="text-sm font-medium truncate">{player.name}</span>
</button>
))}
</div>
<div className="p-3 bg-surface-raised rounded-lg border border-border">
<div className="flex justify-between items-center text-sm">
<span className="text-muted">Selecionados:</span>
<span className="font-bold">{selectedPlayers.length}</span>
</div>
<div className="flex justify-between items-center text-sm mt-1">
<span className="text-muted">Arrecadação Prevista:</span>
<span className="font-bold text-primary text-lg">
R$ {
priceMode === 'FIXED'
? ((parseFloat(amount || '0')) * selectedPlayers.length).toFixed(2)
: (parseFloat(amount || '0')).toFixed(2)
}
</span>
</div>
</div>
</div>
)}
</div>
<div className="p-6 border-t border-border flex justify-between gap-4 bg-background/50">
<button
onClick={step === 1 ? onClose : () => setStep(1)}
className="text-sm font-semibold text-muted hover:text-foreground px-4 py-2"
>
{step === 1 ? 'Cancelar' : 'Voltar'}
</button>
{step === 1 ? (
<button
onClick={() => setStep(2)}
disabled={!amount || !dueDate}
className="ui-button px-6"
>
Continuar <Users className="w-4 h-4 ml-2" />
</button>
) : (
<button
onClick={handleSubmit}
disabled={isPending || selectedPlayers.length === 0}
className="ui-button px-6"
>
{isPending ? <Loader2 className="w-4 h-4 animate-spin" /> : <Plus className="w-4 h-4 ml-2" />}
Criar Evento
</button>
)}
</div>
</div>
</div>
)
}

View File

@@ -0,0 +1,97 @@
'use client'
import React from 'react'
import { motion, AnimatePresence } from 'framer-motion'
import { AlertTriangle, Trash2, X } from 'lucide-react'
interface DeleteConfirmationModalProps {
isOpen: boolean
onClose: () => void
onConfirm: () => void
title?: string
description?: string
confirmText?: string
isDeleting?: boolean
}
export function DeleteConfirmationModal({
isOpen,
onClose,
onConfirm,
title = 'Excluir Item',
description = 'Esta ação não pode ser desfeita. Todos os dados serão perdidos permanentemente.',
confirmText = 'Sim, excluir',
isDeleting = false
}: DeleteConfirmationModalProps) {
if (!isOpen) return null
return (
<AnimatePresence>
<div className="fixed inset-0 z-50 flex items-center justify-center p-4">
{/* Backdrop */}
<motion.div
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
exit={{ opacity: 0 }}
onClick={onClose}
className="absolute inset-0 bg-black/60 backdrop-blur-sm"
/>
{/* Modal */}
<motion.div
initial={{ opacity: 0, scale: 0.95, y: 20 }}
animate={{ opacity: 1, scale: 1, y: 0 }}
exit={{ opacity: 0, scale: 0.95, y: 20 }}
className="relative w-full max-w-md bg-zinc-900 border border-zinc-800 rounded-2xl shadow-xl overflow-hidden"
>
<div className="p-6">
<div className="flex items-start gap-4">
<div className="p-3 bg-red-500/10 rounded-xl border border-red-500/20">
<AlertTriangle className="w-6 h-6 text-red-500" />
</div>
<div className="flex-1">
<h3 className="text-lg font-bold text-white mb-2">{title}</h3>
<p className="text-zinc-400 text-sm leading-relaxed">
{description}
</p>
</div>
<button
onClick={onClose}
className="p-1 hover:bg-zinc-800 rounded-lg transition-colors text-zinc-500 hover:text-white"
>
<X className="w-5 h-5" />
</button>
</div>
</div>
<div className="p-4 bg-zinc-950/50 border-t border-zinc-800 flex justify-end gap-3">
<button
onClick={onClose}
disabled={isDeleting}
className="px-4 py-2 text-sm font-medium text-zinc-400 hover:text-white hover:bg-zinc-800 rounded-xl transition-colors disabled:opacity-50"
>
Cancelar
</button>
<button
onClick={onConfirm}
disabled={isDeleting}
className="px-4 py-2 text-sm font-bold text-white bg-red-600 hover:bg-red-700 rounded-xl transition-colors flex items-center gap-2 disabled:opacity-50 disabled:cursor-not-allowed shadow-lg shadow-red-500/20"
>
{isDeleting ? (
<>
<span className="w-4 h-4 border-2 border-white/20 border-t-white rounded-full animate-spin" />
Excluindo...
</>
) : (
<>
<Trash2 className="w-4 h-4" />
{confirmText}
</>
)}
</button>
</div>
</motion.div>
</div>
</AnimatePresence>
)
}

View File

@@ -0,0 +1,453 @@
'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 { CreateFinanceEventModal } from '@/components/CreateFinanceEventModal'
import { markPaymentAsPaid, markPaymentAsPending, deleteFinancialEvents } from '@/actions/finance'
import { useRouter } from 'next/navigation'
import Link from 'next/link'
import { motion, AnimatePresence } from 'framer-motion'
import { clsx } from 'clsx'
import { DeleteConfirmationModal } from '@/components/DeleteConfirmationModal'
interface FinancialPageProps {
events: any[]
players: any[]
}
export function FinancialDashboard({ events, players }: FinancialPageProps) {
const router = useRouter()
const [isCreateModalOpen, setIsCreateModalOpen] = useState(false)
const [expandedEventId, setExpandedEventId] = useState<string | null>(null)
// Filter & View State
const [searchQuery, setSearchQuery] = useState('')
const [activeTab, setActiveTab] = useState<'ALL' | 'MONTHLY_FEE' | 'EXTRA_EVENT'>('ALL')
const [viewMode, setViewMode] = useState<'grid' | 'list'>('grid')
// Confirmation Modal State
const [deleteModal, setDeleteModal] = useState<{
isOpen: boolean
isDeleting: boolean
title: string
description: string
}>({
isOpen: false,
isDeleting: false,
title: '',
description: ''
})
// Selection
const [selectedIds, setSelectedIds] = useState<Set<string>>(new Set())
const togglePayment = async (paymentId: string, currentStatus: string) => {
if (currentStatus === 'PENDING') {
await markPaymentAsPaid(paymentId)
} else {
await markPaymentAsPending(paymentId)
}
router.refresh()
}
const filteredEvents = useMemo(() => {
return events.filter(e => {
const matchesSearch = e.title.toLowerCase().includes(searchQuery.toLowerCase())
const matchesTab = activeTab === 'ALL' || e.type === activeTab
return matchesSearch && matchesTab
})
}, [events, searchQuery, activeTab])
const totalStats = events.reduce((acc, event) => {
return {
expected: acc.expected + event.stats.totalExpected,
paid: acc.paid + event.stats.totalPaid
}
}, { expected: 0, paid: 0 })
const totalPending = totalStats.expected - totalStats.paid
const toggleSelection = (id: string) => {
const newSelected = new Set(selectedIds)
if (newSelected.has(id)) newSelected.delete(id)
else newSelected.add(id)
setSelectedIds(newSelected)
}
const toggleSelectAll = () => {
if (selectedIds.size === filteredEvents.length) {
setSelectedIds(new Set())
} else {
const newSelected = new Set<string>()
filteredEvents.forEach(e => newSelected.add(e.id))
setSelectedIds(newSelected)
}
}
const handleDeleteSelected = () => {
setDeleteModal({
isOpen: true,
isDeleting: false,
title: `Excluir ${selectedIds.size} eventos?`,
description: 'Você tem certeza que deseja excluir os eventos financeiros selecionados? Todo o histórico de pagamentos destes eventos será apagado para sempre.'
})
}
const confirmDelete = async () => {
setDeleteModal(prev => ({ ...prev, isDeleting: true }))
try {
await deleteFinancialEvents(Array.from(selectedIds))
setSelectedIds(new Set())
setDeleteModal(prev => ({ ...prev, isOpen: false }))
router.refresh()
} catch (error) {
console.error(error)
alert('Erro ao excluir eventos.')
} finally {
setDeleteModal(prev => ({ ...prev, isDeleting: false }))
}
}
return (
<div className="space-y-8 pb-20">
{/* Stats Overview */}
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
<div className="ui-card p-6 bg-gradient-to-br from-surface-raised to-background border-primary/20">
<div className="flex items-center gap-4">
<div className="p-3 bg-primary/10 rounded-xl">
<Wallet className="w-6 h-6 text-primary" />
</div>
<div>
<p className="text-sm text-muted font-bold uppercase tracking-wider">Arrecadado</p>
<h3 className="text-2xl font-black text-foreground">R$ {totalStats.paid.toFixed(2)}</h3>
</div>
</div>
</div>
<div className="ui-card p-6 border-red-500/20">
<div className="flex items-center gap-4">
<div className="p-3 bg-red-500/10 rounded-xl">
<AlertCircle className="w-6 h-6 text-red-500" />
</div>
<div>
<p className="text-sm text-muted font-bold uppercase tracking-wider">Pendente</p>
<h3 className="text-2xl font-black text-red-500">R$ {totalPending.toFixed(2)}</h3>
</div>
</div>
</div>
</div>
<div className="space-y-6">
<div className="flex flex-col lg:flex-row lg:items-center justify-between gap-6">
<div className="space-y-1">
<h2 className="text-xl font-bold tracking-tight">Gestão Financeira</h2>
<p className="text-xs text-muted font-medium">Controle de mensalidades e eventos extras.</p>
</div>
<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>
<button
onClick={handleDeleteSelected}
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"
>
<Trash2 className="w-4 h-4 mr-2" /> Excluir
</button>
</div>
) : (
<>
<div className="relative group w-full sm:w-64">
<Search 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
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
placeholder="Buscar eventos..."
className="ui-input w-full pl-10 h-10 bg-surface-raised border-border/50 text-sm"
/>
</div>
<div className="flex p-1 bg-surface-raised border border-border rounded-lg w-full sm:w-auto">
<button
onClick={() => setViewMode('grid')}
className={clsx("p-2 rounded-md transition-all", viewMode === 'grid' ? "bg-white/10 text-primary shadow-sm" : "text-muted")}
>
<LayoutGrid className="w-4 h-4" />
</button>
<button
onClick={() => setViewMode('list')}
className={clsx("p-2 rounded-md transition-all", viewMode === 'list' ? "bg-white/10 text-primary shadow-sm" : "text-muted")}
>
<List className="w-4 h-4" />
</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>
</div>
<div className="flex items-center gap-2 overflow-x-auto pb-2 scrollbar-none border-b border-border/50">
{[
{ id: 'ALL', label: 'Todos' },
{ id: 'MONTHLY_FEE', label: 'Mensalidades' },
{ id: 'EXTRA_EVENT', label: 'Eventos Extras' }
].map((tab) => (
<button
key={tab.id}
onClick={() => setActiveTab(tab.id as any)}
className={clsx(
"relative px-6 py-2.5 text-[10px] font-bold uppercase tracking-[0.2em] transition-all",
activeTab === tab.id ? "text-primary" : "text-muted hover:text-foreground"
)}
>
{tab.label}
{activeTab === tab.id && (
<motion.div layoutId="activeTab" className="absolute bottom-0 left-0 right-0 h-0.5 bg-primary" />
)}
</button>
))}
<div className="ml-auto text-[10px] font-bold text-muted bg-surface-raised px-3 py-1 rounded-full border border-border">
{filteredEvents.length} REGISTROS
</div>
</div>
<div className={clsx(
"grid gap-4 transition-all duration-500",
viewMode === 'grid' ? "grid-cols-1 md:grid-cols-2 xl:grid-cols-3" : "grid-cols-1"
)}>
{filteredEvents.length > 0 && (
<div className={clsx("col-span-full flex items-center px-2 mb-2", viewMode === 'grid' ? "justify-end" : "")}>
<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 === filteredEvents.length && filteredEvents.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 === filteredEvents.length && filteredEvents.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 === filteredEvents.length ? "text-primary" : "text-muted group-hover:text-foreground"
)}>
Selecionar Todos
</span>
</button>
</div>
)}
<AnimatePresence mode='popLayout'>
{filteredEvents.map((event) => {
const percent = event.stats.totalExpected > 0 ? (event.stats.totalPaid / event.stats.totalExpected) * 100 : 0
const isExpanded = expandedEventId === event.id
return (
<motion.div
key={event.id}
layout
initial={{ opacity: 0, scale: 0.95 }}
animate={{ opacity: 1, scale: 1 }}
exit={{ opacity: 0, scale: 0.95 }}
className={clsx(
"ui-card group relative hover:border-primary/40 transition-all duration-500 overflow-hidden",
viewMode === 'list' ? "p-0" : "p-0",
selectedIds.has(event.id) ? "border-primary bg-primary/5" : "border-border"
)}
>
<div
className={clsx(
"cursor-pointer p-5 flex flex-col gap-4",
viewMode === 'list' ? "sm:flex-row sm:items-center" : ""
)}
onClick={() => setExpandedEventId(isExpanded ? null : event.id)}
>
<div
onClick={(e) => {
e.stopPropagation()
toggleSelection(event.id)
}}
className={clsx("z-20", viewMode === 'grid' ? "absolute top-4 right-4" : "mr-2")}
>
<div className={clsx(
"w-5 h-5 rounded-lg border-2 flex items-center justify-center transition-all duration-200",
selectedIds.has(event.id)
? "bg-primary border-primary shadow-[0_0_10px_rgba(16,185,129,0.4)]"
: "border-muted/30 bg-surface/50 opacity-100 sm:opacity-0 group-hover:opacity-100 hover:border-primary/50"
)}>
{selectedIds.has(event.id) && <Check className="w-3.5 h-3.5 text-background font-bold stroke-[3]" />}
</div>
</div>
<div className="flex items-center gap-4 flex-1">
<div className="w-12 h-12 bg-surface-raised rounded-2xl border border-border/50 flex items-center justify-center text-primary shadow-inner shrink-0">
<Calendar className="w-6 h-6" />
</div>
<div>
<div className="flex items-center gap-2 mb-1">
<span className={clsx("text-[9px] font-bold px-2 py-0.5 rounded-md uppercase tracking-wider",
event.type === 'MONTHLY_FEE' ? 'bg-blue-500/10 text-blue-500' : 'bg-orange-500/10 text-orange-500'
)}>
{event.type === 'MONTHLY_FEE' ? 'Mensalidade' : 'Evento'}
</span>
<span className="text-[10px] text-muted font-bold">
{new Date(event.dueDate).toLocaleDateString('pt-BR')}
</span>
</div>
<h3 className="font-bold text-base leading-tight group-hover:text-primary transition-colors">{event.title}</h3>
</div>
</div>
<div className={clsx("w-full sm:w-48 space-y-2", viewMode === 'list' && "mr-8")}>
<div className="flex justify-between text-[10px] font-bold uppercase tracking-wider text-muted">
<span>Progresso</span>
<span>{Math.round(percent)}%</span>
</div>
<div className="h-1.5 bg-surface-raised rounded-full overflow-hidden">
<div className="h-full bg-primary transition-all duration-500" style={{ width: `${percent}%` }} />
</div>
<div className="flex justify-between text-[10px] text-muted font-mono">
<span>R$ {event.stats.totalPaid.toFixed(2)}</span>
<span>R$ {event.stats.totalExpected.toFixed(2)}</span>
</div>
</div>
</div>
<AnimatePresence>
{isExpanded && (
<motion.div
initial={{ height: 0, opacity: 0 }}
animate={{ height: 'auto', opacity: 1 }}
exit={{ height: 0, opacity: 0 }}
className="border-t border-border bg-surface-raised/30 overflow-hidden"
>
<div className="p-5 space-y-6">
<div className="flex flex-col sm:flex-row gap-3 items-start sm:items-center justify-between p-4 bg-surface/50 rounded-xl border border-dashed border-border">
<div>
<h5 className="text-xs font-bold uppercase tracking-widest text-muted mb-1">Compartilhar Relatório</h5>
<p className="text-[10px] text-muted">Envie o status atual para o grupo do time.</p>
</div>
<div className="flex gap-2 w-full sm:w-auto">
<button
onClick={() => {
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` +
`✅ *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` +
`🔗 *Link do Relatório*: ${window.location.origin}/financial-report/${event.id}`
const url = `https://wa.me/?text=${encodeURIComponent(message)}`
window.open(url, '_blank')
}}
className="flex items-center justify-center gap-2 px-3 py-2 bg-[#25D366] hover:bg-[#128C7E] text-white rounded-lg text-xs font-bold transition-colors w-full sm:w-auto"
>
<Share2 className="w-3.5 h-3.5" />
WhatsApp
</button>
<button
onClick={() => {
navigator.clipboard.writeText(`${window.location.origin}/financial-report/${event.id}`)
alert('Link copiado!')
}}
className="flex items-center justify-center gap-2 px-3 py-2 bg-surface hover:bg-surface-raised border border-border text-foreground rounded-lg text-xs font-bold transition-colors w-full sm:w-auto"
>
<Copy className="w-3.5 h-3.5" />
Copiar Link
</button>
</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"
>
Ver Página Pública <ChevronRight className="w-3 h-3" />
</Link>
</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>
<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>
</motion.div>
)}
</AnimatePresence>
</motion.div>
)
})}
</AnimatePresence>
{filteredEvents.length === 0 && (
<div className="col-span-full py-20 text-center space-y-4">
<div className="w-16 h-16 bg-surface-raised border border-dashed border-border rounded-full flex items-center justify-center mx-auto opacity-40">
<Search className="w-6 h-6 text-muted" />
</div>
<div className="space-y-1">
<p className="text-sm font-bold uppercase tracking-widest">Nenhum Evento Encontrado</p>
<p className="text-xs text-muted">Tente ajustar sua busca ou mudar os filtros.</p>
</div>
</div>
)}
</div>
</div>
<CreateFinanceEventModal
isOpen={isCreateModalOpen}
onClose={() => setIsCreateModalOpen(false)}
players={players}
/>
<DeleteConfirmationModal
isOpen={deleteModal.isOpen}
onClose={() => setDeleteModal(prev => ({ ...prev, isOpen: false }))}
onConfirm={confirmDelete}
isDeleting={deleteModal.isDeleting}
title={deleteModal.title}
description={deleteModal.description}
confirmText="Sim, excluir agora"
/>
</div>
)
}

View File

@@ -0,0 +1,508 @@
'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 { createMatch, updateMatchStatus } from '@/actions/match'
import { getMatchWithAttendance } from '@/actions/attendance'
import { toPng } from 'html-to-image'
import { clsx } from 'clsx'
import { useSearchParams } from 'next/navigation'
import type { Arena } from '@prisma/client'
interface MatchFlowProps {
group: any
arenas?: Arena[]
}
const getInitials = (name: string) => {
return name
.split(' ')
.filter(n => n.length > 0)
.map(n => n[0])
.join('')
.toUpperCase()
.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++) {
hash = ((hash << 5) - hash) + seed.charCodeAt(i)
hash |= 0
}
return () => {
hash = (hash * 16807) % 2147483647
return (hash - 1) / 2147483646
}
}
export function MatchFlow({ group, arenas = [] }: MatchFlowProps) {
const searchParams = useSearchParams()
const scheduledMatchId = searchParams.get('id')
const TEAM_COLORS = ['#10b981', '#3b82f6', '#ef4444', '#f59e0b', '#8b5cf6', '#06b6d4']
const [step, setStep] = useState(1)
const [drawMode, setDrawMode] = useState<'random' | 'balanced'>('balanced')
const [activeMatchId, setActiveMatchId] = useState<string | null>(null)
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 [isSaving, setIsSaving] = useState(false)
const [drawSeed, setDrawSeed] = useState('')
const [location, setLocation] = useState('')
const [selectedArenaId, setSelectedArenaId] = useState('')
useEffect(() => {
if (scheduledMatchId) {
loadScheduledData(scheduledMatchId)
}
// Generate a random seed on mount
generateNewSeed()
}, [scheduledMatchId])
const loadScheduledData = async (id: string) => {
try {
const data = await getMatchWithAttendance(id)
if (data) {
const confirmedIds = data.attendances
.filter((a: any) => a.status === 'CONFIRMED')
.map((a: any) => a.playerId)
setSelectedPlayers(confirmedIds)
setMatchDate(new Date(data.date).toISOString().split('T')[0])
setLocation(data.location || '')
if (data.arenaId) setSelectedArenaId(data.arenaId)
setActiveMatchId(data.id)
}
} catch (error) {
console.error('Erro ao carregar dados agendados:', error)
}
}
const generateNewSeed = () => {
setDrawSeed(Math.random().toString(36).substring(2, 8).toUpperCase())
}
const togglePlayer = (id: string) => {
setSelectedPlayers(prev =>
prev.includes(id) ? prev.filter(p => p !== id) : [...prev, id]
)
}
const performDraw = () => {
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}`,
players: [],
color: TEAM_COLORS[i % TEAM_COLORS.length]
}))
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
})
pool.forEach((p) => {
const teamWithLowestQuality = newTeams.reduce((prev, curr) => {
const prevQuality = prev.players.reduce((sum: number, player: any) => sum + player.level, 0)
const currQuality = curr.players.reduce((sum: number, player: any) => sum + player.level, 0)
return (prevQuality <= currQuality) ? prev : curr
})
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)
} catch (error) {
console.error(error)
} finally {
setIsSaving(false)
}
}
const handleFinish = async () => {
if (!activeMatchId) return
setIsSaving(true)
try {
await updateMatchStatus(activeMatchId, 'COMPLETED')
window.location.href = '/dashboard/matches'
} catch (error) {
console.error(error)
} finally {
setIsSaving(false)
}
}
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>
<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="lg:col-span-4 space-y-6">
<div className="ui-card p-6 space-y-6">
<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>
</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>
<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"
>
<RefreshCw className="w-5 h-5 text-muted" />
</button>
</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>
</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>
</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>
</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 */}
<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>
<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>
</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">
{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"
)}
>
<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>
</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>
</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>
</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>
</div>
))}
</div>
</motion.div>
)}
</AnimatePresence>
</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>
</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">
{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>
))}
</div>
</div>
)}
</div>
)
}

View File

@@ -0,0 +1,608 @@
'use client'
import React, { useState, useMemo } 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'
import Link from 'next/link'
import { deleteMatch, deleteMatches } from '@/actions/match'
import { cancelAttendance } from '@/actions/attendance'
import { DeleteConfirmationModal } from '@/components/DeleteConfirmationModal'
const getInitials = (name: string) => {
return name
.split(' ')
.filter(n => n.length > 0)
.map(n => n[0])
.join('')
.toUpperCase()
.slice(0, 2)
}
export function MatchHistory({ matches, players = [] }: { matches: any[], players?: any[] }) {
const [selectedMatch, setSelectedMatch] = useState<any | null>(null)
const [modalTab, setModalTab] = useState<'confirmed' | 'unconfirmed'>('confirmed')
// New States
const [searchQuery, setSearchQuery] = useState('')
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())
// Confirmation Modal State
const [deleteModal, setDeleteModal] = useState<{
isOpen: boolean
type: 'bulk' | 'match' | 'player' | null
data?: any
isProcessing: boolean
title: string
description: string
}>({
isOpen: false,
type: null,
isProcessing: false,
title: '',
description: ''
})
const itemsPerPage = 10
// Filter & Sort
const filteredMatches = useMemo(() => {
return matches
.filter((m: any) => {
const searchLower = searchQuery.toLowerCase()
return (
(m.location && m.location.toLowerCase().includes(searchLower)) ||
(m.status && m.status.toLowerCase().includes(searchLower)) ||
new Date(m.date).toLocaleDateString().includes(searchLower)
)
})
.sort((a, b) => new Date(b.date).getTime() - new Date(a.date).getTime())
}, [matches, searchQuery])
// Pagination
const totalPages = Math.ceil(filteredMatches.length / itemsPerPage)
const paginatedMatches = filteredMatches.slice(
(currentPage - 1) * itemsPerPage,
currentPage * itemsPerPage
)
// Selection Handlers
const toggleSelection = (id: string) => {
const newSelected = new Set(selectedIds)
if (newSelected.has(id)) newSelected.delete(id)
else newSelected.add(id)
setSelectedIds(newSelected)
}
const toggleSelectAll = () => {
if (selectedIds.size === paginatedMatches.length) {
setSelectedIds(new Set())
} else {
const newSelected = new Set<string>()
paginatedMatches.forEach((m: any) => newSelected.add(m.id))
setSelectedIds(newSelected)
}
}
// Modal Action Handlers
const handleBulkDelete = () => {
setDeleteModal({
isOpen: true,
type: 'bulk',
isProcessing: false,
title: `Excluir ${selectedIds.size} eventos?`,
description: 'Você tem certeza que deseja excluir os eventos selecionados? Esta ação é irreversível.'
})
}
const handleDeleteMatch = (id: string) => {
setDeleteModal({
isOpen: true,
type: 'match',
data: id,
isProcessing: false,
title: 'Excluir Evento?',
description: 'Você tem certeza que deseja excluir este evento? Todo o histórico de presença e times será perdido.'
})
}
const handleRemovePlayer = (matchId: string, playerId: string) => {
setDeleteModal({
isOpen: true,
type: 'player',
data: { matchId, playerId },
isProcessing: false,
title: 'Remover Atleta?',
description: 'Deseja remover este atleta da lista de presença? Ele voltará para a lista de pendentes.'
})
}
const executeDeleteAction = async () => {
setDeleteModal(prev => ({ ...prev, isProcessing: true }))
try {
if (deleteModal.type === 'bulk') {
await deleteMatches(Array.from(selectedIds))
setSelectedIds(new Set())
} else if (deleteModal.type === 'match') {
await deleteMatch(deleteModal.data)
setSelectedMatch(null)
} else if (deleteModal.type === 'player') {
await cancelAttendance(deleteModal.data.matchId, deleteModal.data.playerId)
}
setDeleteModal(prev => ({ ...prev, isOpen: false }))
} catch (error) {
console.error(error)
alert('Ocorreu um erro ao processar a ação.')
} finally {
setDeleteModal(prev => ({ ...prev, isProcessing: false }))
}
}
const getStatusInfo = (status: string) => {
switch (status) {
case 'SCHEDULED': return { label: 'Agendado', color: 'bg-blue-500/10 text-blue-500 border-blue-500/20' }
case 'IN_PROGRESS': return { label: 'Em Andamento', color: 'bg-primary/10 text-primary border-primary/20' }
case 'COMPLETED': return { label: 'Concluído', color: 'bg-white/5 text-muted border-white/10' }
default: return { label: status, color: 'bg-white/5 text-muted border-white/10' }
}
}
const shareMatchLink = (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}`
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 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` +
`✅ *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}`
navigator.clipboard.writeText(text)
const whatsappUrl = `https://api.whatsapp.com/send?text=${encodeURIComponent(text)}`
window.open(whatsappUrl, '_blank')
}
return (
<div className="space-y-6">
<div className="flex flex-col lg:flex-row lg:items-center justify-between gap-6">
<div className="space-y-1">
<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>
<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>
<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"
>
<Trash2 className="w-4 h-4 mr-2" /> Excluir
</button>
</div>
) : (
<>
<div className="relative group w-full sm:w-64">
<Search 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
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
placeholder="Buscar eventos..."
className="ui-input w-full pl-10 h-10 bg-surface-raised border-border/50 text-sm"
/>
</div>
<div className="flex p-1 bg-surface-raised border border-border rounded-lg w-full sm:w-auto">
<button
onClick={() => setViewMode('grid')}
className={clsx("p-2 rounded-md transition-all", viewMode === 'grid' ? "bg-white/10 text-primary shadow-sm" : "text-muted")}
>
<LayoutGrid className="w-4 h-4" />
</button>
<button
onClick={() => setViewMode('list')}
className={clsx("p-2 rounded-md transition-all", viewMode === 'list' ? "bg-white/10 text-primary shadow-sm" : "text-muted")}
>
<List className="w-4 h-4" />
</button>
</div>
</>
)}
</div>
</div>
<div className={clsx(
"grid gap-3 transition-all duration-500",
viewMode === 'grid' ? "grid-cols-1 md:grid-cols-2 lg:grid-cols-3" : "grid-cols-1"
)}>
<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>
</div>
)}
{paginatedMatches.map((match: any) => {
const s = getStatusInfo(match.status)
return (
<motion.div
layout
key={match.id}
initial={{ opacity: 0, scale: 0.98 }}
animate={{ opacity: 1, scale: 1 }}
exit={{ opacity: 0, scale: 0.98 }}
onClick={() => setSelectedMatch(match)}
className={clsx(
"ui-card ui-card-hover relative group cursor-pointer border-l-4",
viewMode === 'list' ? "p-4 flex items-center gap-4" : "p-5 flex flex-col gap-4",
selectedIds.has(match.id) ? "border-primary bg-primary/5" : "border-transparent"
)}
>
{/* 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>
<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-[10px] text-muted uppercase font-medium mt-1">
{new Date(match.date).toLocaleDateString('pt-BR', { month: 'short' }).replace('.', '')}
</p>
</div>
<div className="space-y-1 min-w-0 flex-1">
<div className="flex items-center gap-2 flex-wrap">
<p className="text-sm font-semibold truncate">
{match.status === 'SCHEDULED' ? `Evento: ${match.location || 'Sem local'}` : `Sorteio de ${match.teams.length} times`}
</p>
{match.isRecurring && (
<span className="badge bg-purple-500/10 text-purple-500 border-purple-500/20 px-1.5 py-0.5" title="Recorrente">
<Repeat className="w-3 h-3" />
</span>
)}
</div>
<div className="flex gap-1.5 flex-wrap">
<span className={clsx("badge", s.color)}>{s.label}</span>
</div>
</div>
</div>
<div className={clsx("flex flex-wrap items-center gap-3 text-[11px] text-muted", viewMode === 'list' ? "" : "border-t border-border/50 pt-4 mt-auto")}>
<div className="flex items-center gap-1">
<Users className="w-3 h-3" />
{match.status === 'SCHEDULED'
? `${(match.attendances || []).filter((a: any) => a.status === 'CONFIRMED').length} confirmados`
: `${(match.teams || []).reduce((acc: number, t: any) => acc + t.players.length, 0)} jogadores`}
</div>
<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' })}
</div>
{/* Actions only in List Mode here, else in modal */}
{viewMode === 'list' && (
<div className="ml-auto flex items-center gap-2 opacity-0 group-hover:opacity-100 transition-opacity">
{match.status === 'SCHEDULED' && (
<button
onClick={(e) => {
e.stopPropagation()
shareWhatsAppList(match)
}}
className="p-1.5 text-primary hover:bg-primary/10 rounded transition-colors"
title="Copiar Link"
>
<Share2 className="w-4 h-4" />
</button>
)}
<ChevronRight className="w-4 h-4" />
</div>
)}
</div>
</motion.div>
)
})}
</AnimatePresence>
{paginatedMatches.length === 0 && (
<div className="col-span-full ui-card p-20 text-center border-dashed">
<Calendar className="w-8 h-8 text-muted mx-auto mb-4" />
<p className="text-sm font-bold uppercase tracking-widest text-muted">Nenhuma partida encontrada</p>
</div>
)}
</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>
)}
{/* Modal Overlay */}
<AnimatePresence>
{selectedMatch && (
<div className="fixed inset-0 z-[100] flex items-center justify-center p-4">
<motion.div
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
exit={{ opacity: 0 }}
onClick={() => setSelectedMatch(null)}
className="absolute inset-0 bg-background/80 backdrop-blur-sm"
/>
<motion.div
initial={{ opacity: 0, scale: 0.98, y: 10 }}
animate={{ opacity: 1, scale: 1, y: 0 }}
exit={{ opacity: 0, scale: 0.98, y: 10 }}
className="ui-card w-full max-w-2xl overflow-hidden relative z-10 shadow-2xl flex flex-col max-h-[90vh]"
>
<div className="border-b border-border p-6 flex items-center justify-between bg-surface-raised/50 shrink-0">
<div>
<div className="flex items-center gap-3 mb-1">
<h3 className="text-lg font-bold tracking-tight">Resumo do Evento</h3>
<span className={clsx("badge", getStatusInfo(selectedMatch.status).color)}>
{getStatusInfo(selectedMatch.status).label}
</span>
</div>
<p className="text-sm text-muted">
{new Date(selectedMatch.date).toLocaleDateString('pt-BR', {
weekday: 'long',
day: 'numeric',
month: 'long',
year: 'numeric'
})}
</p>
</div>
<div className="flex items-center gap-2">
<button
onClick={() => handleDeleteMatch(selectedMatch.id)}
className="p-2.5 text-muted hover:text-red-500 transition-colors rounded-lg"
title="Excluir esse evento"
>
<Trash2 className="w-5 h-5" />
</button>
<button
onClick={() => setSelectedMatch(null)}
className="p-2.5 hover:bg-surface-raised rounded-lg transition-colors"
>
<X className="w-5 h-5" />
</button>
</div>
</div>
<div className="p-6 overflow-y-auto custom-scrollbar">
{selectedMatch.status === 'SCHEDULED' ? (
<div className="space-y-6">
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
<div className="ui-card p-4 bg-surface-raised/30">
<p className="text-label mb-2">Local/Arena</p>
<div className="flex items-center gap-2 text-sm font-semibold">
<MapPin className="w-4 h-4 text-primary" />
{selectedMatch.location || 'Não definido'}
</div>
</div>
<div className="ui-card p-4 bg-surface-raised/30">
<p className="text-label mb-2">Limite de Vagas</p>
<div className="flex items-center gap-2 text-sm font-semibold">
<Users className="w-4 h-4 text-primary" />
{selectedMatch.attendances?.filter((a: any) => a.status === 'CONFIRMED').length || 0} / {selectedMatch.maxPlayers || '∞'} Atletas
</div>
</div>
</div>
<div className="space-y-4">
<div className="flex border-b border-border overflow-x-auto">
<button
onClick={() => setModalTab('confirmed')}
className={clsx(
"px-4 py-3 text-xs font-bold uppercase tracking-widest border-b-2 transition-all whitespace-nowrap",
modalTab === 'confirmed' ? "border-primary text-primary" : "border-transparent text-muted hover:text-foreground"
)}
>
Confirmados ({selectedMatch.attendances?.filter((a: any) => a.status === 'CONFIRMED').length || 0})
</button>
<button
onClick={() => setModalTab('unconfirmed')}
className={clsx(
"px-4 py-3 text-xs font-bold uppercase tracking-widest border-b-2 transition-all whitespace-nowrap",
modalTab === 'unconfirmed' ? "border-primary text-primary" : "border-transparent text-muted hover:text-foreground"
)}
>
Pendentes ({
players.filter(p => !selectedMatch.attendances?.find((a: any) => a.playerId === p.id && a.status === 'CONFIRMED')).length || 0
})
</button>
</div>
<div className="grid grid-cols-1 sm:grid-cols-2 gap-2 max-h-[300px] overflow-y-auto pr-2 custom-scrollbar">
{modalTab === 'confirmed' ? (
(selectedMatch.attendances || []).filter((a: any) => a.status === 'CONFIRMED').map((a: any) => (
<div key={a.id} className="flex items-center justify-between p-3 rounded-xl bg-surface border border-border group/at hover:border-primary/30 transition-all">
<div className="flex items-center gap-3">
<div className="w-9 h-9 rounded-lg bg-primary/10 flex items-center justify-center text-xs text-primary font-bold">
{getInitials(a.player.name)}
</div>
<span className="text-xs font-bold tracking-tight uppercase truncate max-w-[120px]">{a.player.name}</span>
</div>
<div className="flex items-center gap-2">
<span className="text-[10px] uppercase font-bold text-primary group-hover/at:hidden bg-primary/5 px-2 py-0.5 rounded">OK</span>
<button
onClick={() => handleRemovePlayer(selectedMatch.id, a.playerId)}
className="hidden group-hover/at:flex p-2 text-red-500 hover:bg-red-500/10 rounded-lg transition-all"
title="Remover Atleta"
>
<Trash2 className="w-4 h-4" />
</button>
</div>
</div>
))
) : (
players.filter(p => !selectedMatch.attendances?.find((a: any) => a.playerId === p.id && a.status === 'CONFIRMED')).map((p: any) => (
<div key={p.id} className="flex items-center justify-between p-3 rounded-xl bg-surface/50 border border-border/50 opacity-70">
<div className="flex items-center gap-3">
<div className="w-9 h-9 rounded-lg bg-surface-raised border border-border flex items-center justify-center text-xs text-muted font-bold">
{getInitials(p.name)}
</div>
<span className="text-xs font-medium uppercase tracking-tight text-muted truncate max-w-[120px]">{p.name}</span>
</div>
<span className="text-[10px] font-bold text-muted uppercase tracking-wider">Pendente</span>
</div>
))
)}
{modalTab === 'confirmed' && (selectedMatch.attendances || []).filter((a: any) => a.status === 'CONFIRMED').length === 0 && (
<div className="col-span-full py-10 text-center opacity-40">
<p className="text-xs font-bold uppercase tracking-widest">Nenhuma confirmação ainda</p>
</div>
)}
</div>
</div>
<div className="pt-4">
<Link
href={`/dashboard/matches/new?id=${selectedMatch.id}`}
className="ui-button w-full h-12 text-sm font-bold"
>
<Shuffle className="w-4 h-4 mr-2" /> Realizar Sorteio Agora
</Link>
</div>
</div>
) : (
<div className="space-y-6">
<div className="flex items-center justify-between">
<p className="text-xs font-bold text-muted uppercase tracking-widest">
Transparência: <span className="text-primary font-mono">{selectedMatch.drawSeed || 'TRANS-1'}</span>
</p>
</div>
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
{selectedMatch.teams?.map((team: any, i: number) => (
<div key={i} className="ui-card p-4 border-l-4" style={{ borderLeftColor: team.color }}>
<div className="flex items-center justify-between mb-4">
<h4 className="font-bold text-sm uppercase tracking-tight">{team.name}</h4>
<span className="badge badge-muted">{team.players.length} atletas</span>
</div>
<div className="space-y-2">
{team.players.map((p: any, j: number) => (
<div key={j} className="flex items-center justify-between text-xs py-2 border-b border-border/50 last:border-0">
<div className="flex items-center gap-3">
<span className="w-8 font-mono text-primary font-bold text-center">
{p.player.number !== null && p.player.number !== undefined ? p.player.number : getInitials(p.player.name)}
</span>
<div className="flex flex-col">
<span className="font-medium text-sm">{p.player.name}</span>
<div className="flex gap-0.5 mt-0.5">
{[...Array(5)].map((_, i) => (
<Star key={i} className={clsx(
"w-2.5 h-2.5",
i < p.player.level ? "text-primary fill-primary" : "text-border fill-transparent"
)} />
))}
</div>
</div>
</div>
<span className="text-[10px] text-muted font-bold uppercase p-1 bg-surface-raised rounded">{p.player.position}</span>
</div>
))}
</div>
</div>
))}
</div>
</div>
)}
</div>
<div className="border-t border-border p-4 flex flex-col sm:flex-row justify-between gap-3 bg-surface-raised/30 shrink-0">
<button
onClick={() => setSelectedMatch(null)}
className="ui-button-ghost py-3 order-3 sm:order-1 h-12 sm:h-auto"
>
Fechar
</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"
>
<MessageCircle className="w-4 h-4 mr-2" /> Copiar Lista
</button>
<button
onClick={() => shareMatchLink(selectedMatch)}
className="ui-button py-3 h-12 sm:h-auto"
>
<LinkIcon className="w-4 h-4 mr-2" /> Compartilhar Link
</button>
</div>
</div>
</motion.div>
</div>
)
}
</AnimatePresence >
<DeleteConfirmationModal
isOpen={deleteModal.isOpen}
onClose={() => setDeleteModal(prev => ({ ...prev, isOpen: false }))}
onConfirm={executeDeleteAction}
isDeleting={deleteModal.isProcessing}
title={deleteModal.title}
description={deleteModal.description}
confirmText='Sim, confirmar'
/>
</div >
)
}

View File

@@ -0,0 +1,148 @@
'use client'
import { useState, useEffect } from 'react'
import Link from 'next/link'
import { usePathname } from 'next/navigation'
import { Menu, X, Trophy, Home, Calendar, Users, Settings, LogOut, ChevronRight } from 'lucide-react'
import { motion, AnimatePresence } from 'framer-motion'
import { clsx } from 'clsx'
export function MobileNav({ group }: { group: any }) {
const [isOpen, setIsOpen] = useState(false)
const pathname = usePathname()
// Prevent scrolling when menu is open
useEffect(() => {
if (isOpen) {
document.body.style.overflow = 'hidden'
} else {
document.body.style.overflow = 'unset'
}
return () => {
document.body.style.overflow = 'unset'
}
}, [isOpen])
const navItems = [
{ name: 'Início', href: '/dashboard', icon: Home },
{ name: 'Partidas', href: '/dashboard/matches', icon: Calendar },
{ name: 'Jogadores', href: '/dashboard/players', icon: Users },
{ name: 'Configurações', href: '/dashboard/settings', icon: Settings },
]
return (
<div className="md:hidden sticky top-0 z-50">
{/* Top Bar - Glass Effect */}
<div className="bg-background/80 backdrop-blur-md border-b border-white/5 h-16 px-4 flex items-center justify-between relative z-50">
<Link href="/dashboard" className="flex items-center gap-3">
<div className="w-9 h-9 bg-primary/10 rounded-xl flex items-center justify-center border border-primary/20 shadow-inner">
<Trophy className="w-5 h-5 text-primary" />
</div>
<span className="font-bold tracking-tight text-xl">TEMFUT</span>
</Link>
<button
onClick={() => setIsOpen(true)}
className="p-2 -mr-2 text-muted hover:text-foreground active:scale-95 transition-all rounded-lg hover:bg-white/5"
>
<Menu className="w-7 h-7" />
</button>
</div>
<AnimatePresence>
{isOpen && (
<>
{/* Backdrop */}
<motion.div
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
exit={{ opacity: 0 }}
onClick={() => setIsOpen(false)}
className="fixed inset-0 bg-black/90 backdrop-blur-sm z-50"
/>
{/* Drawer */}
<motion.div
initial={{ x: '100%' }}
animate={{ x: 0 }}
exit={{ x: '100%' }}
transition={{ type: 'spring', damping: 30, stiffness: 300 }}
className="fixed inset-y-0 right-0 w-full max-w-[320px] bg-surface-raised border-l border-white/10 z-[60] flex flex-col shadow-2xl"
>
{/* Drawer Header */}
<div className="p-6 h-24 flex items-start justify-between bg-surface border-b border-white/5 relative overflow-hidden">
{/* Decor */}
<div className="absolute top-0 right-0 w-32 h-32 bg-primary/5 blur-[50px] rounded-full -mr-10 -mt-10 pointer-events-none" />
<div className="flex items-center gap-4 relative z-10 w-full pr-10">
<div className="w-12 h-12 rounded-full bg-surface-raised border border-white/10 flex items-center justify-center overflow-hidden shadow-lg shrink-0">
{group.logoUrl ? (
<img src={group.logoUrl} alt="" className="w-full h-full object-cover" />
) : (
<span className="text-sm font-bold">{group.name.charAt(0)}</span>
)}
</div>
<div className="min-w-0">
<h3 className="font-bold text-lg leading-tight truncate">{group.name}</h3>
<p className="text-[10px] text-muted font-bold uppercase tracking-widest mt-1">Plano Free</p>
</div>
</div>
<button
onClick={() => setIsOpen(false)}
className="absolute top-6 right-4 p-2 text-muted hover:text-foreground rounded-full hover:bg-white/10 transition-colors"
>
<X className="w-6 h-6" />
</button>
</div>
{/* Nav Items */}
<nav className="flex-1 p-6 space-y-2 overflow-y-auto">
<p className="text-[10px] font-bold text-muted uppercase tracking-[0.2em] px-4 mb-4">Menu Principal</p>
{navItems.map((item) => {
const isActive = pathname === item.href
return (
<Link
key={item.href}
href={item.href}
onClick={() => setIsOpen(false)}
className={clsx(
"flex items-center gap-4 px-4 py-4 rounded-xl text-base font-medium transition-all duration-200 group relative overflow-hidden",
isActive
? "bg-primary text-background shadow-lg shadow-primary/20"
: "text-muted hover:text-foreground hover:bg-surface border border-transparent hover:border-white/5"
)}
>
<item.icon className={clsx("w-5 h-5", isActive ? "text-background" : "text-muted group-hover:text-primary transition-colors")} />
<span className="flex-1">{item.name}</span>
{!isActive && <ChevronRight className="w-4 h-4 text-white/20 group-hover:text-white/50" />}
</Link>
)
})}
<div className="pt-8 mt-8 border-t border-white/5">
<p className="text-[10px] font-bold text-muted uppercase tracking-[0.2em] px-4 mb-4">Geral</p>
<button
onClick={async () => {
await fetch('/api/auth/logout', { method: 'POST' })
window.location.href = '/'
}}
className="flex items-center gap-4 w-full px-4 py-4 text-base font-medium text-red-400 hover:bg-red-500/10 rounded-xl transition-all group"
>
<LogOut className="w-5 h-5 opacity-70 group-hover:opacity-100" />
<span>Sair da Conta</span>
</button>
</div>
</nav>
{/* Footer Decor */}
<div className="p-6 bg-surface border-t border-white/5 text-center">
<p className="text-[10px] font-medium text-muted/50">TemFut v1.0.0 &copy; 2024</p>
</div>
</motion.div>
</>
)}
</AnimatePresence>
</div>
)
}

View File

@@ -0,0 +1,106 @@
'use client'
import { useState } from 'react'
import { motion } from 'framer-motion'
import { Users, Mail, Lock, LogIn, AlertCircle, Eye, EyeOff } from 'lucide-react'
import { loginPeladaAction } from '@/app/actions/auth'
interface Props {
slug: string
groupName?: string
}
export default function PeladaLoginPage({ slug, groupName }: Props) {
const [error, setError] = useState('')
const [loading, setLoading] = useState(false)
const [showPassword, setShowPassword] = useState(false)
async function handleForm(formData: FormData) {
setLoading(true)
setError('')
const result = await loginPeladaAction(formData)
if (result?.error) {
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>
{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>
</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>
</div>
</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>
)
}

View File

@@ -0,0 +1,607 @@
'use client'
import React, { useState, useMemo } from 'react'
import { Plus, Trash2, UserPlus, Star, Search, Filter, MoreHorizontal, User, Shield, Target, Zap, ChevronDown, LayoutGrid, List, ChevronRight, Check, X, AlertCircle } from 'lucide-react'
import { addPlayer, deletePlayer, deletePlayers } from '@/actions/player'
import { motion, AnimatePresence } from 'framer-motion'
import { clsx } from 'clsx'
import { DeleteConfirmationModal } from '@/components/DeleteConfirmationModal'
export function PlayersList({ group }: { group: any }) {
const [newPlayerName, setNewPlayerName] = useState('')
const [level, setLevel] = useState(3)
const [number, setNumber] = useState<string>('')
const [position, setPosition] = useState<'DEF' | 'MEI' | 'ATA'>('MEI')
const [searchQuery, setSearchQuery] = useState('')
const [activeTab, setActiveTab] = useState<'ALL' | 'DEF' | 'MEI' | 'ATA'>('ALL')
const [viewMode, setViewMode] = useState<'grid' | 'list'>('grid')
const [error, setError] = useState<string | null>(null)
const [isAdding, setIsAdding] = useState(false)
const [isFormOpen, setIsFormOpen] = useState(false)
// Pagination & Selection
const [currentPage, setCurrentPage] = useState(1)
const [selectedIds, setSelectedIds] = useState<Set<string>>(new Set())
const itemsPerPage = 12
// Confirmation Modal State
const [deleteModal, setDeleteModal] = useState<{
isOpen: boolean
type: 'single' | 'bulk' | null
playerId?: string
isDeleting: boolean
title: string
description: string
}>({
isOpen: false,
type: null,
isDeleting: false,
title: '',
description: ''
})
const handleAddPlayer = async (e: React.FormEvent) => {
e.preventDefault()
setError(null)
if (!newPlayerName) return
setIsAdding(true)
try {
const playerNumber = number.trim() === '' ? null : parseInt(number)
await addPlayer(group.id, newPlayerName, level, playerNumber, position)
setNewPlayerName('')
setLevel(3)
setNumber('')
setPosition('MEI')
setIsFormOpen(false)
} catch (err: any) {
setError(err.message)
} finally {
setIsAdding(false)
}
}
const filteredPlayers = useMemo(() => {
return group.players.filter((p: any) => {
const matchesSearch = p.name.toLowerCase().includes(searchQuery.toLowerCase()) ||
p.number?.toString().includes(searchQuery) ||
p.position.toLowerCase().includes(searchQuery.toLowerCase())
const matchesTab = activeTab === 'ALL' || p.position === activeTab
return matchesSearch && matchesTab
}).sort((a: any, b: any) => {
if (a.number && b.number) return a.number - b.number
if (!a.number && b.number) return 1
if (a.number && !b.number) return -1
return a.name.localeCompare(b.name)
})
}, [group.players, searchQuery, activeTab])
const totalPages = Math.ceil(filteredPlayers.length / itemsPerPage)
const paginatedPlayers = filteredPlayers.slice(
(currentPage - 1) * itemsPerPage,
currentPage * itemsPerPage
)
useMemo(() => {
setCurrentPage(1)
setSelectedIds(new Set())
}, [searchQuery, activeTab])
const toggleSelection = (id: string) => {
const newSelected = new Set(selectedIds)
if (newSelected.has(id)) newSelected.delete(id)
else newSelected.add(id)
setSelectedIds(newSelected)
}
const toggleSelectAll = () => {
if (selectedIds.size === paginatedPlayers.length) {
setSelectedIds(new Set())
} else {
const newSelected = new Set<string>()
paginatedPlayers.forEach((p: any) => newSelected.add(p.id))
setSelectedIds(newSelected)
}
}
const handleBulkDelete = () => {
setDeleteModal({
isOpen: true,
type: 'bulk',
isDeleting: false,
title: `Excluir ${selectedIds.size} atletas?`,
description: 'Você tem certeza que deseja excluir os atletas selecionados? Esta ação não pode ser desfeita.'
})
}
const handleSingleDelete = (id: string, name: string) => {
setDeleteModal({
isOpen: true,
type: 'single',
playerId: id,
isDeleting: false,
title: `Excluir ${name}?`,
description: `Deseja realmente excluir o atleta ${name} do elenco?`
})
}
const executeDelete = async () => {
setDeleteModal(prev => ({ ...prev, isDeleting: true }))
try {
if (deleteModal.type === 'bulk') {
await deletePlayers(Array.from(selectedIds))
setSelectedIds(new Set())
} else if (deleteModal.type === 'single' && deleteModal.playerId) {
await deletePlayer(deleteModal.playerId)
}
setDeleteModal(prev => ({ ...prev, isOpen: false }))
} catch (err) {
console.error(err)
alert('Erro ao excluir atleta(s).')
} finally {
setDeleteModal(prev => ({ ...prev, isDeleting: false }))
}
}
const getInitials = (name: string) => {
return name
.split(' ')
.filter(n => n.length > 0)
.map(n => n[0])
.join('')
.toUpperCase()
.slice(0, 2)
}
const getLevelInfo = (lvl: number) => {
if (lvl >= 5) return { label: 'Elite', color: 'text-emerald-500' }
if (lvl >= 4) return { label: 'Pro', color: 'text-blue-500' }
if (lvl >= 3) return { label: 'Regular', color: 'text-primary' }
return { label: 'Amador', color: 'text-muted' }
}
return (
<div className="space-y-8 pb-20">
<AnimatePresence>
{!isFormOpen && (
<motion.div
initial={{ opacity: 0, y: -20 }}
animate={{ opacity: 1, y: 0 }}
exit={{ opacity: 0, y: -20 }}
className="flex justify-end"
>
<button
onClick={() => {
setError(null)
setIsFormOpen(true)
}}
className="ui-button w-full sm:w-auto shadow-lg shadow-primary/20"
>
<UserPlus className="w-5 h-5 mr-2" />
Registrar Novo Atleta
</button>
</motion.div>
)}
</AnimatePresence>
<AnimatePresence>
{isFormOpen && (
<div className="fixed inset-0 z-50 flex items-center justify-center p-4">
<motion.div
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
exit={{ opacity: 0 }}
onClick={() => setIsFormOpen(false)}
className="absolute inset-0 bg-background/80 backdrop-blur-sm"
/>
<motion.div
initial={{ opacity: 0, scale: 0.95, y: 20 }}
animate={{ opacity: 1, scale: 1, y: 0 }}
exit={{ opacity: 0, scale: 0.95, y: 20 }}
className="ui-card w-full max-w-lg overflow-hidden relative z-10 shadow-2xl border-primary/20"
>
<div className="p-6 bg-surface-raised/50 border-b border-border flex items-center justify-between">
<div className="flex items-center gap-4">
<div className="w-12 h-12 bg-primary/10 rounded-xl flex items-center justify-center border border-primary/20 shadow-inner">
<UserPlus className="w-6 h-6 text-primary" />
</div>
<div>
<h3 className="text-lg font-bold tracking-tight">Novo Atleta</h3>
<p className="text-xs text-muted font-medium uppercase tracking-wider">Adicionar ao elenco</p>
</div>
</div>
<button
onClick={() => setIsFormOpen(false)}
className="p-2 text-muted hover:text-foreground rounded-lg transition-colors"
>
<X className="w-5 h-5" />
</button>
</div>
<form onSubmit={handleAddPlayer} className="p-6 space-y-6">
<div className="space-y-4">
<div className="flex items-center gap-2">
<User className="w-4 h-4 text-primary" />
<span className="text-xs font-bold text-muted uppercase tracking-widest">Identificação</span>
</div>
<div className="flex gap-4">
<div className="flex-1 ui-form-field">
<input
value={newPlayerName}
onChange={(e) => {
setNewPlayerName(e.target.value)
setError(null)
}}
placeholder="Nome do Atleta"
className="ui-input w-full h-12 font-medium"
autoFocus
required
/>
</div>
<div className="w-24 ui-form-field">
<input
type="number"
value={number}
onChange={(e) => {
setNumber(e.target.value)
setError(null)
}}
placeholder="Nº"
className="ui-input w-full h-12 text-center font-mono font-bold"
/>
</div>
</div>
</div>
<div className="space-y-4">
<div className="flex items-center gap-2">
<Target className="w-4 h-4 text-primary" />
<span className="text-xs font-bold text-muted uppercase tracking-widest">Posição em Campo</span>
</div>
<div className="grid grid-cols-3 gap-3">
{[
{ id: 'DEF', icon: Shield, label: 'Zagueiro', color: 'bg-blue-500' },
{ id: 'MEI', icon: Zap, label: 'Meio-Campo', color: 'bg-emerald-500' },
{ id: 'ATA', icon: Target, label: 'Atacante', color: 'bg-orange-500' },
].map((pos) => {
const isSelected = position === pos.id
return (
<button
key={pos.id}
type="button"
onClick={() => setPosition(pos.id as any)}
className={clsx(
"relative h-24 rounded-xl border-2 flex flex-col items-center justify-center gap-2 transition-all duration-200 overflow-hidden group",
isSelected
? "border-primary bg-primary/5"
: "border-border bg-surface-raised hover:border-primary/50"
)}
>
{isSelected && (
<div className={clsx("absolute top-0 right-0 p-1 rounded-bl-lg text-white", pos.color)}>
<div className="w-1.5 h-1.5 rounded-full bg-white animate-pulse" />
</div>
)}
<pos.icon className={clsx("w-6 h-6 mb-1 transition-colors", isSelected ? "text-primary" : "text-muted group-hover:text-foreground")} />
<span className={clsx("text-[10px] font-black uppercase tracking-widest", isSelected ? "text-foreground" : "text-muted")}>
{pos.label}
</span>
</button>
)
})}
</div>
</div>
<div className="space-y-4">
<div className="flex items-center gap-2">
<Star className="w-4 h-4 text-primary" />
<span className="text-xs font-bold text-muted uppercase tracking-widest">Nível Técnico ({getLevelInfo(level).label})</span>
</div>
<div className="bg-surface-raised p-4 rounded-xl border border-border flex justify-between items-center group hover:border-primary/30 transition-colors">
{[1, 2, 3, 4, 5].map((star) => (
<button
key={star}
type="button"
onClick={() => setLevel(star)}
className="p-2 transition-all hover:scale-125 focus:outline-none"
>
<Star
className={clsx(
"w-8 h-8 transition-all duration-300",
star <= level
? "text-primary fill-primary drop-shadow-[0_0_10px_rgba(16,185,129,0.4)]"
: "text-border fill-transparent group-hover:text-muted"
)}
/>
</button>
))}
</div>
</div>
<AnimatePresence>
{error && (
<motion.div
initial={{ opacity: 0, y: 10 }}
animate={{ opacity: 1, y: 0 }}
exit={{ opacity: 0 }}
className="p-4 rounded-xl bg-red-500/10 border border-red-500/20 text-red-500 flex items-center gap-3"
>
<AlertCircle className="w-5 h-5 shrink-0" />
<p className="text-sm font-bold uppercase tracking-tight">{error}</p>
</motion.div>
)}
</AnimatePresence>
<div className="pt-4 flex gap-3">
<button
type="button"
onClick={() => setIsFormOpen(false)}
className="ui-button-ghost flex-1 h-12"
>
Cancelar
</button>
<button
type="submit"
disabled={isAdding || !newPlayerName}
className="ui-button flex-[2] h-12 shadow-xl shadow-primary/10"
>
{isAdding ? <div className="w-5 h-5 border-2 border-background/30 border-t-background rounded-full animate-spin" /> : 'Confirmar Cadastro'}
</button>
</div>
</form>
</motion.div>
</div>
)}
</AnimatePresence>
<div className="space-y-6">
<div className="flex flex-col lg:flex-row lg:items-center justify-between gap-6">
<div className="space-y-1">
<h2 className="text-xl font-bold tracking-tight">Elenco do Grupo</h2>
<p className="text-xs text-muted font-medium">Gerencie e visualize todos os atletas registrados abaixo.</p>
</div>
<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>
<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"
>
<Trash2 className="w-4 h-4 mr-2" /> Excluir
</button>
</div>
) : (
<>
<div className="relative group w-full sm:w-64">
<Search 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
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
placeholder="Buscar no elenco..."
className="ui-input w-full pl-10 h-10 bg-surface-raised border-border/50 text-sm"
/>
</div>
<div className="flex p-1 bg-surface-raised border border-border rounded-lg w-full sm:w-auto">
<button
onClick={() => setViewMode('grid')}
className={clsx("p-2 rounded-md transition-all", viewMode === 'grid' ? "bg-white/10 text-primary shadow-sm" : "text-muted")}
>
<LayoutGrid className="w-4 h-4" />
</button>
<button
onClick={() => setViewMode('list')}
className={clsx("p-2 rounded-md transition-all", viewMode === 'list' ? "bg-white/10 text-primary shadow-sm" : "text-muted")}
>
<List className="w-4 h-4" />
</button>
</div>
</>
)}
</div>
</div>
<div className="flex items-center gap-2 overflow-x-auto pb-2 scrollbar-none border-b border-border/50">
{['ALL', 'DEF', 'MEI', 'ATA'].map((tab) => (
<button
key={tab}
onClick={() => setActiveTab(tab as any)}
className={clsx(
"relative px-6 py-2.5 text-[10px] font-bold uppercase tracking-[0.2em] transition-all",
activeTab === tab ? "text-primary" : "text-muted hover:text-foreground"
)}
>
{tab === 'ALL' ? 'Todos' : tab}
{activeTab === tab && (
<motion.div layoutId="activeTab" className="absolute bottom-0 left-0 right-0 h-0.5 bg-primary" />
)}
</button>
))}
<div className="ml-auto text-[10px] font-bold text-muted bg-surface-raised px-3 py-1 rounded-full border border-border">
{filteredPlayers.length} ATLETAS
</div>
</div>
<div className={clsx(
"grid gap-4 transition-all duration-500",
viewMode === 'grid' ? "grid-cols-1 md:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4" : "grid-cols-1"
)}>
<AnimatePresence mode='popLayout'>
{paginatedPlayers.length > 0 && (
<div className={clsx("col-span-full flex items-center px-2 mb-2", viewMode === 'grid' ? "justify-end" : "")}>
<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 === paginatedPlayers.length && paginatedPlayers.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 === paginatedPlayers.length && paginatedPlayers.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 === paginatedPlayers.length ? "text-primary" : "text-muted group-hover:text-foreground"
)}>
Selecionar Todos
</span>
</button>
</div>
)}
{paginatedPlayers.map((p: any) => (
<motion.div
key={p.id}
layout
initial={{ opacity: 0, scale: 0.95 }}
animate={{ opacity: 1, scale: 1 }}
exit={{ opacity: 0, scale: 0.95 }}
className={clsx(
"ui-card group relative hover:border-primary/40 transition-all duration-500 overflow-hidden",
viewMode === 'list' ? "flex items-center p-3 sm:px-6" : "p-5 flex flex-col",
selectedIds.has(p.id) ? "border-primary bg-primary/5" : "border-border"
)}
>
<div
onClick={(e) => {
e.stopPropagation()
toggleSelection(p.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(p.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(p.id) && <Check className="w-3.5 h-3.5 text-background font-bold stroke-[3]" />}
</div>
</div>
{viewMode === 'grid' && (
<div className="absolute top-0 right-0 w-32 h-32 bg-primary/5 blur-[40px] rounded-full -mr-16 -mt-16 pointer-events-none group-hover:bg-primary/10 transition-all" />
)}
<div className={clsx("flex items-center gap-4 relative z-10", viewMode === 'list' ? "flex-1" : "mb-5")}>
<div className="relative">
<div className="w-12 h-12 bg-surface-raised rounded-2xl border border-border/50 flex items-center justify-center font-mono font-black text-sm text-primary shadow-inner">
{p.number !== null ? p.number : getInitials(p.name)}
</div>
<div className={clsx(
"absolute -bottom-1 -right-1 w-5 h-5 rounded-lg border-2 border-background flex items-center justify-center bg-foreground text-[8px] font-black text-background",
p.position === 'DEF' ? 'bg-blue-500' : p.position === 'MEI' ? 'bg-emerald-500' : 'bg-orange-500'
)}>
{p.position[0]}
</div>
</div>
<div className="min-w-0">
<p className="font-bold text-[13px] uppercase tracking-tight truncate leading-none mb-1 group-hover:text-primary transition-colors">
{p.name}
</p>
<div className="flex items-center gap-2">
<span className="text-[9px] font-bold text-muted uppercase tracking-widest">{p.position}</span>
<span className="w-1 h-1 rounded-full bg-border" />
<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>
</div>
</div>
{viewMode === 'list' && (
<div className="hidden sm:flex items-center gap-8 mr-8">
<div className="text-center">
<p className="text-[8px] font-bold text-muted uppercase tracking-widest mb-0.5">Nivel</p>
<p className={clsx("text-[10px] font-black uppercase", getLevelInfo(p.level).color)}>{getLevelInfo(p.level).label}</p>
</div>
</div>
)}
<div className={clsx("flex items-center gap-2 relative z-10", viewMode === 'list' ? "ml-auto" : "mt-auto justify-between pt-4 border-t border-border/50")}>
{viewMode === 'grid' && (
<div className={clsx("text-[9px] font-black uppercase tracking-widest", getLevelInfo(p.level).color)}>
Status: {getLevelInfo(p.level).label}
</div>
)}
<button
onClick={(e) => {
e.stopPropagation()
handleSingleDelete(p.id, p.name)
}}
className="p-2 text-muted hover:text-red-500 hover:bg-red-500/10 rounded-xl transition-all opacity-0 lg:group-hover:opacity-100"
title="Excluir Atleta"
>
<Trash2 className="w-4 h-4" />
</button>
</div>
</motion.div>
))}
</AnimatePresence>
{paginatedPlayers.length === 0 && (
<motion.div
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
className="col-span-full py-20 text-center space-y-4"
>
<div className="w-16 h-16 bg-surface-raised border border-dashed border-border rounded-full flex items-center justify-center mx-auto opacity-40">
<Search className="w-6 h-6 text-muted" />
</div>
<div className="space-y-1">
<p className="text-sm font-bold uppercase tracking-widest">Nenhum Atleta Encontrado</p>
<p className="text-xs text-muted">Tente ajustar sua busca ou o filtro por posição.</p>
</div>
</motion.div>
)}
</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>
)}
</div>
<DeleteConfirmationModal
isOpen={deleteModal.isOpen}
onClose={() => setDeleteModal(prev => ({ ...prev, isOpen: false }))}
onConfirm={executeDelete}
isDeleting={deleteModal.isDeleting}
title={deleteModal.title}
description={deleteModal.description}
confirmText="Sim, confirmar"
/>
</div>
)
}

View File

@@ -0,0 +1,229 @@
'use client'
import { useState, useTransition, useEffect } from 'react'
import { updateGroupSettings } from '@/app/actions'
import { Upload, Save, Loader2, Image as ImageIcon, AlertCircle, CheckCircle } from 'lucide-react'
import { useRouter } from 'next/navigation'
import { motion, AnimatePresence } from 'framer-motion'
interface SettingsFormProps {
initialData: {
name: string
slug: string
logoUrl: string | null
primaryColor: string
secondaryColor: string
pixKey?: string | null
pixName?: string | null
}
}
export function SettingsForm({ initialData }: SettingsFormProps) {
const [error, setError] = useState<string | null>(null)
const [successMsg, setSuccessMsg] = useState<string | null>(null)
const [isPending, startTransition] = useTransition()
const [previewUrl, setPreviewUrl] = useState<string | null>(initialData.logoUrl)
const router = useRouter()
// Sincroniza o preview se os dados iniciais mudarem (ex: após redirect/refresh)
useEffect(() => {
setPreviewUrl(initialData.logoUrl)
}, [initialData.logoUrl])
const handleFileChange = (e: React.ChangeEvent<HTMLInputElement>) => {
const file = e.target.files?.[0]
if (file) {
const url = URL.createObjectURL(file)
setPreviewUrl(url)
}
}
const handleSubmit = async (e: React.FormEvent<HTMLFormElement>) => {
e.preventDefault()
const formData = new FormData(e.currentTarget)
setError(null)
setSuccessMsg(null)
startTransition(async () => {
try {
const res = await updateGroupSettings(formData)
if (res.success) {
setSuccessMsg('Configurações salvas com sucesso! Redirecionando...')
// Pequeno delay para o usuário ver a mensagem de sucesso
setTimeout(() => {
const slug = res.slug || initialData.slug
const host = window.location.host
const protocol = window.location.protocol
// Garante que o porto seja preservado se houver (localhost:3000)
const portMatch = host.match(/:\d+$/)
const port = portMatch ? portMatch[0] : ''
// Evita redirecionar para slug vazio
if (slug) {
const targetDomain = host.includes('localhost') ? 'localhost' : 'temfut.com'
window.location.href = `${protocol}//${slug}.${targetDomain}${port}/dashboard/settings`
} else {
router.refresh()
}
}, 1500)
} else {
setError(res.error || 'Erro ao salvar configurações.')
}
} catch (err: any) {
console.error(err)
setError('Ocorreu um erro inesperado ao salvar.')
}
})
}
return (
<form onSubmit={handleSubmit} className="space-y-8 animate-in fade-in duration-500">
{/* Logo Section */}
<div className="ui-card p-8 flex flex-col sm:flex-row items-center gap-8">
<div className="relative group">
<div className="w-32 h-32 rounded-2xl bg-surface-raised border-2 border-dashed border-border flex items-center justify-center overflow-hidden transition-all group-hover:border-primary/50">
{previewUrl ? (
<img
src={previewUrl}
alt="Logo Preview"
className="w-full h-full object-cover"
/>
) : (
<ImageIcon className="w-10 h-10 text-muted group-hover:text-primary transition-colors" />
)}
</div>
<label htmlFor="logo-upload" className="absolute inset-0 cursor-pointer flex items-center justify-center bg-black/60 opacity-0 group-hover:opacity-100 transition-opacity rounded-2xl">
<span className="text-xs font-medium text-white flex items-center gap-1">
<Upload className="w-3 h-3" /> Alterar
</span>
</label>
<input
id="logo-upload"
name="logo"
type="file"
accept="image/*"
className="hidden"
onChange={handleFileChange}
/>
</div>
<div className="flex-1 text-center sm:text-left space-y-2">
<h3 className="font-semibold text-lg uppercase tracking-tight">Escudo do Grupo</h3>
<p className="text-muted text-xs font-medium uppercase tracking-wider">
Recomendado: 500x500px. JPG, PNG ou WEBP.
</p>
</div>
</div>
{/* General Info Section */}
<div className="ui-card p-8 space-y-8">
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
<div className="ui-form-field">
<label className="text-label ml-1">Nome da Pelada</label>
<input
name="name"
defaultValue={initialData.name}
placeholder="Ex: Pelada de Quarta"
className="ui-input w-full h-12 text-lg font-bold"
required
/>
</div>
<div className="ui-form-field">
<label className="text-label ml-1">Chave PIX para Cobranças (Opcional)</label>
<input
name="pixKey"
defaultValue={initialData.pixKey || ''}
placeholder="CPF, Email ou Aleatória"
className="ui-input w-full h-12 text-lg font-mono placeholder:font-sans"
/>
</div>
</div>
<div className="ui-form-field">
<label className="text-label ml-1">Nome Completo do Titular do Pix</label>
<input
name="pixName"
defaultValue={initialData.pixName || ''}
placeholder="Ex: José da Silva (Para o QR Code)"
className="ui-input w-full h-12 text-lg font-medium"
/>
</div>
<div className="grid grid-cols-1 md:grid-cols-2 gap-8">
<div className="ui-form-field">
<label className="text-label ml-1">Cor de Destaque (Primária)</label>
<div className="flex gap-4 items-center p-4 rounded-xl border border-border bg-surface-raised/30">
<input
type="color"
name="primaryColor"
defaultValue={initialData.primaryColor}
className="h-14 w-14 rounded-lg cursor-pointer border-0 p-0 overflow-hidden bg-transparent"
/>
<div className="flex-1">
<p className="text-xs font-bold uppercase text-muted tracking-widest mb-1">Status: Ativo</p>
<p className="text-[10px] text-muted font-medium uppercase">Personaliza botões e ícones.</p>
</div>
</div>
</div>
<div className="ui-form-field">
<label className="text-label ml-1">Cor de Fundo (Secundária)</label>
<div className="flex gap-4 items-center p-4 rounded-xl border border-border bg-surface-raised/30">
<input
type="color"
name="secondaryColor"
defaultValue={initialData.secondaryColor}
className="h-14 w-14 rounded-lg cursor-pointer border-0 p-0 overflow-hidden bg-transparent"
/>
<div className="flex-1">
<p className="text-xs font-bold uppercase text-muted tracking-widest mb-1">Contraste</p>
<p className="text-[10px] text-muted font-medium uppercase">Usada em detalhes de UI.</p>
</div>
</div>
</div>
</div>
</div>
{/* Status Messages */}
<AnimatePresence>
{error && (
<motion.div initial={{ opacity: 0, y: 10 }} animate={{ opacity: 1, y: 0 }} exit={{ opacity: 0 }} className="p-4 rounded-xl bg-red-500/10 border border-red-500/20 text-red-500 flex items-center gap-3">
<AlertCircle className="w-5 h-5 shrink-0" />
<p className="text-sm font-bold uppercase tracking-tight">{error}</p>
</motion.div>
)}
{successMsg && (
<motion.div initial={{ opacity: 0, y: 10 }} animate={{ opacity: 1, y: 0 }} exit={{ opacity: 0 }} className="p-4 rounded-xl bg-emerald-500/10 border border-emerald-500/20 text-emerald-500 flex items-center gap-3">
<CheckCircle className="w-5 h-5 shrink-0" />
<p className="text-sm font-bold uppercase tracking-tight">{successMsg}</p>
</motion.div>
)}
</AnimatePresence>
{/* Submit Button */}
<div className="flex justify-end items-center gap-4 border-t border-border pt-8">
<button
type="submit"
disabled={isPending}
className="ui-button w-full sm:w-auto h-14 min-w-[240px] shadow-xl shadow-primary/20 text-base font-bold uppercase tracking-widest"
>
{isPending ? (
<>
<Loader2 className="w-5 h-5 animate-spin mr-2" />
Processando...
</>
) : (
<>
<Save className="w-5 h-5 mr-2" />
Confirmar Alterações
</>
)}
</button>
</div>
</form>
)
}

View File

@@ -0,0 +1,69 @@
'use client'
import { useState } from 'react'
import { MapPin, Palette } from 'lucide-react'
import { clsx } from 'clsx'
import { motion, AnimatePresence } from 'framer-motion'
interface SettingsTabsProps {
branding: React.ReactNode
arenas: React.ReactNode
}
export function SettingsTabs({ branding, arenas }: SettingsTabsProps) {
const [activeTab, setActiveTab] = useState<'branding' | 'arenas'>('branding')
const tabs = [
{ id: 'branding', label: 'Identidade Visual', icon: Palette },
{ id: 'arenas', label: 'Locais & Arenas', icon: MapPin },
] as const
return (
<div className="space-y-8">
<div className="flex p-1 bg-surface-raised rounded-xl border border-border w-full sm:w-fit">
{tabs.map((tab) => (
<button
key={tab.id}
onClick={() => setActiveTab(tab.id)}
className={clsx(
"flex items-center gap-2 px-6 py-2.5 rounded-lg text-sm font-bold transition-all flex-1 sm:flex-none justify-center",
activeTab === tab.id
? "bg-primary text-background shadow-lg shadow-emerald-500/10"
: "text-muted hover:text-foreground hover:bg-white/5"
)}
>
<tab.icon className="w-4 h-4" />
{tab.label}
</button>
))}
</div>
<div className="min-h-[500px]">
<AnimatePresence mode="wait">
{activeTab === 'branding' && (
<motion.div
key="branding"
initial={{ opacity: 0, y: 10 }}
animate={{ opacity: 1, y: 0 }}
exit={{ opacity: 0, y: -10 }}
transition={{ duration: 0.2 }}
>
{branding}
</motion.div>
)}
{activeTab === 'arenas' && (
<motion.div
key="arenas"
initial={{ opacity: 0, y: 10 }}
animate={{ opacity: 1, y: 0 }}
exit={{ opacity: 0, y: -10 }}
transition={{ duration: 0.2 }}
>
{arenas}
</motion.div>
)}
</AnimatePresence>
</div>
</div>
)
}

View File

@@ -0,0 +1,70 @@
'use client'
import { createGroup } from '@/actions/group'
import { motion } from 'framer-motion'
import { Plus, Image as ImageIcon } from 'lucide-react'
export function SetupForm() {
const handleAction = async (formData: FormData) => {
await createGroup(formData)
}
return (
<form action={handleAction} className="space-y-6">
<div className="ui-form-field">
<label className="text-label ml-0.5">
Nome do Grupo
</label>
<input
name="name"
placeholder="Ex: Amigos do Futebol"
className="ui-input w-full h-12"
required
/>
</div>
<div className="ui-form-field">
<label className="text-label ml-0.5">
Logo (Opcional)
</label>
<div className="relative group">
<input
name="logo"
type="file"
accept="image/*"
className="ui-input w-full h-12 pt-2.5 file:bg-primary file:border-0 file:rounded file:text-background file:text-xs file:font-bold file:uppercase file:px-3 file:py-1 file:mr-4 file:cursor-pointer transition-all"
/>
</div>
</div>
<div className="grid grid-cols-2 gap-4 pt-2">
<div className="ui-form-field">
<label className="text-label ml-0.5">
Cor Primária
</label>
<input
name="primaryColor"
type="color"
defaultValue="#10b981"
className="w-full h-12 bg-transparent border border-border rounded-lg cursor-pointer p-1"
/>
</div>
<div className="ui-form-field">
<label className="text-label ml-0.5">
Cor Secundária
</label>
<input
name="secondaryColor"
type="color"
defaultValue="#000000"
className="w-full h-12 bg-transparent border border-border rounded-lg cursor-pointer p-1"
/>
</div>
</div>
<button type="submit" className="ui-button w-full mt-4 h-12 text-sm font-bold">
<Plus className="w-5 h-5 mr-2" /> Finalizar Cadastro
</button>
</form>
)
}

50
src/components/Shell.tsx Normal file
View File

@@ -0,0 +1,50 @@
'use client'
import { motion } from 'framer-motion'
import { Trophy, Search, Bell, User } from 'lucide-react'
import Link from 'next/link'
export function Shell({ children }: { children: React.ReactNode }) {
return (
<div className="min-h-screen bg-background text-foreground font-sans">
<nav className="border-b border-border bg-background/50 backdrop-blur-md sticky top-0 z-50">
<div className="container mx-auto px-4 h-14 flex items-center justify-between">
<div className="flex items-center gap-8">
<Link href="/dashboard" className="flex items-center gap-2">
<div className="w-8 h-8 bg-primary rounded flex items-center justify-center">
<Trophy className="w-4 h-4 text-background" />
</div>
<span className="font-bold tracking-tight text-lg">TEMFUT</span>
</Link>
<div className="hidden md:flex items-center gap-6">
<Link href="/dashboard" className="text-sm font-medium text-muted hover:text-foreground transition-colors">Dashboard</Link>
<Link href="/dashboard/matches" className="text-sm font-medium text-muted hover:text-foreground transition-colors">Partidas</Link>
<Link href="/dashboard/players" className="text-sm font-medium text-muted hover:text-foreground transition-colors">Jogadores</Link>
</div>
</div>
<div className="flex items-center gap-4">
<div className="hidden sm:flex relative">
<Search className="absolute left-3 top-1/2 -translate-y-1/2 w-3.5 h-3.5 text-muted" />
<input
type="text"
placeholder="Search..."
className="bg-surface-raised border border-border rounded-md pl-9 pr-4 py-1.5 text-xs w-64 focus:outline-none focus:ring-1 focus:ring-primary"
/>
</div>
<button className="p-2 text-muted hover:text-foreground transition-colors">
<Bell className="w-4 h-4" />
</button>
<div className="w-8 h-8 rounded-full bg-surface-raised border border-border flex items-center justify-center">
<User className="w-4 h-4 text-muted" />
</div>
</div>
</div>
</nav>
<main className="container mx-auto px-4 py-8 max-w-6xl">
{children}
</main>
</div>
)
}

104
src/components/Sidebar.tsx Normal file
View File

@@ -0,0 +1,104 @@
'use client'
import Link from 'next/link'
import { usePathname, useRouter } from 'next/navigation'
import {
Trophy,
Users,
Calendar,
Settings,
Home,
Search,
Banknote,
LogOut
} from 'lucide-react'
import { clsx } from 'clsx'
export function Sidebar({ group }: { group: any }) {
const pathname = usePathname()
const router = useRouter()
const navItems = [
{ name: 'Início', href: '/dashboard', icon: Home },
{ name: 'Partidas', href: '/dashboard/matches', icon: Calendar },
{ name: 'Jogadores', href: '/dashboard/players', icon: Users },
{ name: 'Financeiro', href: '/dashboard/financial', icon: Banknote },
{ name: 'Configurações', href: '/dashboard/settings', icon: Settings },
]
const handleLogout = async () => {
await fetch('/api/auth/logout', { method: 'POST' })
window.location.href = '/'
}
return (
<aside className="hidden md:flex w-64 border-r border-border bg-surface flex-col fixed inset-y-0 z-50 md:sticky">
<div className="p-6 h-14 flex items-center border-b border-border">
<Link href="/dashboard" className="flex items-center gap-2">
<div className="w-7 h-7 bg-primary rounded flex items-center justify-center">
<Trophy className="w-3.5 h-3.5 text-background" />
</div>
<span className="font-bold tracking-tight text-lg">TEMFUT</span>
</Link>
</div>
<div className="p-4">
<div className="relative">
<Search className="absolute left-3 top-1/2 -translate-y-1/2 w-3.5 h-3.5 text-muted" />
<input
type="text"
placeholder="Search..."
className="w-full bg-surface-raised border border-border rounded-md pl-9 pr-3 py-1.5 text-xs focus:outline-none focus:ring-1 focus:ring-primary"
/>
</div>
</div>
<nav className="flex-1 px-3 py-2 space-y-1">
<p className="text-[10px] font-bold text-muted uppercase tracking-widest px-3 mb-2 mt-4">Navegação</p>
{navItems.map((item) => {
const isActive = pathname === item.href
return (
<Link
key={item.href}
href={item.href}
className={clsx(
"flex items-center gap-3 px-3 py-2 rounded-md text-sm font-medium transition-all",
isActive
? "bg-primary/5 text-primary border border-primary/20"
: "text-muted hover:text-foreground hover:bg-surface-raised"
)}
>
<item.icon className={clsx("w-4 h-4", isActive ? "text-primary" : "text-muted")} />
<span>{item.name}</span>
</Link>
)
})}
</nav>
<div className="p-4 border-t border-border mt-auto">
<button
onClick={handleLogout}
className="flex w-full items-center gap-3 px-3 py-2 text-sm font-medium text-red-400 hover:text-red-300 hover:bg-red-500/10 rounded-md transition-all mb-4"
>
<LogOut className="w-4 h-4" />
<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">
{group.logoUrl ? (
<img src={group.logoUrl} alt="" className="w-full h-full object-cover" />
) : (
<span className="text-[10px] font-bold">{group.name.charAt(0)}</span>
)}
</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>
</div>
</div>
</div>
</aside>
)
}

View File

@@ -0,0 +1,21 @@
'use client'
import { useEffect } from 'react'
interface ThemeWrapperProps {
primaryColor?: string
children?: React.ReactNode
}
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
}

72
src/lib/auth.ts Normal file
View File

@@ -0,0 +1,72 @@
import { cookies, headers } from 'next/headers'
import { prisma } from '@/lib/prisma'
export async function getActiveGroup() {
console.log('--- [AUTH CHECK START] ---')
const headerStore = await headers()
const slug = headerStore.get('x-current-slug')?.toLowerCase()
const cookieStore = await cookies()
const groupSession = cookieStore.get('group_session')?.value
const groupId = cookieStore.get('group_id')?.value
console.log(`Contexto Slug: ${slug || 'Nenhum'}`)
console.log(`Cookies: Session=${groupSession ? 'OK' : 'MISSING'}, ID=${groupId || 'MISSING'}`)
let loggedId: string | null = null
if (groupSession) {
try {
const data = JSON.parse(groupSession)
loggedId = data.id
console.log(`ID recuperado do JSON: ${loggedId}`)
} catch (e) {
console.error('[AUTH CHECK] Falha ao ler JSON da sessão')
}
}
if (!loggedId && groupId) loggedId = groupId
if (!loggedId) {
console.log(`[AUTH CHECK] Nenhum usuário logado.`)
return null
}
if (slug) {
const group = await prisma.group.findUnique({
where: { slug },
include: {
players: true,
matches: {
include: {
teams: { include: { players: { include: { player: true } } } },
attendances: { include: { player: true } }
},
orderBy: { date: 'desc' }
}
}
})
if (group && group.id === loggedId) {
if ((group as any).status === 'FROZEN') {
console.log(`[AUTH CHECK] Pelada ${slug} está CONGELADA. Acesso negado.`)
return null
}
console.log(`[AUTH CHECK] Sucesso: Acesso liberado para ${slug}`)
return group
}
console.log(`[AUTH CHECK] Falha: Token pertence ao grupo ${loggedId}, mas tentando acessar ${group?.id} (${slug})`)
return null
}
if (loggedId) {
console.log(`[AUTH CHECK] Acesso direto via ID: ${loggedId}`)
return await prisma.group.findUnique({
where: { id: loggedId },
include: { players: true }
})
}
return null
}

48
src/lib/minio.ts Normal file
View File

@@ -0,0 +1,48 @@
import * as Minio from 'minio'
export const minioClient = new Minio.Client({
endPoint: process.env.MINIO_ENDPOINT || '127.0.0.1',
port: parseInt(process.env.MINIO_PORT || '9100'),
useSSL: process.env.MINIO_USE_SSL === 'true',
accessKey: process.env.MINIO_ACCESS_KEY || 'temfut_admin',
secretKey: process.env.MINIO_SECRET_KEY || 'temfut_secret_2026',
})
export const BUCKET_NAME = process.env.MINIO_BUCKET || 'temfut'
export const ensureBucket = async () => {
console.log('--- ENSURE BUCKET START ---')
try {
console.log('Checking if bucket exists:', BUCKET_NAME)
const exists = await minioClient.bucketExists(BUCKET_NAME)
console.log('Bucket exists:', exists)
if (!exists) {
console.log(`Creating bucket: ${BUCKET_NAME}`)
await minioClient.makeBucket(BUCKET_NAME, 'us-east-1')
}
// Set bucket policy to public for viewing images (Idempotent)
const policy = {
Version: '2012-10-17',
Statement: [
{
Effect: 'Allow',
Principal: { AWS: ['*'] },
Action: ['s3:GetBucketLocation', 's3:ListBucket'],
Resource: [`arn:aws:s3:::${BUCKET_NAME}`],
},
{
Effect: 'Allow',
Principal: { AWS: ['*'] },
Action: ['s3:GetObject'],
Resource: [`arn:aws:s3:::${BUCKET_NAME}/*`],
},
],
}
await minioClient.setBucketPolicy(BUCKET_NAME, JSON.stringify(policy))
console.log('Bucket policy set to public.')
} catch (error) {
console.error('ERROR IN ENSURE_BUCKET:', error)
throw error
}
}

9
src/lib/prisma.ts Normal file
View File

@@ -0,0 +1,9 @@
import { PrismaClient } from '@prisma/client'
const globalForPrisma = global as unknown as { prisma: PrismaClient }
export const prisma =
globalForPrisma.prisma ||
new PrismaClient()
if (process.env.NODE_ENV !== 'production') globalForPrisma.prisma = prisma

31
src/lib/upload.ts Normal file
View File

@@ -0,0 +1,31 @@
import { minioClient, BUCKET_NAME, ensureBucket } from '@/lib/minio'
import { v4 as uuidv4 } from 'uuid'
export async function uploadFile(file: File): Promise<string> {
console.log('--- START UPLOAD ---')
console.log('File name:', file.name)
console.log('File size:', file.size)
await ensureBucket()
const buffer = Buffer.from(await file.arrayBuffer())
const sanitizedName = file.name.replace(/[^a-zA-Z0-9.]/g, '-')
const fileName = `${uuidv4()}-${sanitizedName}`
console.log('Uploading to bucket:', BUCKET_NAME)
console.log('Generated fileName:', fileName)
try {
await minioClient.putObject(BUCKET_NAME, fileName, buffer, buffer.length, {
'Content-Type': file.type,
})
console.log('Upload successful!')
} catch (error) {
console.error('CRITICAL MINIO ERROR:', error)
throw error
}
// Return the proxied URL through Nginx to avoid DNS resolution errors in the browser
// Return the relative URL. Next.js proxy will handle the rest.
return `/storage/${BUCKET_NAME}/${fileName}`
}

82
src/middleware.ts Normal file
View File

@@ -0,0 +1,82 @@
import { NextResponse } from 'next/server'
import type { NextRequest } from 'next/server'
export function middleware(request: NextRequest) {
const url = request.nextUrl
const hostname = request.headers.get('host') || 'localhost'
const pathname = url.pathname
const hostnameNoPort = hostname.split(':')[0].toLowerCase()
console.log(`[MIDDLEWARE] Requisição: ${hostnameNoPort}${pathname}`)
// 1. Identificar se é ADMIN
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')) {
return NextResponse.next()
}
// Se NÃO está logado e tenta acessar qualquer coisa que não seja login -> Redireciona para Login
if (!isAdminSession && pathname !== '/login') {
url.pathname = '/login'
return NextResponse.redirect(url)
}
// Se JÁ está logado e tenta acessar login -> Redireciona para Dashboard
if (isAdminSession && pathname === '/login') {
url.pathname = '/'
return NextResponse.redirect(url)
}
// Se usuario digita /admin na URL manualmente -> Limpa para /
if (pathname.startsWith('/admin')) {
url.pathname = pathname.replace('/admin', '') || '/'
return NextResponse.redirect(url)
}
// Roteamento Interno:
// O usuário vê "admin.localhost/" ou "admin.localhost/settings"
// O Next.js recebe "/admin" ou "/admin/settings"
if (pathname === '/') {
url.pathname = '/admin'
return NextResponse.rewrite(url)
}
url.pathname = `/admin${pathname}`
return NextResponse.rewrite(url)
}
// 2. Identificar se é PELADA (Multi-tenant)
const parts = hostnameNoPort.split('.')
let slug = ''
if (parts.length === 2 && parts[1] === 'localhost' && parts[0] !== 'admin') {
slug = parts[0]
} else if (parts.length > 2 && parts[0] !== 'www' && parts[0] !== 'admin') {
slug = parts[0]
}
if (slug) {
console.log(`[MIDDLEWARE] Slug detectado: ${slug}`)
const requestHeaders = new Headers(request.headers)
requestHeaders.set('x-current-slug', slug)
return NextResponse.next({
request: {
headers: requestHeaders,
},
})
}
return NextResponse.next()
}
export const config = {
matcher: [
'/((?!_next/static|_next/image|favicon.ico).*)',
],
}

14
traefik.yml Normal file
View File

@@ -0,0 +1,14 @@
api:
insecure: true
dashboard: true
entryPoints:
web:
address: ":80"
providers:
file:
filename: /etc/traefik/dynamic.yml
log:
level: DEBUG

34
tsconfig.json Normal file
View File

@@ -0,0 +1,34 @@
{
"compilerOptions": {
"target": "ES2017",
"lib": ["dom", "dom.iterable", "esnext"],
"allowJs": true,
"skipLibCheck": true,
"strict": true,
"noEmit": true,
"esModuleInterop": true,
"module": "esnext",
"moduleResolution": "bundler",
"resolveJsonModule": true,
"isolatedModules": true,
"jsx": "react-jsx",
"incremental": true,
"plugins": [
{
"name": "next"
}
],
"paths": {
"@/*": ["./src/*"]
}
},
"include": [
"next-env.d.ts",
"**/*.ts",
"**/*.tsx",
".next/types/**/*.ts",
".next/dev/types/**/*.ts",
"**/*.mts"
],
"exclude": ["node_modules"]
}