feat: add error handling to player creation and update gitignore for agent
This commit is contained in:
10
.agent/memories.md
Normal file
10
.agent/memories.md
Normal 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
8
.dockerignore
Normal file
@@ -0,0 +1,8 @@
|
||||
node_modules
|
||||
.next
|
||||
.git
|
||||
.env*
|
||||
.DS_Store
|
||||
README.md
|
||||
Dockerfile
|
||||
docker-compose.yml
|
||||
10
.env.example
Normal file
10
.env.example
Normal 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
48
.gitignore
vendored
Normal 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
29
Dockerfile
Normal 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
63
README.md
Normal 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
76
docker-compose.yml
Normal 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
17
dynamic.yml
Normal 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
18
eslint.config.mjs
Normal 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
6
next-env.d.ts
vendored
Normal 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
18
next.config.ts
Normal 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
7486
package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
47
package.json
Normal file
47
package.json
Normal 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
7
postcss.config.mjs
Normal file
@@ -0,0 +1,7 @@
|
||||
const config = {
|
||||
plugins: {
|
||||
"@tailwindcss/postcss": {},
|
||||
},
|
||||
};
|
||||
|
||||
export default config;
|
||||
@@ -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;
|
||||
15
prisma/migrations/20260121025323_sync_schema/migration.sql
Normal file
15
prisma/migrations/20260121025323_sync_schema/migration.sql
Normal 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");
|
||||
@@ -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;
|
||||
@@ -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';
|
||||
@@ -0,0 +1,2 @@
|
||||
-- AlterTable
|
||||
ALTER TABLE "Match" ALTER COLUMN "status" SET DEFAULT 'SCHEDULED';
|
||||
@@ -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;
|
||||
@@ -0,0 +1,3 @@
|
||||
-- AlterTable
|
||||
ALTER TABLE "Match" ADD COLUMN "isRecurring" BOOLEAN NOT NULL DEFAULT false,
|
||||
ADD COLUMN "recurrenceInterval" TEXT;
|
||||
@@ -0,0 +1,2 @@
|
||||
-- AlterTable
|
||||
ALTER TABLE "Match" ADD COLUMN "recurrenceEndDate" TIMESTAMP(3);
|
||||
3
prisma/migrations/migration_lock.toml
Normal file
3
prisma/migrations/migration_lock.toml
Normal 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
206
prisma/schema.prisma
Normal 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
47
prisma/seed.ts
Normal 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
1
public/file.svg
Normal 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
1
public/globe.svg
Normal 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
1
public/next.svg
Normal 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
1
public/vercel.svg
Normal 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
1
public/window.svg
Normal 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
67
src/actions/arena.ts
Normal 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
71
src/actions/attendance.ts
Normal 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
238
src/actions/finance.ts
Normal 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
50
src/actions/group.ts
Normal 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
230
src/actions/match.ts
Normal 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
46
src/actions/player.ts
Normal 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('/')
|
||||
}
|
||||
25
src/app/(dashboard)/dashboard/financial/page.tsx
Normal file
25
src/app/(dashboard)/dashboard/financial/page.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
29
src/app/(dashboard)/dashboard/matches/new/page.tsx
Normal file
29
src/app/(dashboard)/dashboard/matches/new/page.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
40
src/app/(dashboard)/dashboard/matches/page.tsx
Normal file
40
src/app/(dashboard)/dashboard/matches/page.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
233
src/app/(dashboard)/dashboard/matches/schedule/page.tsx
Normal file
233
src/app/(dashboard)/dashboard/matches/schedule/page.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
161
src/app/(dashboard)/dashboard/page.tsx
Normal file
161
src/app/(dashboard)/dashboard/page.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
19
src/app/(dashboard)/dashboard/players/page.tsx
Normal file
19
src/app/(dashboard)/dashboard/players/page.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
39
src/app/(dashboard)/dashboard/settings/page.tsx
Normal file
39
src/app/(dashboard)/dashboard/settings/page.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
28
src/app/(dashboard)/layout.tsx
Normal file
28
src/app/(dashboard)/layout.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
32
src/app/(pelada)/layout.tsx
Normal file
32
src/app/(pelada)/layout.tsx
Normal 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
386
src/app/LandingPage.tsx
Normal 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 só 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
166
src/app/actions.ts
Normal 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
98
src/app/actions/auth.ts
Normal 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')
|
||||
}
|
||||
}
|
||||
134
src/app/admin/login/page.tsx
Normal file
134
src/app/admin/login/page.tsx
Normal 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
527
src/app/admin/page.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
380
src/app/admin/settings/page.tsx
Normal file
380
src/app/admin/settings/page.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
100
src/app/api/admin/groups/[id]/route.ts
Normal file
100
src/app/api/admin/groups/[id]/route.ts
Normal 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 })
|
||||
}
|
||||
}
|
||||
57
src/app/api/admin/groups/route.ts
Normal file
57
src/app/api/admin/groups/route.ts
Normal 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 }
|
||||
)
|
||||
}
|
||||
}
|
||||
71
src/app/api/admin/login/route.ts
Normal file
71
src/app/api/admin/login/route.ts
Normal 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 }
|
||||
)
|
||||
}
|
||||
}
|
||||
19
src/app/api/admin/logout/route.ts
Normal file
19
src/app/api/admin/logout/route.ts
Normal 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 }
|
||||
)
|
||||
}
|
||||
}
|
||||
160
src/app/api/admin/profile/route.ts
Normal file
160
src/app/api/admin/profile/route.ts
Normal 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 }
|
||||
)
|
||||
}
|
||||
}
|
||||
54
src/app/api/admin/seed/route.ts
Normal file
54
src/app/api/admin/seed/route.ts
Normal 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 }
|
||||
)
|
||||
}
|
||||
}
|
||||
76
src/app/api/auth/login/route.ts
Normal file
76
src/app/api/auth/login/route.ts
Normal 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 }
|
||||
)
|
||||
}
|
||||
}
|
||||
12
src/app/api/auth/logout/route.ts
Normal file
12
src/app/api/auth/logout/route.ts
Normal 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 })
|
||||
}
|
||||
61
src/app/api/auth/pelada-login/route.ts
Normal file
61
src/app/api/auth/pelada-login/route.ts
Normal 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 })
|
||||
}
|
||||
}
|
||||
28
src/app/api/groups/check/route.ts
Normal file
28
src/app/api/groups/check/route.ts
Normal 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
407
src/app/create/page.tsx
Normal 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" /> Já 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
BIN
src/app/favicon.ico
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 25 KiB |
155
src/app/financial-report/[id]/page.tsx
Normal file
155
src/app/financial-report/[id]/page.tsx
Normal 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
119
src/app/globals.css
Normal 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
27
src/app/layout.tsx
Normal 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
33
src/app/login/page.tsx
Normal 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} />
|
||||
}
|
||||
329
src/app/match/[id]/confirmacao/page.tsx
Normal file
329
src/app/match/[id]/confirmacao/page.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
58
src/app/not-found-pelada/page.tsx
Normal file
58
src/app/not-found-pelada/page.tsx
Normal 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
37
src/app/page.tsx
Normal 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} />
|
||||
}
|
||||
143
src/components/ArenasManager.tsx
Normal file
143
src/components/ArenasManager.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
24
src/components/CopyButton.tsx
Normal file
24
src/components/CopyButton.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
266
src/components/CreateFinanceEventModal.tsx
Normal file
266
src/components/CreateFinanceEventModal.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
97
src/components/DeleteConfirmationModal.tsx
Normal file
97
src/components/DeleteConfirmationModal.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
453
src/components/FinancialDashboard.tsx
Normal file
453
src/components/FinancialDashboard.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
508
src/components/MatchFlow.tsx
Normal file
508
src/components/MatchFlow.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
608
src/components/MatchHistory.tsx
Normal file
608
src/components/MatchHistory.tsx
Normal 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 >
|
||||
)
|
||||
}
|
||||
148
src/components/MobileNav.tsx
Normal file
148
src/components/MobileNav.tsx
Normal 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 © 2024</p>
|
||||
</div>
|
||||
</motion.div>
|
||||
</>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
106
src/components/PeladaLoginPage.tsx
Normal file
106
src/components/PeladaLoginPage.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
607
src/components/PlayersList.tsx
Normal file
607
src/components/PlayersList.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
229
src/components/SettingsForm.tsx
Normal file
229
src/components/SettingsForm.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
69
src/components/SettingsTabs.tsx
Normal file
69
src/components/SettingsTabs.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
70
src/components/SetupForm.tsx
Normal file
70
src/components/SetupForm.tsx
Normal 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
50
src/components/Shell.tsx
Normal 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
104
src/components/Sidebar.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
|
||||
21
src/components/ThemeWrapper.tsx
Normal file
21
src/components/ThemeWrapper.tsx
Normal 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
72
src/lib/auth.ts
Normal 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
48
src/lib/minio.ts
Normal 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
9
src/lib/prisma.ts
Normal 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
31
src/lib/upload.ts
Normal 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
82
src/middleware.ts
Normal 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
14
traefik.yml
Normal 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
34
tsconfig.json
Normal 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"]
|
||||
}
|
||||
Reference in New Issue
Block a user