commit 22c6f93e7879255ce1ba7c13fe94557c3692e598 Author: jpbeldomenico Date: Thu Jun 11 11:22:46 2026 -0300 Initial commit - Prode Mundial 2026 diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..9ec6ae8 --- /dev/null +++ b/.env.example @@ -0,0 +1,14 @@ +# Base de datos +POSTGRES_USER=prode +POSTGRES_PASSWORD=prode_secret +POSTGRES_DB=prode_db + +# JWT - CAMBIAR en producción +JWT_SECRET=un_secret_muy_largo_y_seguro_aqui + +# URL del sitio externo de resultados (se configura cuando esté disponible) +RESULTS_API_URL= +RESULTS_API_KEY= + +# Secret para proteger el endpoint de sync +ADMIN_SYNC_SECRET=sync_secret_cambialo diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..3a44d20 --- /dev/null +++ b/.gitignore @@ -0,0 +1,28 @@ +# Dependencies +node_modules/ +.pnp +.pnp.js + +# Next.js +.next/ +out/ +build/ + +# Env files +.env +.env.local +.env.*.local + +# Logs +*.log +npm-debug.log* + +# OS +.DS_Store +Thumbs.db + +# Prisma generated +app/prisma/migrations/dev.db + +# Claude +.claude/settings.local.json diff --git a/app/Dockerfile b/app/Dockerfile new file mode 100644 index 0000000..3515621 --- /dev/null +++ b/app/Dockerfile @@ -0,0 +1,35 @@ +FROM node:20-alpine AS deps +WORKDIR /app +COPY package*.json ./ +RUN npm ci + +FROM node:20-alpine AS builder +WORKDIR /app +COPY --from=deps /app/node_modules ./node_modules +COPY . . +RUN npx prisma generate +RUN npm run build + +FROM node:20-alpine AS runner +WORKDIR /app +ENV NODE_ENV=production + +RUN addgroup --system --gid 1001 nodejs +RUN adduser --system --uid 1001 nextjs + +COPY --from=builder /app/public ./public +COPY --from=builder --chown=nextjs:nodejs /app/.next/standalone ./ +COPY --from=builder --chown=nextjs:nodejs /app/.next/static ./.next/static +COPY --from=builder /app/prisma ./prisma +COPY --from=builder /app/node_modules/.prisma ./node_modules/.prisma +COPY --from=builder /app/node_modules/@prisma ./node_modules/@prisma +COPY --from=builder /app/node_modules/bcryptjs ./node_modules/bcryptjs +COPY entrypoint.sh . +RUN chmod +x entrypoint.sh + +USER nextjs +EXPOSE 3000 +ENV PORT=3000 +ENV HOSTNAME="0.0.0.0" + +ENTRYPOINT ["./entrypoint.sh"] diff --git a/app/entrypoint.sh b/app/entrypoint.sh new file mode 100644 index 0000000..e4ebb6f --- /dev/null +++ b/app/entrypoint.sh @@ -0,0 +1,23 @@ +#!/bin/sh +set -e + +echo ">>> Ejecutando migraciones..." +npx prisma migrate deploy + +echo ">>> Verificando seed..." +TEAM_COUNT=$(node -e " +const { PrismaClient } = require('./node_modules/@prisma/client'); +const p = new PrismaClient(); +p.team.count().then(c => { console.log(c); p.\$disconnect(); }).catch(() => { console.log(0); }); +") + +if [ "$TEAM_COUNT" = "0" ]; then + echo ">>> Cargando datos iniciales..." + node -e " + const { execSync } = require('child_process'); + execSync('node prisma/seed.js', { stdio: 'inherit' }); + " || echo "Seed via JS no disponible, salteando..." +fi + +echo ">>> Iniciando servidor..." +exec node server.js diff --git a/app/middleware.ts b/app/middleware.ts new file mode 100644 index 0000000..0ef7176 --- /dev/null +++ b/app/middleware.ts @@ -0,0 +1,43 @@ +import { NextResponse } from 'next/server' +import type { NextRequest } from 'next/server' +import { jwtVerify } from 'jose' + +const secret = new TextEncoder().encode( + process.env.JWT_SECRET ?? 'fallback_secret_change_in_production' +) + +const PUBLIC_PATHS = ['/login', '/register', '/api/auth/login', '/api/auth/register'] + +export async function middleware(request: NextRequest) { + const { pathname } = request.nextUrl + + if (PUBLIC_PATHS.some(p => pathname.startsWith(p))) { + return NextResponse.next() + } + + const token = request.cookies.get('prode_token')?.value + + if (!token) { + return NextResponse.redirect(new URL('/login', request.url)) + } + + try { + const { payload } = await jwtVerify(token, secret) + + if (pathname.startsWith('/admin') || pathname.startsWith('/api/admin')) { + if (payload.role !== 'ADMIN') { + return NextResponse.redirect(new URL('/predictions', request.url)) + } + } + + return NextResponse.next() + } catch { + return NextResponse.redirect(new URL('/login', request.url)) + } +} + +export const config = { + matcher: [ + '/((?!_next/static|_next/image|favicon.ico|.*\\.png|.*\\.jpg|.*\\.svg).*)', + ], +} diff --git a/app/next.config.ts b/app/next.config.ts new file mode 100644 index 0000000..95296d3 --- /dev/null +++ b/app/next.config.ts @@ -0,0 +1,15 @@ +import type { NextConfig } from 'next' + +const nextConfig: NextConfig = { + output: 'standalone', + images: { + remotePatterns: [ + { + protocol: 'https', + hostname: 'flagcdn.com', + }, + ], + }, +} + +export default nextConfig diff --git a/app/package.json b/app/package.json new file mode 100644 index 0000000..b524e72 --- /dev/null +++ b/app/package.json @@ -0,0 +1,41 @@ +{ + "name": "prode-mundial-2026", + "version": "1.0.0", + "private": true, + "scripts": { + "dev": "next dev", + "build": "next build", + "start": "next start", + "lint": "next lint", + "db:migrate": "prisma migrate dev", + "db:seed": "tsx prisma/seed.ts", + "db:studio": "prisma studio", + "db:reset": "prisma migrate reset --force", + "dev:migrate": "prisma migrate dev --schema ./prisma/schema.dev.prisma", + "dev:seed": "tsx prisma/seed.ts", + "dev:generate": "prisma generate --schema ./prisma/schema.dev.prisma" + }, + "prisma": { + "seed": "tsx prisma/seed.ts" + }, + "dependencies": { + "@prisma/client": "^5.17.0", + "bcryptjs": "^2.4.3", + "jose": "^5.6.3", + "next": "^14.2.5", + "react": "^18.3.1", + "react-dom": "^18.3.1" + }, + "devDependencies": { + "@types/bcryptjs": "^2.4.6", + "@types/node": "^20.14.0", + "@types/react": "^18.3.3", + "@types/react-dom": "^18.3.0", + "autoprefixer": "^10.4.19", + "postcss": "^8.4.39", + "prisma": "^5.17.0", + "tailwindcss": "^3.4.6", + "tsx": "^4.16.2", + "typescript": "^5.5.4" + } +} diff --git a/app/postcss.config.mjs b/app/postcss.config.mjs new file mode 100644 index 0000000..393a10f --- /dev/null +++ b/app/postcss.config.mjs @@ -0,0 +1,8 @@ +const config = { + plugins: { + tailwindcss: {}, + autoprefixer: {}, + }, +} + +export default config diff --git a/app/prisma/schema.dev.prisma b/app/prisma/schema.dev.prisma new file mode 100644 index 0000000..b57e9c4 --- /dev/null +++ b/app/prisma/schema.dev.prisma @@ -0,0 +1,77 @@ +generator client { + provider = "prisma-client-js" + output = "../node_modules/.prisma/client" +} + +datasource db { + provider = "sqlite" + url = env("DATABASE_URL") +} + +model User { + id String @id @default(cuid()) + email String @unique + password String + name String + role String @default("PLAYER") + predictions Prediction[] + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt +} + +model Team { + id String @id @default(cuid()) + name String + shortName String @unique + isoCode String @unique + group String + groupOrder Int + confederation String + homeMatches Match[] @relation("HomeTeam") + awayMatches Match[] @relation("AwayTeam") + + @@index([group]) +} + +model Match { + id String @id @default(cuid()) + matchNumber Int @unique + phase String + group String? + matchday Int? + homeTeam Team? @relation("HomeTeam", fields: [homeTeamId], references: [id]) + homeTeamId String? + awayTeam Team? @relation("AwayTeam", fields: [awayTeamId], references: [id]) + awayTeamId String? + homeLabel String? + awayLabel String? + scheduledAt DateTime + venue String? + homeScore Int? + awayScore Int? + status String @default("SCHEDULED") + predictions Prediction[] + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + + @@index([phase]) + @@index([group]) + @@index([status]) + @@index([scheduledAt]) +} + +model Prediction { + id String @id @default(cuid()) + user User @relation(fields: [userId], references: [id]) + userId String + match Match @relation(fields: [matchId], references: [id]) + matchId String + result String + points Int? + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + + @@unique([userId, matchId]) + @@index([userId]) + @@index([matchId]) +} diff --git a/app/prisma/schema.prisma b/app/prisma/schema.prisma new file mode 100644 index 0000000..9a5d73b --- /dev/null +++ b/app/prisma/schema.prisma @@ -0,0 +1,103 @@ +generator client { + provider = "prisma-client-js" +} + +datasource db { + provider = "postgresql" + url = env("DATABASE_URL") +} + +model User { + id String @id @default(cuid()) + email String @unique + password String + name String + role Role @default(PLAYER) + predictions Prediction[] + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt +} + +enum Role { + ADMIN + PLAYER +} + +model Team { + id String @id @default(cuid()) + name String + shortName String @unique + isoCode String @unique + group String + groupOrder Int + confederation String + homeMatches Match[] @relation("HomeTeam") + awayMatches Match[] @relation("AwayTeam") + + @@index([group]) +} + +model Match { + id String @id @default(cuid()) + matchNumber Int @unique + phase Phase + group String? + matchday Int? + homeTeam Team? @relation("HomeTeam", fields: [homeTeamId], references: [id]) + homeTeamId String? + awayTeam Team? @relation("AwayTeam", fields: [awayTeamId], references: [id]) + awayTeamId String? + homeLabel String? + awayLabel String? + scheduledAt DateTime + venue String? + homeScore Int? + awayScore Int? + status MatchStatus @default(SCHEDULED) + predictions Prediction[] + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + + @@index([phase]) + @@index([group]) + @@index([status]) + @@index([scheduledAt]) +} + +enum Phase { + GROUP_STAGE + ROUND_OF_32 + ROUND_OF_16 + QUARTER_FINALS + SEMI_FINALS + THIRD_PLACE + FINAL +} + +enum MatchStatus { + SCHEDULED + LIVE + FINISHED +} + +model Prediction { + id String @id @default(cuid()) + user User @relation(fields: [userId], references: [id]) + userId String + match Match @relation(fields: [matchId], references: [id]) + matchId String + result PredictionResult + points Int? + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + + @@unique([userId, matchId]) + @@index([userId]) + @@index([matchId]) +} + +enum PredictionResult { + HOME + DRAW + AWAY +} diff --git a/app/prisma/seed.ts b/app/prisma/seed.ts new file mode 100644 index 0000000..a823ddc --- /dev/null +++ b/app/prisma/seed.ts @@ -0,0 +1,312 @@ +import { PrismaClient, Phase, MatchStatus } from '@prisma/client' +import bcrypt from 'bcryptjs' + +const prisma = new PrismaClient() + +const teams = [ + // GROUP A + { name: 'Mexico', shortName: 'MEX', isoCode: 'mx', group: 'A', groupOrder: 1, confederation: 'CONCACAF' }, + { name: 'South Africa', shortName: 'RSA', isoCode: 'za', group: 'A', groupOrder: 2, confederation: 'CAF' }, + { name: 'South Korea', shortName: 'KOR', isoCode: 'kr', group: 'A', groupOrder: 3, confederation: 'AFC' }, + { name: 'Czech Republic',shortName: 'CZE', isoCode: 'cz', group: 'A', groupOrder: 4, confederation: 'UEFA' }, + // GROUP B + { name: 'Canada', shortName: 'CAN', isoCode: 'ca', group: 'B', groupOrder: 1, confederation: 'CONCACAF' }, + { name: 'Bosnia and Herzegovina',shortName: 'BIH', isoCode: 'ba', group: 'B', groupOrder: 2, confederation: 'UEFA' }, + { name: 'Qatar', shortName: 'QAT', isoCode: 'qa', group: 'B', groupOrder: 3, confederation: 'AFC' }, + { name: 'Switzerland', shortName: 'SUI', isoCode: 'ch', group: 'B', groupOrder: 4, confederation: 'UEFA' }, + // GROUP C + { name: 'Brazil', shortName: 'BRA', isoCode: 'br', group: 'C', groupOrder: 1, confederation: 'CONMEBOL' }, + { name: 'Morocco', shortName: 'MAR', isoCode: 'ma', group: 'C', groupOrder: 2, confederation: 'CAF' }, + { name: 'Haiti', shortName: 'HAI', isoCode: 'ht', group: 'C', groupOrder: 3, confederation: 'CONCACAF' }, + { name: 'Scotland', shortName: 'SCO', isoCode: 'gb-sct', group: 'C', groupOrder: 4, confederation: 'UEFA' }, + // GROUP D + { name: 'United States', shortName: 'USA', isoCode: 'us', group: 'D', groupOrder: 1, confederation: 'CONCACAF' }, + { name: 'Paraguay', shortName: 'PAR', isoCode: 'py', group: 'D', groupOrder: 2, confederation: 'CONMEBOL' }, + { name: 'Australia', shortName: 'AUS', isoCode: 'au', group: 'D', groupOrder: 3, confederation: 'AFC' }, + { name: 'Turkey', shortName: 'TUR', isoCode: 'tr', group: 'D', groupOrder: 4, confederation: 'UEFA' }, + // GROUP E + { name: 'Germany', shortName: 'GER', isoCode: 'de', group: 'E', groupOrder: 1, confederation: 'UEFA' }, + { name: 'Curaçao', shortName: 'CUW', isoCode: 'cw', group: 'E', groupOrder: 2, confederation: 'CONCACAF' }, + { name: 'Ivory Coast', shortName: 'CIV', isoCode: 'ci', group: 'E', groupOrder: 3, confederation: 'CAF' }, + { name: 'Ecuador', shortName: 'ECU', isoCode: 'ec', group: 'E', groupOrder: 4, confederation: 'CONMEBOL' }, + // GROUP F + { name: 'Netherlands', shortName: 'NED', isoCode: 'nl', group: 'F', groupOrder: 1, confederation: 'UEFA' }, + { name: 'Japan', shortName: 'JPN', isoCode: 'jp', group: 'F', groupOrder: 2, confederation: 'AFC' }, + { name: 'Sweden', shortName: 'SWE', isoCode: 'se', group: 'F', groupOrder: 3, confederation: 'UEFA' }, + { name: 'Tunisia', shortName: 'TUN', isoCode: 'tn', group: 'F', groupOrder: 4, confederation: 'CAF' }, + // GROUP G + { name: 'Belgium', shortName: 'BEL', isoCode: 'be', group: 'G', groupOrder: 1, confederation: 'UEFA' }, + { name: 'Egypt', shortName: 'EGY', isoCode: 'eg', group: 'G', groupOrder: 2, confederation: 'CAF' }, + { name: 'Iran', shortName: 'IRN', isoCode: 'ir', group: 'G', groupOrder: 3, confederation: 'AFC' }, + { name: 'New Zealand', shortName: 'NZL', isoCode: 'nz', group: 'G', groupOrder: 4, confederation: 'OFC' }, + // GROUP H + { name: 'Spain', shortName: 'ESP', isoCode: 'es', group: 'H', groupOrder: 1, confederation: 'UEFA' }, + { name: 'Cape Verde', shortName: 'CPV', isoCode: 'cv', group: 'H', groupOrder: 2, confederation: 'CAF' }, + { name: 'Saudi Arabia', shortName: 'KSA', isoCode: 'sa', group: 'H', groupOrder: 3, confederation: 'AFC' }, + { name: 'Uruguay', shortName: 'URU', isoCode: 'uy', group: 'H', groupOrder: 4, confederation: 'CONMEBOL' }, + // GROUP I + { name: 'France', shortName: 'FRA', isoCode: 'fr', group: 'I', groupOrder: 1, confederation: 'UEFA' }, + { name: 'Senegal', shortName: 'SEN', isoCode: 'sn', group: 'I', groupOrder: 2, confederation: 'CAF' }, + { name: 'Iraq', shortName: 'IRQ', isoCode: 'iq', group: 'I', groupOrder: 3, confederation: 'AFC' }, + { name: 'Norway', shortName: 'NOR', isoCode: 'no', group: 'I', groupOrder: 4, confederation: 'UEFA' }, + // GROUP J + { name: 'Argentina', shortName: 'ARG', isoCode: 'ar', group: 'J', groupOrder: 1, confederation: 'CONMEBOL' }, + { name: 'Algeria', shortName: 'ALG', isoCode: 'dz', group: 'J', groupOrder: 2, confederation: 'CAF' }, + { name: 'Austria', shortName: 'AUT', isoCode: 'at', group: 'J', groupOrder: 3, confederation: 'UEFA' }, + { name: 'Jordan', shortName: 'JOR', isoCode: 'jo', group: 'J', groupOrder: 4, confederation: 'AFC' }, + // GROUP K + { name: 'Portugal', shortName: 'POR', isoCode: 'pt', group: 'K', groupOrder: 1, confederation: 'UEFA' }, + { name: 'DR Congo', shortName: 'COD', isoCode: 'cd', group: 'K', groupOrder: 2, confederation: 'CAF' }, + { name: 'Uzbekistan', shortName: 'UZB', isoCode: 'uz', group: 'K', groupOrder: 3, confederation: 'AFC' }, + { name: 'Colombia', shortName: 'COL', isoCode: 'co', group: 'K', groupOrder: 4, confederation: 'CONMEBOL' }, + // GROUP L + { name: 'England', shortName: 'ENG', isoCode: 'gb-eng', group: 'L', groupOrder: 1, confederation: 'UEFA' }, + { name: 'Croatia', shortName: 'CRO', isoCode: 'hr', group: 'L', groupOrder: 2, confederation: 'UEFA' }, + { name: 'Ghana', shortName: 'GHA', isoCode: 'gh', group: 'L', groupOrder: 3, confederation: 'CAF' }, + { name: 'Panama', shortName: 'PAN', isoCode: 'pa', group: 'L', groupOrder: 4, confederation: 'CONCACAF' }, +] + +// Fechas por grupo: cada grupo tiene 3 fechas (jornadas) +// Jornada 1: 11-16 junio | Jornada 2: 17-23 junio | Jornada 3: 25-30 junio +const groupDates: Record = { + A: { md1a: new Date('2026-06-11T18:00:00Z'), md1b: new Date('2026-06-11T21:00:00Z'), md2a: new Date('2026-06-17T18:00:00Z'), md2b: new Date('2026-06-17T21:00:00Z'), md3: new Date('2026-06-25T21:00:00Z') }, + B: { md1a: new Date('2026-06-12T18:00:00Z'), md1b: new Date('2026-06-12T21:00:00Z'), md2a: new Date('2026-06-18T18:00:00Z'), md2b: new Date('2026-06-18T21:00:00Z'), md3: new Date('2026-06-26T21:00:00Z') }, + C: { md1a: new Date('2026-06-12T18:00:00Z'), md1b: new Date('2026-06-12T21:00:00Z'), md2a: new Date('2026-06-18T18:00:00Z'), md2b: new Date('2026-06-18T21:00:00Z'), md3: new Date('2026-06-26T18:00:00Z') }, + D: { md1a: new Date('2026-06-13T18:00:00Z'), md1b: new Date('2026-06-13T21:00:00Z'), md2a: new Date('2026-06-19T18:00:00Z'), md2b: new Date('2026-06-19T21:00:00Z'), md3: new Date('2026-06-27T21:00:00Z') }, + E: { md1a: new Date('2026-06-13T18:00:00Z'), md1b: new Date('2026-06-13T21:00:00Z'), md2a: new Date('2026-06-19T18:00:00Z'), md2b: new Date('2026-06-19T21:00:00Z'), md3: new Date('2026-06-27T18:00:00Z') }, + F: { md1a: new Date('2026-06-14T18:00:00Z'), md1b: new Date('2026-06-14T21:00:00Z'), md2a: new Date('2026-06-20T18:00:00Z'), md2b: new Date('2026-06-20T21:00:00Z'), md3: new Date('2026-06-28T21:00:00Z') }, + G: { md1a: new Date('2026-06-14T18:00:00Z'), md1b: new Date('2026-06-14T21:00:00Z'), md2a: new Date('2026-06-20T18:00:00Z'), md2b: new Date('2026-06-20T21:00:00Z'), md3: new Date('2026-06-28T18:00:00Z') }, + H: { md1a: new Date('2026-06-15T18:00:00Z'), md1b: new Date('2026-06-15T21:00:00Z'), md2a: new Date('2026-06-21T18:00:00Z'), md2b: new Date('2026-06-21T21:00:00Z'), md3: new Date('2026-06-29T21:00:00Z') }, + I: { md1a: new Date('2026-06-15T18:00:00Z'), md1b: new Date('2026-06-15T21:00:00Z'), md2a: new Date('2026-06-21T18:00:00Z'), md2b: new Date('2026-06-21T21:00:00Z'), md3: new Date('2026-06-29T18:00:00Z') }, + J: { md1a: new Date('2026-06-16T18:00:00Z'), md1b: new Date('2026-06-16T21:00:00Z'), md2a: new Date('2026-06-22T18:00:00Z'), md2b: new Date('2026-06-22T21:00:00Z'), md3: new Date('2026-06-30T21:00:00Z') }, + K: { md1a: new Date('2026-06-16T18:00:00Z'), md1b: new Date('2026-06-16T21:00:00Z'), md2a: new Date('2026-06-22T18:00:00Z'), md2b: new Date('2026-06-22T21:00:00Z'), md3: new Date('2026-06-30T18:00:00Z') }, + L: { md1a: new Date('2026-06-17T18:00:00Z'), md1b: new Date('2026-06-17T21:00:00Z'), md2a: new Date('2026-06-23T18:00:00Z'), md2b: new Date('2026-06-23T21:00:00Z'), md3: new Date('2026-07-01T21:00:00Z') }, +} + +async function main() { + console.log('Seeding teams...') + + const teamMap: Record = {} + + for (const team of teams) { + const t = await prisma.team.upsert({ + where: { shortName: team.shortName }, + update: team, + create: team, + }) + teamMap[team.shortName] = t.id + } + + console.log(`${teams.length} teams seeded.`) + + // ── GROUP STAGE MATCHES ───────────────────────────────────────────────────── + console.log('Seeding group stage matches...') + + const groups = ['A', 'B', 'C', 'D', 'E', 'F', 'G', 'H', 'I', 'J', 'K', 'L'] + + for (let gi = 0; gi < groups.length; gi++) { + const g = groups[gi] + const baseMatchNum = gi * 6 + 1 + const gTeams = teams.filter(t => t.group === g).sort((a, b) => a.groupOrder - b.groupOrder) + const [t1, t2, t3, t4] = gTeams + const d = groupDates[g] + + const groupMatches = [ + // Jornada 1 + { matchNumber: baseMatchNum, homeShort: t1.shortName, awayShort: t2.shortName, scheduledAt: d.md1a, matchday: 1 }, + { matchNumber: baseMatchNum + 1, homeShort: t3.shortName, awayShort: t4.shortName, scheduledAt: d.md1b, matchday: 1 }, + // Jornada 2 + { matchNumber: baseMatchNum + 2, homeShort: t1.shortName, awayShort: t3.shortName, scheduledAt: d.md2a, matchday: 2 }, + { matchNumber: baseMatchNum + 3, homeShort: t2.shortName, awayShort: t4.shortName, scheduledAt: d.md2b, matchday: 2 }, + // Jornada 3 (simultáneos) + { matchNumber: baseMatchNum + 4, homeShort: t1.shortName, awayShort: t4.shortName, scheduledAt: d.md3, matchday: 3 }, + { matchNumber: baseMatchNum + 5, homeShort: t2.shortName, awayShort: t3.shortName, scheduledAt: d.md3, matchday: 3 }, + ] + + for (const m of groupMatches) { + await prisma.match.upsert({ + where: { matchNumber: m.matchNumber }, + update: {}, + create: { + matchNumber: m.matchNumber, + phase: Phase.GROUP_STAGE, + group: g, + matchday: m.matchday, + homeTeamId: teamMap[m.homeShort], + awayTeamId: teamMap[m.awayShort], + scheduledAt: m.scheduledAt, + status: MatchStatus.SCHEDULED, + }, + }) + } + } + + // ── ROUND OF 32 (16 partidos) ──────────────────────────────────────────────── + console.log('Seeding knockout matches...') + + const r32Labels = [ + { home: '1° Grupo A', away: '3° Grupo B/C/D' }, + { home: '1° Grupo C', away: '3° Grupo A/B/F' }, + { home: '1° Grupo B', away: '3° Grupo A/C/D' }, + { home: '1° Grupo D', away: '3° Grupo B/C/F' }, + { home: '1° Grupo E', away: '3° Grupo A/B/E' }, + { home: '1° Grupo G', away: '3° Grupo A/C/E' }, + { home: '1° Grupo F', away: '3° Grupo A/D/E' }, + { home: '1° Grupo H', away: '3° Grupo B/D/E' }, + { home: '1° Grupo I', away: '3° Grupo C/D/F' }, + { home: '1° Grupo K', away: '3° Grupo B/E/F' }, + { home: '1° Grupo J', away: '3° Grupo C/E/F' }, + { home: '1° Grupo L', away: '3° Grupo A/E/F' }, + { home: '2° Grupo A', away: '2° Grupo B' }, + { home: '2° Grupo C', away: '2° Grupo D' }, + { home: '2° Grupo E', away: '2° Grupo F' }, + { home: '2° Grupo G', away: '2° Grupo H' }, + ] + + const r32Dates = [ + new Date('2026-07-04T18:00:00Z'), new Date('2026-07-04T21:00:00Z'), + new Date('2026-07-05T18:00:00Z'), new Date('2026-07-05T21:00:00Z'), + new Date('2026-07-06T18:00:00Z'), new Date('2026-07-06T21:00:00Z'), + new Date('2026-07-07T18:00:00Z'), new Date('2026-07-07T21:00:00Z'), + new Date('2026-07-08T18:00:00Z'), new Date('2026-07-08T21:00:00Z'), + new Date('2026-07-09T18:00:00Z'), new Date('2026-07-09T21:00:00Z'), + new Date('2026-07-10T18:00:00Z'), new Date('2026-07-10T21:00:00Z'), + new Date('2026-07-11T18:00:00Z'), new Date('2026-07-11T21:00:00Z'), + ] + + for (let i = 0; i < 16; i++) { + await prisma.match.upsert({ + where: { matchNumber: 73 + i }, + update: {}, + create: { + matchNumber: 73 + i, + phase: Phase.ROUND_OF_32, + homeLabel: r32Labels[i].home, + awayLabel: r32Labels[i].away, + scheduledAt: r32Dates[i], + status: MatchStatus.SCHEDULED, + }, + }) + } + + // ── ROUND OF 16 (8 partidos) ───────────────────────────────────────────────── + const r16Dates = [ + new Date('2026-07-13T18:00:00Z'), new Date('2026-07-13T21:00:00Z'), + new Date('2026-07-14T18:00:00Z'), new Date('2026-07-14T21:00:00Z'), + new Date('2026-07-15T18:00:00Z'), new Date('2026-07-15T21:00:00Z'), + new Date('2026-07-16T18:00:00Z'), new Date('2026-07-16T21:00:00Z'), + ] + + for (let i = 0; i < 8; i++) { + await prisma.match.upsert({ + where: { matchNumber: 89 + i }, + update: {}, + create: { + matchNumber: 89 + i, + phase: Phase.ROUND_OF_16, + homeLabel: `Ganador partido ${73 + i * 2}`, + awayLabel: `Ganador partido ${74 + i * 2}`, + scheduledAt: r16Dates[i], + status: MatchStatus.SCHEDULED, + }, + }) + } + + // ── QUARTER FINALS (4 partidos) ─────────────────────────────────────────────── + const qfDates = [ + new Date('2026-07-18T18:00:00Z'), new Date('2026-07-18T21:00:00Z'), + new Date('2026-07-19T18:00:00Z'), new Date('2026-07-19T21:00:00Z'), + ] + + for (let i = 0; i < 4; i++) { + await prisma.match.upsert({ + where: { matchNumber: 97 + i }, + update: {}, + create: { + matchNumber: 97 + i, + phase: Phase.QUARTER_FINALS, + homeLabel: `Ganador partido ${89 + i * 2}`, + awayLabel: `Ganador partido ${90 + i * 2}`, + scheduledAt: qfDates[i], + status: MatchStatus.SCHEDULED, + }, + }) + } + + // ── SEMI FINALS (2 partidos) ────────────────────────────────────────────────── + await prisma.match.upsert({ + where: { matchNumber: 101 }, + update: {}, + create: { + matchNumber: 101, + phase: Phase.SEMI_FINALS, + homeLabel: 'Ganador partido 97', + awayLabel: 'Ganador partido 98', + scheduledAt: new Date('2026-07-22T21:00:00Z'), + status: MatchStatus.SCHEDULED, + }, + }) + + await prisma.match.upsert({ + where: { matchNumber: 102 }, + update: {}, + create: { + matchNumber: 102, + phase: Phase.SEMI_FINALS, + homeLabel: 'Ganador partido 99', + awayLabel: 'Ganador partido 100', + scheduledAt: new Date('2026-07-23T21:00:00Z'), + status: MatchStatus.SCHEDULED, + }, + }) + + // ── TERCER PUESTO ───────────────────────────────────────────────────────────── + await prisma.match.upsert({ + where: { matchNumber: 103 }, + update: {}, + create: { + matchNumber: 103, + phase: Phase.THIRD_PLACE, + homeLabel: 'Perdedor Semi 1', + awayLabel: 'Perdedor Semi 2', + scheduledAt: new Date('2026-07-25T18:00:00Z'), + status: MatchStatus.SCHEDULED, + }, + }) + + // ── FINAL ───────────────────────────────────────────────────────────────────── + await prisma.match.upsert({ + where: { matchNumber: 104 }, + update: {}, + create: { + matchNumber: 104, + phase: Phase.FINAL, + homeLabel: 'Ganador Semi 1', + awayLabel: 'Ganador Semi 2', + scheduledAt: new Date('2026-07-26T20:00:00Z'), + status: MatchStatus.SCHEDULED, + }, + }) + + console.log('104 matches seeded.') + + // ── ADMIN USER ──────────────────────────────────────────────────────────────── + const adminPassword = await bcrypt.hash('admin2026', 12) + await prisma.user.upsert({ + where: { email: 'admin@prode.com' }, + update: {}, + create: { + email: 'admin@prode.com', + password: adminPassword, + name: 'Admin', + role: 'ADMIN', + }, + }) + + console.log('Admin user: admin@prode.com / admin2026') + console.log('Seed complete!') +} + +main() + .catch(e => { console.error(e); process.exit(1) }) + .finally(() => prisma.$disconnect()) diff --git a/app/public/manifest.json b/app/public/manifest.json new file mode 100644 index 0000000..95de884 --- /dev/null +++ b/app/public/manifest.json @@ -0,0 +1,21 @@ +{ + "name": "Prode Mundial 2026", + "short_name": "Prode", + "description": "Pronosticá los partidos del Mundial 2026", + "start_url": "/", + "display": "standalone", + "background_color": "#14532d", + "theme_color": "#16a34a", + "icons": [ + { + "src": "/icon-192.png", + "sizes": "192x192", + "type": "image/png" + }, + { + "src": "/icon-512.png", + "sizes": "512x512", + "type": "image/png" + } + ] +} diff --git a/app/src/app/(app)/layout.tsx b/app/src/app/(app)/layout.tsx new file mode 100644 index 0000000..2b0c94b --- /dev/null +++ b/app/src/app/(app)/layout.tsx @@ -0,0 +1,16 @@ +import { redirect } from 'next/navigation' +import { getSession } from '@/lib/auth' +import { Nav } from '@/components/Nav' + +export default async function AppLayout({ children }: { children: React.ReactNode }) { + const session = await getSession() + if (!session) redirect('/login') + + return ( +
+ {children} +
{/* spacer for fixed nav */} +
+ ) +} diff --git a/app/src/app/(app)/leaderboard/page.tsx b/app/src/app/(app)/leaderboard/page.tsx new file mode 100644 index 0000000..812913a --- /dev/null +++ b/app/src/app/(app)/leaderboard/page.tsx @@ -0,0 +1,83 @@ +'use client' + +import { useState, useEffect } from 'react' +import type { Phase, LeaderboardEntry } from '@/types' +import { PHASE_LABELS, PHASE_ORDER } from '@/types' + +export default function LeaderboardPage() { + const [phase, setPhase] = useState('all') + const [data, setData] = useState([]) + const [loading, setLoading] = useState(true) + + useEffect(() => { + setLoading(true) + const url = phase === 'all' ? '/api/leaderboard' : `/api/leaderboard?phase=${phase}` + fetch(url) + .then(r => r.json()) + .then(d => { setData(d); setLoading(false) }) + }, [phase]) + + const medals = ['🥇', '🥈', '🥉'] + + return ( +
+ {/* Header */} +
+

🏆 Ranking

+
+ + {PHASE_ORDER.map(p => ( + + ))} +
+
+ +
+ {loading ? ( +
+
+
+ ) : data.length === 0 ? ( +

Aún no hay puntos registrados

+ ) : ( +
+ {data.map((entry, i) => ( +
+ + {medals[i] ?? {entry.rank}} + +
+

{entry.name}

+

{entry.count} pronósticos

+
+
+

= 0 ? 'text-green-600' : 'text-red-500'}`}> + {entry.total >= 0 ? '+' : ''}{entry.total} +

+

puntos

+
+
+ ))} +
+ )} +
+
+ ) +} diff --git a/app/src/app/(app)/predictions/page.tsx b/app/src/app/(app)/predictions/page.tsx new file mode 100644 index 0000000..797c007 --- /dev/null +++ b/app/src/app/(app)/predictions/page.tsx @@ -0,0 +1,97 @@ +'use client' + +import { useState, useEffect, useCallback } from 'react' +import { MatchCard } from '@/components/MatchCard' +import { PhaseFilter } from '@/components/PhaseFilter' +import type { MatchData, Phase, PredictionResult } from '@/types' +import { PHASE_LABELS } from '@/types' + +export default function PredictionsPage() { + const [phase, setPhase] = useState('GROUP_STAGE') + const [matches, setMatches] = useState([]) + const [loading, setLoading] = useState(true) + const [savingId, setSavingId] = useState(null) + + const fetchMatches = useCallback(async (p: Phase) => { + setLoading(true) + const res = await fetch(`/api/matches?phase=${p}`) + if (res.ok) { + const data = await res.json() + setMatches(data) + } + setLoading(false) + }, []) + + useEffect(() => { fetchMatches(phase) }, [phase, fetchMatches]) + + const handlePredict = async (matchId: string, result: PredictionResult) => { + setSavingId(matchId) + const res = await fetch('/api/predictions', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ matchId, result }), + }) + if (res.ok) { + setMatches(prev => + prev.map(m => m.id === matchId ? { ...m, userPrediction: result } : m) + ) + } + setSavingId(null) + } + + const groups = phase === 'GROUP_STAGE' + ? [...new Set(matches.map(m => m.group).filter(Boolean))] as string[] + : null + + return ( +
+ {/* Header */} +
+

⚽ Prode Mundial 2026

+ +
+ +
+ {loading ? ( +
+
+
+ ) : matches.length === 0 ? ( +

No hay partidos disponibles

+ ) : groups ? ( + groups.map(g => ( +
+

+ Grupo {g} +

+ {matches + .filter(m => m.group === g) + .map(m => ( + + ))} +
+ )) + ) : ( +
+

+ {PHASE_LABELS[phase]} +

+ {matches.map(m => ( + + ))} +
+ )} +
+
+ ) +} diff --git a/app/src/app/(auth)/login/page.tsx b/app/src/app/(auth)/login/page.tsx new file mode 100644 index 0000000..e8eacdd --- /dev/null +++ b/app/src/app/(auth)/login/page.tsx @@ -0,0 +1,96 @@ +'use client' + +import { useState } from 'react' +import Link from 'next/link' +import { useRouter } from 'next/navigation' + +export default function LoginPage() { + const router = useRouter() + 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) + + const res = await fetch('/api/auth/login', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ email, password }), + }) + + const data = await res.json() + setLoading(false) + + if (!res.ok) { + setError(data.error ?? 'Error al iniciar sesión') + return + } + + router.push('/predictions') + } + + return ( +
+ {/* Logo */} +
+
⚽
+

Prode Mundial

+

FIFA World Cup 2026

+
+ + {/* Card */} +
+

Iniciar sesión

+ +
+
+ + setEmail(e.target.value)} + required + placeholder="tu@email.com" + className="w-full border border-gray-300 rounded-xl px-4 py-3 text-sm focus:outline-none focus:ring-2 focus:ring-green-500" + /> +
+ +
+ + setPassword(e.target.value)} + required + placeholder="••••••" + className="w-full border border-gray-300 rounded-xl px-4 py-3 text-sm focus:outline-none focus:ring-2 focus:ring-green-500" + /> +
+ + {error && ( +

{error}

+ )} + + +
+ +

+ ¿No tenés cuenta?{' '} + + Registrate + +

+
+
+ ) +} diff --git a/app/src/app/(auth)/register/page.tsx b/app/src/app/(auth)/register/page.tsx new file mode 100644 index 0000000..bcf8aea --- /dev/null +++ b/app/src/app/(auth)/register/page.tsx @@ -0,0 +1,108 @@ +'use client' + +import { useState } from 'react' +import Link from 'next/link' +import { useRouter } from 'next/navigation' + +export default function RegisterPage() { + const router = useRouter() + const [name, setName] = useState('') + 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) + + const res = await fetch('/api/auth/register', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ name, email, password }), + }) + + const data = await res.json() + setLoading(false) + + if (!res.ok) { + setError(data.error ?? 'Error al registrarse') + return + } + + router.push('/predictions') + } + + return ( +
+
+
⚽
+

Prode Mundial

+

FIFA World Cup 2026

+
+ +
+

Crear cuenta

+ +
+
+ + setName(e.target.value)} + required + placeholder="Tu nombre" + className="w-full border border-gray-300 rounded-xl px-4 py-3 text-sm focus:outline-none focus:ring-2 focus:ring-green-500" + /> +
+ +
+ + setEmail(e.target.value)} + required + placeholder="tu@email.com" + className="w-full border border-gray-300 rounded-xl px-4 py-3 text-sm focus:outline-none focus:ring-2 focus:ring-green-500" + /> +
+ +
+ + setPassword(e.target.value)} + required + minLength={6} + placeholder="Mínimo 6 caracteres" + className="w-full border border-gray-300 rounded-xl px-4 py-3 text-sm focus:outline-none focus:ring-2 focus:ring-green-500" + /> +
+ + {error && ( +

{error}

+ )} + + +
+ +

+ ¿Ya tenés cuenta?{' '} + + Iniciar sesión + +

+
+
+ ) +} diff --git a/app/src/app/admin/layout.tsx b/app/src/app/admin/layout.tsx new file mode 100644 index 0000000..8a075e7 --- /dev/null +++ b/app/src/app/admin/layout.tsx @@ -0,0 +1,16 @@ +import { redirect } from 'next/navigation' +import { getSession } from '@/lib/auth' +import { Nav } from '@/components/Nav' + +export default async function AdminLayout({ children }: { children: React.ReactNode }) { + const session = await getSession() + if (!session || session.role !== 'ADMIN') redirect('/predictions') + + return ( +
+ {children} +
+
+ ) +} diff --git a/app/src/app/admin/page.tsx b/app/src/app/admin/page.tsx new file mode 100644 index 0000000..3faceba --- /dev/null +++ b/app/src/app/admin/page.tsx @@ -0,0 +1,151 @@ +'use client' + +import { useState, useEffect } from 'react' +import { Flag } from '@/components/Flag' +import type { MatchData, Phase } from '@/types' +import { PHASE_LABELS, PHASE_ORDER } from '@/types' + +export default function AdminPage() { + const [phase, setPhase] = useState('GROUP_STAGE') + const [matches, setMatches] = useState([]) + const [loading, setLoading] = useState(true) + const [scores, setScores] = useState>({}) + const [saving, setSaving] = useState(null) + const [syncing, setSyncing] = useState(false) + + useEffect(() => { + setLoading(true) + fetch(`/api/matches?phase=${phase}`) + .then(r => r.json()) + .then(d => { + setMatches(d) + const initial: Record = {} + d.forEach((m: MatchData) => { + initial[m.id] = { + home: m.homeScore !== null ? String(m.homeScore) : '', + away: m.awayScore !== null ? String(m.awayScore) : '', + } + }) + setScores(initial) + setLoading(false) + }) + }, [phase]) + + const saveResult = async (matchId: string) => { + const s = scores[matchId] + if (s.home === '' || s.away === '') return + setSaving(matchId) + await fetch(`/api/admin/matches/${matchId}`, { + method: 'PATCH', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ homeScore: Number(s.home), awayScore: Number(s.away), status: 'FINISHED' }), + }) + setMatches(prev => prev.map(m => + m.id === matchId + ? { ...m, homeScore: Number(s.home), awayScore: Number(s.away), status: 'FINISHED' } + : m + )) + setSaving(null) + } + + return ( +
+
+
+

⚙️ Admin

+ +
+ +
+ {PHASE_ORDER.map(p => ( + + ))} +
+
+ +
+ {loading ? ( +
+
+
+ ) : matches.map(m => { + const homeName = m.homeTeam?.shortName ?? m.homeLabel ?? '?' + const awayName = m.awayTeam?.shortName ?? m.awayLabel ?? '?' + const s = scores[m.id] ?? { home: '', away: '' } + const date = new Date(m.scheduledAt).toLocaleDateString('es-AR', { day: '2-digit', month: 'short', hour: '2-digit', minute: '2-digit' }) + + return ( +
+
+ #{m.matchNumber} · {date} + + {m.status} + +
+ +
+ {/* Home */} +
+ {m.homeTeam && } + {homeName} +
+ + {/* Score inputs */} +
+ setScores(prev => ({ ...prev, [m.id]: { ...prev[m.id], home: e.target.value } }))} + className="w-10 text-center border border-gray-300 rounded-lg py-1 font-bold text-sm focus:outline-none focus:ring-2 focus:ring-gray-400" + /> + - + setScores(prev => ({ ...prev, [m.id]: { ...prev[m.id], away: e.target.value } }))} + className="w-10 text-center border border-gray-300 rounded-lg py-1 font-bold text-sm focus:outline-none focus:ring-2 focus:ring-gray-400" + /> +
+ + {/* Away */} +
+ {awayName} + {m.awayTeam && } +
+
+ + +
+ ) + })} +
+
+ ) +} diff --git a/app/src/app/api/admin/matches/[id]/route.ts b/app/src/app/api/admin/matches/[id]/route.ts new file mode 100644 index 0000000..eb5de02 --- /dev/null +++ b/app/src/app/api/admin/matches/[id]/route.ts @@ -0,0 +1,46 @@ +import { NextResponse } from 'next/server' +import { prisma } from '@/lib/prisma' +import { getSession } from '@/lib/auth' +import { calculatePoints } from '@/lib/scoring' + +export async function PATCH( + req: Request, + { params }: { params: Promise<{ id: string }> } +) { + const session = await getSession() + if (!session || session.role !== 'ADMIN') { + return NextResponse.json({ error: 'No autorizado' }, { status: 403 }) + } + + const { id } = await params + const { homeScore, awayScore, status } = await req.json() + + if (homeScore === undefined || awayScore === undefined) { + return NextResponse.json({ error: 'Scores requeridos' }, { status: 400 }) + } + + const match = await prisma.match.update({ + where: { id }, + data: { + homeScore: Number(homeScore), + awayScore: Number(awayScore), + status: status ?? 'FINISHED', + }, + }) + + // Calcular puntos para todas las predicciones de este partido + if (match.status === 'FINISHED' && match.homeScore !== null && match.awayScore !== null) { + const predictions = await prisma.prediction.findMany({ where: { matchId: id } }) + + await Promise.all( + predictions.map(p => + prisma.prediction.update({ + where: { id: p.id }, + data: { points: calculatePoints(p.result as 'HOME' | 'DRAW' | 'AWAY', match.homeScore!, match.awayScore!) }, + }) + ) + ) + } + + return NextResponse.json({ ok: true, match }) +} diff --git a/app/src/app/api/admin/sync/route.ts b/app/src/app/api/admin/sync/route.ts new file mode 100644 index 0000000..8a1095a --- /dev/null +++ b/app/src/app/api/admin/sync/route.ts @@ -0,0 +1,22 @@ +import { NextResponse } from 'next/server' + +export async function POST(req: Request) { + const { searchParams } = new URL(req.url) + const secret = searchParams.get('secret') + + if (secret !== process.env.ADMIN_SYNC_SECRET) { + return NextResponse.json({ error: 'No autorizado' }, { status: 401 }) + } + + const url = process.env.RESULTS_API_URL + if (!url) { + return NextResponse.json({ error: 'RESULTS_API_URL no configurada' }, { status: 400 }) + } + + // TODO: implementar cuando se provea la URL del sitio externo + // const res = await fetch(url, { headers: { Authorization: `Bearer ${process.env.RESULTS_API_KEY}` } }) + // const data = await res.json() + // ... procesar y actualizar partidos + + return NextResponse.json({ ok: true, message: 'Endpoint listo. Configurar RESULTS_API_URL para activar sync.' }) +} diff --git a/app/src/app/api/auth/login/route.ts b/app/src/app/api/auth/login/route.ts new file mode 100644 index 0000000..e097d04 --- /dev/null +++ b/app/src/app/api/auth/login/route.ts @@ -0,0 +1,38 @@ +import { NextResponse } from 'next/server' +import bcrypt from 'bcryptjs' +import { prisma } from '@/lib/prisma' +import { signToken } from '@/lib/auth' + +export async function POST(req: Request) { + try { + const { email, password } = await req.json() + + if (!email || !password) { + return NextResponse.json({ error: 'Email y contraseña requeridos' }, { status: 400 }) + } + + const user = await prisma.user.findUnique({ where: { email } }) + if (!user) { + return NextResponse.json({ error: 'Credenciales incorrectas' }, { status: 401 }) + } + + const valid = await bcrypt.compare(password, user.password) + if (!valid) { + return NextResponse.json({ error: 'Credenciales incorrectas' }, { status: 401 }) + } + + const token = await signToken({ id: user.id, email: user.email, name: user.name, role: user.role }) + + const response = NextResponse.json({ user: { id: user.id, email: user.email, name: user.name, role: user.role } }) + response.cookies.set('prode_token', token, { + httpOnly: true, + sameSite: 'lax', + maxAge: 7 * 24 * 60 * 60, + path: '/', + }) + return response + } catch (e) { + console.error(e) + return NextResponse.json({ error: 'Error interno' }, { status: 500 }) + } +} diff --git a/app/src/app/api/auth/logout/route.ts b/app/src/app/api/auth/logout/route.ts new file mode 100644 index 0000000..ad005d4 --- /dev/null +++ b/app/src/app/api/auth/logout/route.ts @@ -0,0 +1,7 @@ +import { NextResponse } from 'next/server' + +export async function POST() { + const response = NextResponse.json({ ok: true }) + response.cookies.set('prode_token', '', { maxAge: 0, path: '/' }) + return response +} diff --git a/app/src/app/api/auth/register/route.ts b/app/src/app/api/auth/register/route.ts new file mode 100644 index 0000000..ad993e3 --- /dev/null +++ b/app/src/app/api/auth/register/route.ts @@ -0,0 +1,41 @@ +import { NextResponse } from 'next/server' +import bcrypt from 'bcryptjs' +import { prisma } from '@/lib/prisma' +import { signToken } from '@/lib/auth' + +export async function POST(req: Request) { + try { + const { email, password, name } = await req.json() + + if (!email || !password || !name) { + return NextResponse.json({ error: 'Todos los campos son requeridos' }, { status: 400 }) + } + if (password.length < 6) { + return NextResponse.json({ error: 'La contraseña debe tener al menos 6 caracteres' }, { status: 400 }) + } + + const existing = await prisma.user.findUnique({ where: { email } }) + if (existing) { + return NextResponse.json({ error: 'Ya existe una cuenta con ese email' }, { status: 409 }) + } + + const hashed = await bcrypt.hash(password, 12) + const user = await prisma.user.create({ + data: { email, password: hashed, name, role: 'PLAYER' }, + }) + + const token = await signToken({ id: user.id, email: user.email, name: user.name, role: user.role }) + + const response = NextResponse.json({ user: { id: user.id, email: user.email, name: user.name, role: user.role } }) + response.cookies.set('prode_token', token, { + httpOnly: true, + sameSite: 'lax', + maxAge: 7 * 24 * 60 * 60, + path: '/', + }) + return response + } catch (e) { + console.error(e) + return NextResponse.json({ error: 'Error interno' }, { status: 500 }) + } +} diff --git a/app/src/app/api/leaderboard/route.ts b/app/src/app/api/leaderboard/route.ts new file mode 100644 index 0000000..7e66f99 --- /dev/null +++ b/app/src/app/api/leaderboard/route.ts @@ -0,0 +1,36 @@ +import { NextResponse } from 'next/server' +import { prisma } from '@/lib/prisma' +import { getSession } from '@/lib/auth' +import type { Phase } from '@/types' + +export async function GET(req: Request) { + const session = await getSession() + if (!session) return NextResponse.json({ error: 'No autorizado' }, { status: 401 }) + + const { searchParams } = new URL(req.url) + const phase = searchParams.get('phase') as Phase | null + + const predictions = await prisma.prediction.findMany({ + where: { + points: { not: null }, + ...(phase ? { match: { phase } } : {}), + }, + include: { user: { select: { id: true, name: true, email: true } } }, + }) + + const map = new Map() + + for (const p of predictions) { + const existing = map.get(p.userId) ?? { name: p.user.name, email: p.user.email, total: 0, count: 0 } + existing.total += p.points ?? 0 + existing.count += 1 + map.set(p.userId, existing) + } + + const sorted = [...map.entries()] + .map(([userId, data]) => ({ userId, ...data })) + .sort((a, b) => b.total - a.total) + .map((entry, i) => ({ ...entry, rank: i + 1 })) + + return NextResponse.json(sorted) +} diff --git a/app/src/app/api/matches/route.ts b/app/src/app/api/matches/route.ts new file mode 100644 index 0000000..2844140 --- /dev/null +++ b/app/src/app/api/matches/route.ts @@ -0,0 +1,50 @@ +import { NextResponse } from 'next/server' +import { prisma } from '@/lib/prisma' +import { getSession } from '@/lib/auth' +import type { Phase } from '@/types' + +export async function GET(req: Request) { + const session = await getSession() + if (!session) return NextResponse.json({ error: 'No autorizado' }, { status: 401 }) + + const { searchParams } = new URL(req.url) + const phase = searchParams.get('phase') as Phase | null + const group = searchParams.get('group') + + const matches = await prisma.match.findMany({ + where: { + ...(phase ? { phase } : {}), + ...(group ? { group } : {}), + }, + include: { + homeTeam: true, + awayTeam: true, + predictions: { + where: { userId: session.id }, + select: { result: true, points: true }, + }, + }, + orderBy: [{ scheduledAt: 'asc' }, { matchNumber: 'asc' }], + }) + + const result = matches.map(m => ({ + id: m.id, + matchNumber: m.matchNumber, + phase: m.phase, + group: m.group, + matchday: m.matchday, + homeTeam: m.homeTeam, + awayTeam: m.awayTeam, + homeLabel: m.homeLabel, + awayLabel: m.awayLabel, + scheduledAt: m.scheduledAt.toISOString(), + venue: m.venue, + homeScore: m.homeScore, + awayScore: m.awayScore, + status: m.status, + userPrediction: m.predictions[0]?.result ?? null, + userPoints: m.predictions[0]?.points ?? null, + })) + + return NextResponse.json(result) +} diff --git a/app/src/app/api/predictions/route.ts b/app/src/app/api/predictions/route.ts new file mode 100644 index 0000000..1ab7672 --- /dev/null +++ b/app/src/app/api/predictions/route.ts @@ -0,0 +1,31 @@ +import { NextResponse } from 'next/server' +import { prisma } from '@/lib/prisma' +import { getSession } from '@/lib/auth' +import { isMatchLocked } from '@/lib/scoring' +import type { PredictionResult } from '@/types' + +export async function POST(req: Request) { + const session = await getSession() + if (!session) return NextResponse.json({ error: 'No autorizado' }, { status: 401 }) + + const { matchId, result } = await req.json() as { matchId: string; result: PredictionResult } + + if (!matchId || !['HOME', 'DRAW', 'AWAY'].includes(result)) { + return NextResponse.json({ error: 'Datos inválidos' }, { status: 400 }) + } + + const match = await prisma.match.findUnique({ where: { id: matchId } }) + if (!match) return NextResponse.json({ error: 'Partido no encontrado' }, { status: 404 }) + + if (isMatchLocked(match.scheduledAt) || match.status !== 'SCHEDULED') { + return NextResponse.json({ error: 'Ya no se pueden modificar pronósticos para este partido' }, { status: 403 }) + } + + const prediction = await prisma.prediction.upsert({ + where: { userId_matchId: { userId: session.id, matchId } }, + update: { result }, + create: { userId: session.id, matchId, result }, + }) + + return NextResponse.json({ ok: true, prediction }) +} diff --git a/app/src/app/globals.css b/app/src/app/globals.css new file mode 100644 index 0000000..f665511 --- /dev/null +++ b/app/src/app/globals.css @@ -0,0 +1,26 @@ +@tailwind base; +@tailwind components; +@tailwind utilities; + +@layer base { + html { + -webkit-tap-highlight-color: transparent; + } + + body { + @apply bg-gray-50 text-gray-900; + } +} + +@layer utilities { + .scrollbar-hide { + -ms-overflow-style: none; + scrollbar-width: none; + } + .scrollbar-hide::-webkit-scrollbar { + display: none; + } + .safe-area-pb { + padding-bottom: env(safe-area-inset-bottom, 0px); + } +} diff --git a/app/src/app/layout.tsx b/app/src/app/layout.tsx new file mode 100644 index 0000000..71b8141 --- /dev/null +++ b/app/src/app/layout.tsx @@ -0,0 +1,26 @@ +import type { Metadata, Viewport } from 'next' +import { Inter } from 'next/font/google' +import './globals.css' + +const inter = Inter({ subsets: ['latin'] }) + +export const metadata: Metadata = { + title: 'Prode Mundial 2026', + description: 'Pronosticá los partidos del Mundial 2026 con tu familia', + manifest: '/manifest.json', +} + +export const viewport: Viewport = { + width: 'device-width', + initialScale: 1, + maximumScale: 1, + themeColor: '#16a34a', +} + +export default function RootLayout({ children }: { children: React.ReactNode }) { + return ( + + {children} + + ) +} diff --git a/app/src/app/page.tsx b/app/src/app/page.tsx new file mode 100644 index 0000000..30a5dfb --- /dev/null +++ b/app/src/app/page.tsx @@ -0,0 +1,8 @@ +import { redirect } from 'next/navigation' +import { getSession } from '@/lib/auth' + +export default async function Home() { + const session = await getSession() + if (session) redirect('/predictions') + redirect('/login') +} diff --git a/app/src/components/Flag.tsx b/app/src/components/Flag.tsx new file mode 100644 index 0000000..9913af4 --- /dev/null +++ b/app/src/components/Flag.tsx @@ -0,0 +1,25 @@ +'use client' + +interface FlagProps { + isoCode: string + name?: string + size?: number + className?: string +} + +export function Flag({ isoCode, name, size = 32, className = '' }: FlagProps) { + const width = size * 1.5 + const height = size + + return ( + // eslint-disable-next-line @next/next/no-img-element + {name + ) +} diff --git a/app/src/components/MatchCard.tsx b/app/src/components/MatchCard.tsx new file mode 100644 index 0000000..df57447 --- /dev/null +++ b/app/src/components/MatchCard.tsx @@ -0,0 +1,88 @@ +'use client' + +import { Flag } from './Flag' +import { PredictionButtons } from './PredictionButtons' +import type { MatchData, PredictionResult } from '@/types' +import { isMatchLocked } from '@/lib/scoring' + +interface MatchCardProps { + match: MatchData + onPredict: (matchId: string, result: PredictionResult) => void + saving?: boolean +} + +export function MatchCard({ match, onPredict, saving }: MatchCardProps) { + const locked = isMatchLocked(match.scheduledAt) || match.status !== 'SCHEDULED' + const finished = match.status === 'FINISHED' + + const date = new Date(match.scheduledAt) + const dateStr = date.toLocaleDateString('es-AR', { day: '2-digit', month: 'short' }) + const timeStr = date.toLocaleTimeString('es-AR', { hour: '2-digit', minute: '2-digit' }) + + const homeName = match.homeTeam?.shortName ?? match.homeLabel ?? '?' + const awayName = match.awayTeam?.shortName ?? match.awayLabel ?? '?' + + return ( +
+ {/* Date + Status */} +
+ {dateStr} · {timeStr} + {match.status === 'LIVE' && ( + EN VIVO + )} + {finished && ( + FINALIZADO + )} +
+ + {/* Teams */} +
+ {/* Home */} +
+ {match.homeTeam ? ( + + ) : ( +
?
+ )} + {homeName} +
+ + {/* Score / VS */} +
+ {finished ? ( + + {match.homeScore} - {match.awayScore} + + ) : ( + vs + )} +
+ + {/* Away */} +
+ {match.awayTeam ? ( + + ) : ( +
?
+ )} + {awayName} +
+
+ + {/* Prediction */} +
+ onPredict(match.id, result)} + saving={saving} + /> +
+
+ ) +} diff --git a/app/src/components/Nav.tsx b/app/src/components/Nav.tsx new file mode 100644 index 0000000..70a5ca9 --- /dev/null +++ b/app/src/components/Nav.tsx @@ -0,0 +1,52 @@ +'use client' + +import Link from 'next/link' +import { usePathname, useRouter } from 'next/navigation' + +interface NavProps { + isAdmin?: boolean +} + +export function Nav({ isAdmin }: NavProps) { + const pathname = usePathname() + const router = useRouter() + + const logout = async () => { + await fetch('/api/auth/logout', { method: 'POST' }) + router.push('/login') + } + + const links = [ + { href: '/predictions', label: 'Pronósticos', icon: '⚽' }, + { href: '/leaderboard', label: 'Ranking', icon: '🏆' }, + ...(isAdmin ? [{ href: '/admin', label: 'Admin', icon: '⚙️' }] : []), + ] + + return ( + + ) +} diff --git a/app/src/components/PhaseFilter.tsx b/app/src/components/PhaseFilter.tsx new file mode 100644 index 0000000..4d2160f --- /dev/null +++ b/app/src/components/PhaseFilter.tsx @@ -0,0 +1,31 @@ +'use client' + +import type { Phase } from '@/types' +import { PHASE_LABELS, PHASE_ORDER } from '@/types' + +interface PhaseFilterProps { + selected: Phase + onChange: (phase: Phase) => void +} + +export function PhaseFilter({ selected, onChange }: PhaseFilterProps) { + return ( +
+ {PHASE_ORDER.map(phase => ( + + ))} +
+ ) +} diff --git a/app/src/components/PredictionButtons.tsx b/app/src/components/PredictionButtons.tsx new file mode 100644 index 0000000..8f305cb --- /dev/null +++ b/app/src/components/PredictionButtons.tsx @@ -0,0 +1,72 @@ +'use client' + +import type { PredictionResult } from '@/types' + +interface Props { + matchId: string + selected: PredictionResult | null + locked: boolean + finished: boolean + points: number | null + homeName: string + awayName: string + onSelect: (result: PredictionResult) => void + saving?: boolean +} + +const BUTTONS: { result: PredictionResult; label: (h: string, a: string) => string }[] = [ + { result: 'HOME', label: (h) => h }, + { result: 'DRAW', label: () => 'Empate' }, + { result: 'AWAY', label: (_, a) => a }, +] + +const COLORS: Record = { + HOME: 'bg-green-600 text-white border-green-600', + DRAW: 'bg-yellow-500 text-white border-yellow-500', + AWAY: 'bg-blue-600 text-white border-blue-600', +} + +const IDLE = 'bg-white text-gray-600 border-gray-200 hover:border-gray-400' + +export function PredictionButtons({ selected, locked, finished, points, homeName, awayName, onSelect, saving }: Props) { + if (finished && selected) { + const pts = points ?? 0 + const color = pts > 0 ? 'text-green-600' : pts < 0 ? 'text-red-500' : 'text-gray-500' + return ( +
+ + Tu pronóstico: {selected === 'HOME' ? homeName : selected === 'AWAY' ? awayName : 'Empate'} + + + {pts > 0 ? '+' : ''}{pts} pts + +
+ ) + } + + if (finished && !selected) { + return

No pronosticaste este partido

+ } + + return ( +
+ {BUTTONS.map(({ result, label }) => { + const isSelected = selected === result + return ( + + ) + })} +
+ ) +} diff --git a/app/src/lib/auth.ts b/app/src/lib/auth.ts new file mode 100644 index 0000000..168e0e1 --- /dev/null +++ b/app/src/lib/auth.ts @@ -0,0 +1,43 @@ +import { SignJWT, jwtVerify } from 'jose' +import { cookies } from 'next/headers' +import type { JWTPayload } from '@/types' + +const COOKIE_NAME = 'prode_token' +const secret = new TextEncoder().encode( + process.env.JWT_SECRET ?? 'fallback_secret_change_in_production' +) + +export async function signToken(payload: JWTPayload): Promise { + return new SignJWT({ ...payload }) + .setProtectedHeader({ alg: 'HS256' }) + .setIssuedAt() + .setExpirationTime('7d') + .sign(secret) +} + +export async function verifyToken(token: string): Promise { + try { + const { payload } = await jwtVerify(token, secret) + return payload as unknown as JWTPayload + } catch { + return null + } +} + +export async function getSession(): Promise { + const cookieStore = await cookies() + const token = cookieStore.get(COOKIE_NAME)?.value + if (!token) return null + return verifyToken(token) +} + +export function setSessionCookie(token: string) { + const response = new Headers() + response.append( + 'Set-Cookie', + `${COOKIE_NAME}=${token}; Path=/; HttpOnly; SameSite=Lax; Max-Age=${7 * 24 * 60 * 60}` + ) + return response +} + +export { COOKIE_NAME } diff --git a/app/src/lib/prisma.ts b/app/src/lib/prisma.ts new file mode 100644 index 0000000..629630c --- /dev/null +++ b/app/src/lib/prisma.ts @@ -0,0 +1,9 @@ +import { PrismaClient } from '@prisma/client' + +const globalForPrisma = globalThis as unknown as { prisma: PrismaClient } + +export const prisma = + globalForPrisma.prisma ?? + new PrismaClient({ log: process.env.NODE_ENV === 'development' ? ['error'] : [] }) + +if (process.env.NODE_ENV !== 'production') globalForPrisma.prisma = prisma diff --git a/app/src/lib/scoring.ts b/app/src/lib/scoring.ts new file mode 100644 index 0000000..8cf01a5 --- /dev/null +++ b/app/src/lib/scoring.ts @@ -0,0 +1,25 @@ +import type { PredictionResult } from '@/types' + +export function calculatePoints( + prediction: PredictionResult, + homeScore: number, + awayScore: number +): number { + const actual: PredictionResult = + homeScore > awayScore ? 'HOME' : homeScore < awayScore ? 'AWAY' : 'DRAW' + + if (prediction === actual) { + return actual === 'DRAW' ? 1 : 3 + } + return -1 +} + +export function getActualResult(homeScore: number, awayScore: number): PredictionResult { + if (homeScore > awayScore) return 'HOME' + if (homeScore < awayScore) return 'AWAY' + return 'DRAW' +} + +export function isMatchLocked(scheduledAt: Date | string): boolean { + return new Date(scheduledAt) <= new Date() +} diff --git a/app/src/types/index.ts b/app/src/types/index.ts new file mode 100644 index 0000000..a47026a --- /dev/null +++ b/app/src/types/index.ts @@ -0,0 +1,63 @@ +export type Role = 'ADMIN' | 'PLAYER' +export type Phase = 'GROUP_STAGE' | 'ROUND_OF_32' | 'ROUND_OF_16' | 'QUARTER_FINALS' | 'SEMI_FINALS' | 'THIRD_PLACE' | 'FINAL' +export type MatchStatus = 'SCHEDULED' | 'LIVE' | 'FINISHED' +export type PredictionResult = 'HOME' | 'DRAW' | 'AWAY' + +export interface JWTPayload { + id: string + email: string + name: string + role: Role +} + +export interface TeamData { + id: string + name: string + shortName: string + isoCode: string + group: string + confederation: string +} + +export interface MatchData { + id: string + matchNumber: number + phase: Phase + group: string | null + matchday: number | null + homeTeam: TeamData | null + awayTeam: TeamData | null + homeLabel: string | null + awayLabel: string | null + scheduledAt: string + venue: string | null + homeScore: number | null + awayScore: number | null + status: MatchStatus + userPrediction?: PredictionResult | null + userPoints?: number | null +} + +export interface LeaderboardEntry { + userId: string + name: string + email: string + totalPoints: number + predictions: number + rank: number +} + +export const PHASE_LABELS: Record = { + GROUP_STAGE: 'Fase de Grupos', + ROUND_OF_32: 'Octavos de Final', + ROUND_OF_16: 'Cuartos de Final', + QUARTER_FINALS:'Cuartos', + SEMI_FINALS: 'Semifinales', + THIRD_PLACE: 'Tercer Puesto', + FINAL: 'Final', +} + +export const PHASE_ORDER: Phase[] = [ + 'GROUP_STAGE', 'ROUND_OF_32', 'ROUND_OF_16', + 'QUARTER_FINALS', 'SEMI_FINALS', 'THIRD_PLACE', 'FINAL', +] diff --git a/app/tailwind.config.ts b/app/tailwind.config.ts new file mode 100644 index 0000000..7f5ca21 --- /dev/null +++ b/app/tailwind.config.ts @@ -0,0 +1,27 @@ +import type { Config } from 'tailwindcss' + +const config: Config = { + content: [ + './src/pages/**/*.{js,ts,jsx,tsx,mdx}', + './src/components/**/*.{js,ts,jsx,tsx,mdx}', + './src/app/**/*.{js,ts,jsx,tsx,mdx}', + ], + theme: { + extend: { + colors: { + brand: { + 50: '#f0fdf4', + 100: '#dcfce7', + 500: '#22c55e', + 600: '#16a34a', + 700: '#15803d', + 800: '#166534', + 900: '#14532d', + }, + }, + }, + }, + plugins: [], +} + +export default config diff --git a/app/tsconfig.json b/app/tsconfig.json new file mode 100644 index 0000000..2aead53 --- /dev/null +++ b/app/tsconfig.json @@ -0,0 +1,22 @@ +{ + "compilerOptions": { + "lib": ["dom", "dom.iterable", "esnext"], + "allowJs": true, + "skipLibCheck": true, + "strict": true, + "noEmit": true, + "esModuleInterop": true, + "module": "esnext", + "moduleResolution": "bundler", + "resolveJsonModule": true, + "isolatedModules": true, + "jsx": "preserve", + "incremental": true, + "plugins": [{ "name": "next" }], + "paths": { + "@/*": ["./src/*"] + } + }, + "include": ["next-env.d.ts", "**/*.ts", "**/*.tsx", ".next/types/**/*.ts"], + "exclude": ["node_modules"] +} diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..1c69f0a --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,37 @@ +version: '3.8' + +services: + db: + image: postgres:16-alpine + restart: unless-stopped + environment: + POSTGRES_USER: ${POSTGRES_USER:-prode} + POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:-prode_secret} + POSTGRES_DB: ${POSTGRES_DB:-prode_db} + volumes: + - postgres_data:/var/lib/postgresql/data + healthcheck: + test: ["CMD-SHELL", "pg_isready -U ${POSTGRES_USER:-prode}"] + interval: 10s + timeout: 5s + retries: 5 + + app: + build: + context: ./app + dockerfile: Dockerfile + restart: unless-stopped + ports: + - "3000:3000" + environment: + DATABASE_URL: postgresql://${POSTGRES_USER:-prode}:${POSTGRES_PASSWORD:-prode_secret}@db:5432/${POSTGRES_DB:-prode_db} + JWT_SECRET: ${JWT_SECRET:-cambia_este_secret_en_produccion} + RESULTS_API_URL: ${RESULTS_API_URL:-} + RESULTS_API_KEY: ${RESULTS_API_KEY:-} + ADMIN_SYNC_SECRET: ${ADMIN_SYNC_SECRET:-sync_secret_cambialo} + depends_on: + db: + condition: service_healthy + +volumes: + postgres_data: diff --git a/fixture.png b/fixture.png new file mode 100644 index 0000000..82c1753 Binary files /dev/null and b/fixture.png differ diff --git a/grupos.jpg b/grupos.jpg new file mode 100644 index 0000000..c4be937 Binary files /dev/null and b/grupos.jpg differ diff --git a/otro.jpg b/otro.jpg new file mode 100644 index 0000000..8052ff8 Binary files /dev/null and b/otro.jpg differ