feat: phase 3 redesign, game images, auth system, vm guides, service isolation
Some checks failed
Deploy Multi-VM / Deploy VM Web (push) Has been cancelled
Deploy Multi-VM / Deploy VM Auth (push) Has been cancelled
Deploy Multi-VM / Deploy Game Servers (docker-compose.fusionfall.yml, VM_FUSIONFALL_HOST, VM_FUSIONFALL_SSH_KEY, VM_FUSIONFALL_USER, fusionfall) (push) Has been cancelled
Deploy Multi-VM / Deploy Game Servers (docker-compose.maple2.yml, VM_MAPLE2_HOST, VM_MAPLE2_SSH_KEY, VM_MAPLE2_USER, maple2) (push) Has been cancelled
Deploy Multi-VM / Deploy Game Servers (docker-compose.minecraft.yml, VM_MINECRAFT_HOST, VM_MINECRAFT_SSH_KEY, VM_MINECRAFT_USER, minecraft) (push) Has been cancelled
Deploy Multi-VM / Deploy Game Servers (docker-compose.retro.yml, VM_RETRO_HOST, VM_RETRO_SSH_KEY, VM_RETRO_USER, retro) (push) Has been cancelled
Some checks failed
Deploy Multi-VM / Deploy VM Web (push) Has been cancelled
Deploy Multi-VM / Deploy VM Auth (push) Has been cancelled
Deploy Multi-VM / Deploy Game Servers (docker-compose.fusionfall.yml, VM_FUSIONFALL_HOST, VM_FUSIONFALL_SSH_KEY, VM_FUSIONFALL_USER, fusionfall) (push) Has been cancelled
Deploy Multi-VM / Deploy Game Servers (docker-compose.maple2.yml, VM_MAPLE2_HOST, VM_MAPLE2_SSH_KEY, VM_MAPLE2_USER, maple2) (push) Has been cancelled
Deploy Multi-VM / Deploy Game Servers (docker-compose.minecraft.yml, VM_MINECRAFT_HOST, VM_MINECRAFT_SSH_KEY, VM_MINECRAFT_USER, minecraft) (push) Has been cancelled
Deploy Multi-VM / Deploy Game Servers (docker-compose.retro.yml, VM_RETRO_HOST, VM_RETRO_SSH_KEY, VM_RETRO_USER, retro) (push) Has been cancelled
- Redesign all internal pages to warm/gold aesthetic (catalog, game detail, documentary, about, donate, community, guides, contact, server-status, login, profile, admin, not-found) - Add real cover images for all 4 games via Strapi CMS with getImageUrl helper - Integrate NextAuth v5 with Authentik OIDC authentication - Add new public pages: community, guides, contact, server-status - Add new protected pages: login, profile, admin dashboard - Remove legacy AFC/MercadoPago system entirely - Add Docker Compose split files for service isolation (main, auth, fusionfall, nier) - Add OpenFusion VM deployment configs (config.vm.ini, systemd service, README-VM) - Add NieR Reincarnation server guide and desktop client guide - Add architecture docs for multi-VM deployment - Add healthcheck, SSE, contact, newsletter, admin API routes - Add reusable UI components, skeleton loaders, activity feed, bookmark system - Update deployment and game server documentation
This commit is contained in:
81
.github/workflows/deploy.yml
vendored
81
.github/workflows/deploy.yml
vendored
@@ -1,26 +1,85 @@
|
|||||||
name: Deploy
|
name: Deploy Multi-VM
|
||||||
|
|
||||||
on:
|
on:
|
||||||
push:
|
push:
|
||||||
branches: [main]
|
branches: [main]
|
||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
deploy:
|
deploy-web:
|
||||||
|
name: Deploy VM Web
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@v4
|
- uses: actions/checkout@v4
|
||||||
|
- name: Deploy to VM Web
|
||||||
- name: Deploy to VPS
|
|
||||||
uses: appleboy/ssh-action@v1
|
uses: appleboy/ssh-action@v1
|
||||||
with:
|
with:
|
||||||
host: ${{ secrets.VPS_HOST }}
|
host: ${{ secrets.VM_WEB_HOST }}
|
||||||
username: ${{ secrets.VPS_USER }}
|
username: ${{ secrets.VM_WEB_USER }}
|
||||||
key: ${{ secrets.VPS_SSH_KEY }}
|
key: ${{ secrets.VM_WEB_SSH_KEY }}
|
||||||
script: |
|
script: |
|
||||||
cd /opt/project-afterlife
|
cd /opt/project-afterlife
|
||||||
git pull origin main
|
git pull origin main
|
||||||
cd docker
|
cd docker
|
||||||
docker compose build
|
docker compose -f docker-compose.web.yml build
|
||||||
docker compose up -d
|
docker compose -f docker-compose.web.yml up -d
|
||||||
docker compose exec web npm run build
|
docker compose -f docker-compose.web.yml exec web npm run build
|
||||||
docker compose restart web
|
docker compose -f docker-compose.web.yml restart web
|
||||||
|
|
||||||
|
deploy-auth:
|
||||||
|
name: Deploy VM Auth
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v4
|
||||||
|
- name: Deploy to VM Auth
|
||||||
|
uses: appleboy/ssh-action@v1
|
||||||
|
with:
|
||||||
|
host: ${{ secrets.VM_AUTH_HOST }}
|
||||||
|
username: ${{ secrets.VM_AUTH_USER }}
|
||||||
|
key: ${{ secrets.VM_AUTH_SSH_KEY }}
|
||||||
|
script: |
|
||||||
|
cd /opt/project-afterlife
|
||||||
|
git pull origin main
|
||||||
|
cd docker
|
||||||
|
docker compose -f docker-compose.auth.yml pull
|
||||||
|
docker compose -f docker-compose.auth.yml up -d
|
||||||
|
|
||||||
|
deploy-games:
|
||||||
|
name: Deploy Game Servers
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
strategy:
|
||||||
|
matrix:
|
||||||
|
include:
|
||||||
|
- vm: fusionfall
|
||||||
|
host_secret: VM_FUSIONFALL_HOST
|
||||||
|
user_secret: VM_FUSIONFALL_USER
|
||||||
|
key_secret: VM_FUSIONFALL_SSH_KEY
|
||||||
|
compose: docker-compose.fusionfall.yml
|
||||||
|
- vm: maple2
|
||||||
|
host_secret: VM_MAPLE2_HOST
|
||||||
|
user_secret: VM_MAPLE2_USER
|
||||||
|
key_secret: VM_MAPLE2_SSH_KEY
|
||||||
|
compose: docker-compose.maple2.yml
|
||||||
|
- vm: minecraft
|
||||||
|
host_secret: VM_MINECRAFT_HOST
|
||||||
|
user_secret: VM_MINECRAFT_USER
|
||||||
|
key_secret: VM_MINECRAFT_SSH_KEY
|
||||||
|
compose: docker-compose.minecraft.yml
|
||||||
|
- vm: retro
|
||||||
|
host_secret: VM_RETRO_HOST
|
||||||
|
user_secret: VM_RETRO_USER
|
||||||
|
key_secret: VM_RETRO_SSH_KEY
|
||||||
|
compose: docker-compose.retro.yml
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v4
|
||||||
|
- name: Deploy to ${{ matrix.vm }}
|
||||||
|
uses: appleboy/ssh-action@v1
|
||||||
|
with:
|
||||||
|
host: ${{ secrets[matrix.host_secret] }}
|
||||||
|
username: ${{ secrets[matrix.user_secret] }}
|
||||||
|
key: ${{ secrets[matrix.key_secret] }}
|
||||||
|
script: |
|
||||||
|
cd /opt/project-afterlife
|
||||||
|
git pull origin main
|
||||||
|
cd docker
|
||||||
|
docker compose -f ${{ matrix.compose }} build
|
||||||
|
docker compose -f ${{ matrix.compose }} up -d
|
||||||
|
|||||||
184
README.md
184
README.md
@@ -10,58 +10,59 @@ Plataforma de preservacion de videojuegos con documentales interactivos. Servido
|
|||||||
| **Strapi 5** (CMS) | En linea | 1337 | ~179 MB |
|
| **Strapi 5** (CMS) | En linea | 1337 | ~179 MB |
|
||||||
| **PostgreSQL 16** | En linea | 5432 | ~57 MB |
|
| **PostgreSQL 16** | En linea | 5432 | ~57 MB |
|
||||||
| **MinIO** (almacenamiento) | En linea | 9000/9001 | ~144 MB |
|
| **MinIO** (almacenamiento) | En linea | 9000/9001 | ~144 MB |
|
||||||
| **OpenFusion** (FusionFall) | En linea | 23000-23001 | ~254 MB |
|
| **Authentik** (SSO) | En linea | 9000 | ~512 MB |
|
||||||
|
| **NieR Reincarnation** | Alpha | 80/443 | ~1 GB |
|
||||||
|
| **Dragon Ball Online** | En configuracion | 22000-22010 | ~2 GB |
|
||||||
| **MapleStory 2 - World** | En linea | 21001 | ~126 MB |
|
| **MapleStory 2 - World** | En linea | 21001 | ~126 MB |
|
||||||
| **MapleStory 2 - Login** | En linea | 20001 | ~100 MB |
|
| **MapleStory 2 - Login** | En linea | 20001 | ~100 MB |
|
||||||
| **MapleStory 2 - Web** | En linea | 4000 | ~70 MB |
|
|
||||||
| **MapleStory 2 - Game Ch0** | En linea | 20003/21003 | ~341 MB |
|
| **MapleStory 2 - Game Ch0** | En linea | 20003/21003 | ~341 MB |
|
||||||
| **MapleStory 2 - MySQL** | En linea | 3307 | ~733 MB |
|
| **FusionFall** | En linea | 23000-23001 | ~254 MB |
|
||||||
| **Minecraft FTB Evolution** | En linea | 25565 | ~3.5 GB |
|
|
||||||
| **SM64 Coop DX** | En linea | 7777/udp | ~45 MB |
|
|
||||||
| **N64 Netplay** (Mario Party) | En linea | 45000-45004 | <1 MB |
|
|
||||||
| **Dolphin Traversal** (GC/Wii) | En linea | 6262/udp, 6226/udp | <10 MB |
|
|
||||||
|
|
||||||
**Total**: ~6 GB RAM / 40 GB disponibles | 35 GB disco / 96 GB disponibles
|
## Soft Launch — Juegos Disponibles
|
||||||
|
|
||||||
## Juegos Preservados
|
### NieR Reincarnation
|
||||||
|
- **Emulador**: [MariesWonderland](https://github.com/BillyCool/MariesWonderland) (C# .NET 10)
|
||||||
|
- **Conexion**: `play.consultoria-as.com:80/443` (HTTP/gRPC HTTP/2)
|
||||||
|
- **Cliente**: APK Android parcheado (via Google Colab)
|
||||||
|
- **Documental**: "El Mundo de las Voces Perdidas" (en produccion)
|
||||||
|
- **Estado**: Alpha — gameplay basico funcional
|
||||||
|
|
||||||
### FusionFall (Cartoon Network Universe)
|
### Dragon Ball Online
|
||||||
- **Emulador**: [OpenFusion](https://github.com/OpenFusionProject/OpenFusion) (C++)
|
- **Emulador**: [DBO Global](https://github.com/dboglobal) (C++ / Windows)
|
||||||
- **Conexion**: `play.consultoria-as.com:23000` (o `192.168.10.234:23000` en LAN)
|
- **Conexion**: `play.consultoria-as.com:22000`
|
||||||
- **Cliente**: [FusionFall Retro Client](https://github.com/OpenFusionProject)
|
- **Cliente**: DBO Global Client (Windows)
|
||||||
- **Documental**: "FusionFall: El Mundo Que No Queriamos Perder" (7 capitulos)
|
- **Documental**: "La Tierra Sin Goku" (en produccion)
|
||||||
|
- **Estado**: En configuracion — requiere VM Windows
|
||||||
|
|
||||||
### MapleStory 2
|
### MapleStory 2
|
||||||
- **Emulador**: [Maple2](https://github.com/MS2Community/Maple2) (C# .NET 8)
|
- **Emulador**: [Maple2](https://github.com/MS2Community/Maple2) (C# .NET 8)
|
||||||
- **Conexion**: `play.consultoria-as.com:20001` (o `192.168.10.234:20001` en LAN)
|
- **Conexion**: `play.consultoria-as.com:20001`
|
||||||
- **Cliente**: MapleStory 2 Global Client + XML Patches
|
- **Cliente**: MapleStory 2 Global Client + XML Patches
|
||||||
- **Documental**: "MapleStory 2: El Mundo Que Construimos Juntos" (7 capitulos)
|
- **Documental**: "El Mundo Que Construimos Juntos" (7 capitulos)
|
||||||
|
- **Estado**: Online
|
||||||
|
|
||||||
### Minecraft: FTB Evolution
|
### FusionFall
|
||||||
- **Servidor**: [itzg/minecraft-server](https://github.com/itzg/docker-minecraft-server) (Java 21)
|
- **Emulador**: [OpenFusion](https://github.com/OpenFusionProject/OpenFusion) (C++)
|
||||||
- **Conexion**: `play.consultoria-as.com:25565` (o `192.168.10.234:25565` en LAN)
|
- **Conexion**: `play.consultoria-as.com:23000`
|
||||||
- **Cliente**: FTB App o launcher compatible con FTB Evolution v1.29.1
|
- **Cliente**: FusionFall Retro Client
|
||||||
- **Modpack**: 200+ mods, Minecraft 1.21.1 + NeoForge 21.1.218
|
- **Documental**: "El Mundo Que No Queriamos Perder" (7 capitulos)
|
||||||
|
- **Estado**: Online
|
||||||
|
|
||||||
### Super Mario 64 Coop
|
|
||||||
- **Servidor**: [sm64coopdx](https://github.com/coop-deluxe/sm64coopdx) (C, headless)
|
|
||||||
- **Conexion**: `play.consultoria-as.com:7777` (o `192.168.10.234:7777` en LAN)
|
|
||||||
- **Cliente**: sm64coopdx (compilado con la misma ROM)
|
|
||||||
- **Jugadores**: Hasta 16, con mods incluidos (star-road, arena, character-select)
|
|
||||||
|
|
||||||
### Mario Party 1-3 (N64 Netplay)
|
|
||||||
- **Servidor**: [gopher64-netplay-server](https://github.com/gopher64/gopher64-netplay-server) (Go)
|
|
||||||
- **Conexion**: `play.consultoria-as.com:45000` (o `192.168.10.234:45000` en LAN)
|
|
||||||
- **Cliente**: [gopher64](https://github.com/gopher64/gopher64) o RMG + ROM de Mario Party
|
|
||||||
- **Jugadores**: 4 por sala, 4 salas concurrentes
|
|
||||||
|
|
||||||
### GameCube / Wii (Dolphin Netplay)
|
|
||||||
- **Servidor**: Dolphin Traversal Server (NAT hole-punching)
|
|
||||||
- **Config en Dolphin**: Traversal Server = `play.consultoria-as.com`, Port = `6262`
|
|
||||||
- **Juegos**: Mario Party 4-7, MKDD, Smash Melee, F-Zero GX, y cualquier juego de GC/Wii
|
|
||||||
|
|
||||||
## Arquitectura
|
## Arquitectura
|
||||||
|
|
||||||
|
### Multi-VM (Nueva Arquitectura)
|
||||||
|
|
||||||
|
Cada componente corre en su propia VM para maximizar aislamiento y control:
|
||||||
|
|
||||||
|
| VM | IP Privada | Puertos Públicos | Servicios |
|
||||||
|
|----|-----------|------------------|-----------|
|
||||||
|
| **vm-main** | `10.0.0.10` | `80, 443` | Web + Auth + CMS + PostgreSQL + MinIO + Nginx |
|
||||||
|
| **vm-nier** | `10.0.0.70` | `80, 443` | NieR Reincarnation (MariesWonderland) |
|
||||||
|
| **vm-dbo** | `10.0.0.80` | `22000-22010` | Dragon Ball Online (DBO Global) |
|
||||||
|
| **vm-maple2** | `10.0.0.40` | `20001, 21001, 20003, 21003, 4000` | MapleStory 2 |
|
||||||
|
| **vm-fusionfall** | `10.0.0.30` | `23000, 23001` | OpenFusion Server |
|
||||||
|
|
||||||
```
|
```
|
||||||
project-afterlife/
|
project-afterlife/
|
||||||
├── apps/
|
├── apps/
|
||||||
@@ -72,15 +73,35 @@ project-afterlife/
|
|||||||
├── servers/
|
├── servers/
|
||||||
│ ├── openfusion/ # Servidor FusionFall (C++)
|
│ ├── openfusion/ # Servidor FusionFall (C++)
|
||||||
│ ├── maple2/ # Servidor MapleStory 2 (C# .NET 8)
|
│ ├── maple2/ # Servidor MapleStory 2 (C# .NET 8)
|
||||||
│ ├── sm64coopdx/ # Super Mario 64 Coop (C, headless)
|
│ ├── nier-reincarnation/ # NieR Reincarnation (MariesWonderland .NET 10)
|
||||||
│ └── dolphin-traversal/ # Dolphin Traversal Server (C++)
|
│ └── dragonball-online/ # Dragon Ball Online (DBO Global C++)
|
||||||
|
├── services/
|
||||||
|
│ └── afc-bridge/ # Bridge API blockchain (Node.js) — legacy
|
||||||
|
├── blockchain/
|
||||||
|
│ ├── contracts/AfterCoin.sol # Contrato inteligente ERC-20 — legacy
|
||||||
|
│ ├── genesis.json # Config genesis Geth — legacy
|
||||||
|
│ └── Dockerfile # Nodo Geth — legacy
|
||||||
├── docker/
|
├── docker/
|
||||||
│ ├── docker-compose.dev.yml # Stack local (web + CMS + juegos)
|
│ ├── docker-compose.main.yml # VM Principal (Web + Auth + CMS)
|
||||||
│ ├── docker-compose.maple2.yml # MapleStory 2 (separado)
|
│ ├── docker-compose.nier.yml # VM NieR Reincarnation
|
||||||
│ ├── docker-compose.yml # Produccion (con Nginx + SSL)
|
│ ├── docker-compose.dbo.yml # VM Dragon Ball Online
|
||||||
│ └── nginx/ # Configuracion Nginx
|
│ ├── docker-compose.fusionfall.yml # VM OpenFusion
|
||||||
├── docs/ # Documentacion del proyecto
|
│ ├── docker-compose.maple2.yml # VM MapleStory 2
|
||||||
└── .github/workflows/ # CI/CD deployment
|
│ ├── docker-compose.dev.yml # Legacy: stack local completo
|
||||||
|
│ ├── docker-compose.yml # Legacy: produccion monolitica
|
||||||
|
│ ├── docker-compose.web.yml # Legacy: web separado
|
||||||
|
│ ├── docker-compose.auth.yml # Legacy: auth separado
|
||||||
|
│ └── nginx/ # Configuracion Nginx
|
||||||
|
├── scripts/
|
||||||
|
│ └── deploy-vm.sh # Script helper para deploy por VM
|
||||||
|
├── docs/
|
||||||
|
│ ├── architecture.md # Arquitectura tecnica detallada
|
||||||
|
│ ├── architecture-vms.md # Documentacion multi-VM
|
||||||
|
│ ├── game-servers.md # Setup de servidores de juegos
|
||||||
|
│ ├── cms-content.md # Modelo de contenido CMS
|
||||||
|
│ └── deployment.md # Guia de despliegue
|
||||||
|
└── .github/workflows/
|
||||||
|
└── deploy.yml # CI/CD multi-VM
|
||||||
```
|
```
|
||||||
|
|
||||||
### Stack Tecnologico
|
### Stack Tecnologico
|
||||||
@@ -102,19 +123,55 @@ project-afterlife/
|
|||||||
|
|
||||||
## Inicio Rapido
|
## Inicio Rapido
|
||||||
|
|
||||||
### Requisitos
|
### Instalacion Limpia (Recomendado para Produccion)
|
||||||
|
|
||||||
|
Ver `docs/clean-install.md` para la guia completa paso a paso.
|
||||||
|
|
||||||
|
#### Resumen rapido
|
||||||
|
|
||||||
|
**VM Principal** (Web + Auth + CMS):
|
||||||
|
```bash
|
||||||
|
# 1. Preparar la VM (instala Docker, firewall, genera secrets)
|
||||||
|
./scripts/setup-main.sh
|
||||||
|
|
||||||
|
# 2. Clonar y configurar
|
||||||
|
git clone https://git.consultoria-as.com/consultoria-as/project-afterlife.git /opt/project-afterlife
|
||||||
|
cd /opt/project-afterlife
|
||||||
|
cp docker/.env.example docker/.env
|
||||||
|
# Edita docker/.env con los secrets generados
|
||||||
|
|
||||||
|
# 3. Instalar
|
||||||
|
./scripts/install.sh main
|
||||||
|
```
|
||||||
|
|
||||||
|
**VMs de Juegos** (una por juego):
|
||||||
|
```bash
|
||||||
|
# Preparar VM de juego (ejemplo: NieR)
|
||||||
|
./scripts/setup-game-vm.sh nier
|
||||||
|
|
||||||
|
# Clonar e instalar
|
||||||
|
git clone ... /opt/project-afterlife
|
||||||
|
cd /opt/project-afterlife
|
||||||
|
./scripts/install.sh nier
|
||||||
|
```
|
||||||
|
|
||||||
|
### Stack Local Completo (Desarrollo / Monolito Legacy)
|
||||||
|
|
||||||
|
Para desarrollo local donde todo corre en una sola maquina:
|
||||||
|
|
||||||
|
#### Requisitos
|
||||||
- Docker y Docker Compose v2+
|
- Docker y Docker Compose v2+
|
||||||
- 8 GB RAM minimo (16 GB recomendado con todos los servidores)
|
- 8 GB RAM minimo (16 GB recomendado con todos los servidores)
|
||||||
- 50 GB disco libre
|
- 50 GB disco libre
|
||||||
|
|
||||||
### 1. Clonar y configurar
|
#### 1. Clonar y configurar
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
git clone https://git.consultoria-as.com/consultoria-as/project-afterlife.git
|
git clone https://git.consultoria-as.com/consultoria-as/project-afterlife.git
|
||||||
cd project-afterlife
|
cd project-afterlife
|
||||||
```
|
```
|
||||||
|
|
||||||
### 2. Crear archivo de entorno
|
#### 2. Crear archivo de entorno
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
cp docker/.env.example docker/.env
|
cp docker/.env.example docker/.env
|
||||||
@@ -144,24 +201,27 @@ STRAPI_API_TOKEN=
|
|||||||
|
|
||||||
# Strapi URL publica
|
# Strapi URL publica
|
||||||
PUBLIC_STRAPI_URL=http://localhost:1337
|
PUBLIC_STRAPI_URL=http://localhost:1337
|
||||||
|
|
||||||
# OpenFusion
|
|
||||||
OPENFUSION_SHARD_IP=192.168.10.234
|
|
||||||
```
|
```
|
||||||
|
|
||||||
### 3. Levantar servicios base
|
#### 3. Levantar servicios base
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
cd docker
|
cd docker
|
||||||
|
|
||||||
# Stack principal (CMS + Web + OpenFusion + Minecraft FTB)
|
# Stack principal (CMS + Web)
|
||||||
docker compose -f docker-compose.dev.yml up -d
|
docker compose -f docker-compose.dev.yml up -d
|
||||||
|
|
||||||
|
# NieR Reincarnation (requiere setup previo, ver docs/game-servers.md)
|
||||||
|
docker compose -f docker-compose.nier.yml up -d
|
||||||
|
|
||||||
# MapleStory 2 (requiere setup previo, ver docs/game-servers.md)
|
# MapleStory 2 (requiere setup previo, ver docs/game-servers.md)
|
||||||
docker compose -f docker-compose.maple2.yml up -d
|
docker compose -f docker-compose.maple2.yml up -d
|
||||||
|
|
||||||
|
# Dragon Ball Online (requiere setup previo, ver docs/game-servers.md)
|
||||||
|
docker compose -f docker-compose.dbo.yml up -d
|
||||||
```
|
```
|
||||||
|
|
||||||
### 4. Setup inicial de Strapi
|
#### 4. Setup inicial de Strapi
|
||||||
|
|
||||||
1. Abrir http://localhost:1337/admin
|
1. Abrir http://localhost:1337/admin
|
||||||
2. Crear usuario administrador
|
2. Crear usuario administrador
|
||||||
@@ -169,7 +229,7 @@ docker compose -f docker-compose.maple2.yml up -d
|
|||||||
4. Tipo: Full access, copiar el token a `STRAPI_API_TOKEN` en `.env`
|
4. Tipo: Full access, copiar el token a `STRAPI_API_TOKEN` en `.env`
|
||||||
5. Reiniciar el servicio web: `docker compose -f docker-compose.dev.yml restart web`
|
5. Reiniciar el servicio web: `docker compose -f docker-compose.dev.yml restart web`
|
||||||
|
|
||||||
### 5. Verificar
|
#### 5. Verificar
|
||||||
|
|
||||||
- **Frontend**: http://localhost:3000
|
- **Frontend**: http://localhost:3000
|
||||||
- **CMS Admin**: http://localhost:1337/admin
|
- **CMS Admin**: http://localhost:1337/admin
|
||||||
@@ -196,23 +256,29 @@ docker compose -f docker-compose.maple2.yml up -d
|
|||||||
| `/es/donate` | Pagina de donaciones |
|
| `/es/donate` | Pagina de donaciones |
|
||||||
| `/es/games/[slug]` | Pagina individual de juego |
|
| `/es/games/[slug]` | Pagina individual de juego |
|
||||||
| `/es/games/[slug]/documentary` | Documental interactivo |
|
| `/es/games/[slug]/documentary` | Documental interactivo |
|
||||||
|
| `/es/login` | Iniciar sesion con Authentik |
|
||||||
|
| `/es/profile` | Perfil de usuario |
|
||||||
|
| `/es/server-status` | Estado de todos los servidores |
|
||||||
|
|
||||||
## Contenido en Base de Datos
|
## Contenido en Base de Datos
|
||||||
|
|
||||||
### Juegos
|
### Juegos (Soft Launch)
|
||||||
| Slug | Titulo | Estado | Documental |
|
| Slug | Titulo | Estado | Documental |
|
||||||
|------|--------|--------|------------|
|
|------|--------|--------|------------|
|
||||||
| `fusionfall` | FusionFall | Online | 7 capitulos |
|
| `nier-reincarnation` | NieR Reincarnation | Alpha | En produccion |
|
||||||
|
| `dragonball-online` | Dragon Ball Online | En configuracion | En produccion |
|
||||||
| `maplestory2` | MapleStory 2 | Online | 7 capitulos |
|
| `maplestory2` | MapleStory 2 | Online | 7 capitulos |
|
||||||
| `minecraft-ftb-evolution` | Minecraft: FTB Evolution | Online | Pendiente |
|
| `fusionfall` | FusionFall | Online | 7 capitulos |
|
||||||
|
|
||||||
### Documentales
|
### Documentales
|
||||||
| Juego | Titulo | Capitulos |
|
| Juego | Titulo | Capitulos |
|
||||||
|-------|--------|-----------|
|
|-------|--------|-----------|
|
||||||
|
| NieR Reincarnation | "El Mundo de las Voces Perdidas" | En produccion |
|
||||||
|
| Dragon Ball Online | "La Tierra Sin Goku" | En produccion |
|
||||||
| FusionFall | "El Mundo Que No Queriamos Perder" | 7 |
|
| FusionFall | "El Mundo Que No Queriamos Perder" | 7 |
|
||||||
| MapleStory 2 | "El Mundo Que Construimos Juntos" | 7 |
|
| MapleStory 2 | "El Mundo Que Construimos Juntos" | 7 |
|
||||||
|
|
||||||
Cada documental tiene sus 7 capitulos publicados en ambos idiomas (ES/EN).
|
Cada documental publicado tiene sus capitulos disponibles en ambos idiomas (ES/EN).
|
||||||
|
|
||||||
## Licencia
|
## Licencia
|
||||||
|
|
||||||
|
|||||||
@@ -6,4 +6,24 @@ export default () => ({
|
|||||||
locales: ["es", "en"],
|
locales: ["es", "en"],
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
"users-permissions": {
|
||||||
|
config: {
|
||||||
|
providers: {
|
||||||
|
// Authentik OIDC provider for CMS admin SSO
|
||||||
|
authentik: {
|
||||||
|
enabled: true,
|
||||||
|
icon: "authentik",
|
||||||
|
key: "",
|
||||||
|
secret: "",
|
||||||
|
callback: `${process.env.PUBLIC_STRAPI_URL || "http://localhost:1337"}/api/auth/authentik/callback`,
|
||||||
|
scope: ["openid", "email", "profile"],
|
||||||
|
// Authentik endpoints
|
||||||
|
authorization_endpoint: `${process.env.AUTHENTIK_URL || "http://10.0.0.20:9000"}/application/o/authorize/`,
|
||||||
|
access_token_endpoint: `${process.env.AUTHENTIK_URL || "http://10.0.0.20:9000"}/application/o/token/`,
|
||||||
|
access_token_params: {},
|
||||||
|
grant_type: "authorization_code",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -62,7 +62,6 @@
|
|||||||
"coverImage": {
|
"coverImage": {
|
||||||
"type": "media",
|
"type": "media",
|
||||||
"multiple": false,
|
"multiple": false,
|
||||||
"required": true,
|
|
||||||
"allowedTypes": ["images"]
|
"allowedTypes": ["images"]
|
||||||
},
|
},
|
||||||
"serverStatus": {
|
"serverStatus": {
|
||||||
|
|||||||
@@ -5,7 +5,8 @@ COPY package.json package-lock.json* turbo.json ./
|
|||||||
COPY apps/web/package.json ./apps/web/
|
COPY apps/web/package.json ./apps/web/
|
||||||
COPY packages/shared/package.json ./packages/shared/
|
COPY packages/shared/package.json ./packages/shared/
|
||||||
|
|
||||||
RUN npm ci
|
# Remove stale lockfile to force fresh install
|
||||||
|
RUN rm -f package-lock.json && npm install
|
||||||
|
|
||||||
COPY packages/shared/ ./packages/shared/
|
COPY packages/shared/ ./packages/shared/
|
||||||
COPY apps/web/ ./apps/web/
|
COPY apps/web/ ./apps/web/
|
||||||
|
|||||||
@@ -2,10 +2,57 @@ import createNextIntlPlugin from "next-intl/plugin";
|
|||||||
|
|
||||||
const withNextIntl = createNextIntlPlugin("./src/i18n/request.ts");
|
const withNextIntl = createNextIntlPlugin("./src/i18n/request.ts");
|
||||||
|
|
||||||
|
const cmsUrl = process.env.NEXT_PUBLIC_STRAPI_URL || process.env.STRAPI_URL || "http://localhost:1337";
|
||||||
|
const cmsHostname = new URL(cmsUrl).hostname;
|
||||||
|
const cmsPort = new URL(cmsUrl).port || undefined;
|
||||||
|
|
||||||
const nextConfig = {
|
const nextConfig = {
|
||||||
eslint: {
|
eslint: {
|
||||||
ignoreDuringBuilds: true,
|
ignoreDuringBuilds: true,
|
||||||
},
|
},
|
||||||
|
images: {
|
||||||
|
remotePatterns: [
|
||||||
|
{
|
||||||
|
protocol: "http" as const,
|
||||||
|
hostname: cmsHostname,
|
||||||
|
port: cmsPort,
|
||||||
|
pathname: "/uploads/**",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
async headers() {
|
||||||
|
return [
|
||||||
|
{
|
||||||
|
source: "/(.*)",
|
||||||
|
headers: [
|
||||||
|
{
|
||||||
|
key: "X-Frame-Options",
|
||||||
|
value: "DENY",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: "X-Content-Type-Options",
|
||||||
|
value: "nosniff",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: "Referrer-Policy",
|
||||||
|
value: "strict-origin-when-cross-origin",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: "X-DNS-Prefetch-Control",
|
||||||
|
value: "on",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: "Strict-Transport-Security",
|
||||||
|
value: "max-age=63072000; includeSubDomains; preload",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: "Permissions-Policy",
|
||||||
|
value: "camera=(), microphone=(), geolocation=()",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
];
|
||||||
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
export default withNextIntl(nextConfig);
|
export default withNextIntl(nextConfig);
|
||||||
|
|||||||
@@ -10,12 +10,20 @@
|
|||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@afterlife/shared": "*",
|
"@afterlife/shared": "*",
|
||||||
|
"@giscus/react": "^3.1.0",
|
||||||
|
"@vercel/analytics": "^2.0.1",
|
||||||
|
"chart.js": "^4.5.1",
|
||||||
"framer-motion": "^12.34.3",
|
"framer-motion": "^12.34.3",
|
||||||
"howler": "^2.2.4",
|
"howler": "^2.2.4",
|
||||||
|
"ioredis": "^5.10.1",
|
||||||
"mercadopago": "^2.12.0",
|
"mercadopago": "^2.12.0",
|
||||||
"next": "^15",
|
"next": "^15",
|
||||||
|
"next-auth": "^5.0.0-beta.25",
|
||||||
"next-intl": "^4.8.3",
|
"next-intl": "^4.8.3",
|
||||||
|
"nodemailer": "^6.10.1",
|
||||||
|
"pg": "^8.20.0",
|
||||||
"react": "^19",
|
"react": "^19",
|
||||||
|
"react-chartjs-2": "^5.3.1",
|
||||||
"react-dom": "^19",
|
"react-dom": "^19",
|
||||||
"uuid": "^13.0.0"
|
"uuid": "^13.0.0"
|
||||||
},
|
},
|
||||||
@@ -23,6 +31,8 @@
|
|||||||
"@tailwindcss/postcss": "^4",
|
"@tailwindcss/postcss": "^4",
|
||||||
"@types/howler": "^2.2.12",
|
"@types/howler": "^2.2.12",
|
||||||
"@types/node": "^20",
|
"@types/node": "^20",
|
||||||
|
"@types/nodemailer": "^8.0.0",
|
||||||
|
"@types/pg": "^8.20.0",
|
||||||
"@types/react": "^19",
|
"@types/react": "^19",
|
||||||
"@types/react-dom": "^19",
|
"@types/react-dom": "^19",
|
||||||
"eslint": "^9",
|
"eslint": "^9",
|
||||||
|
|||||||
@@ -1,11 +1,23 @@
|
|||||||
|
import type { Metadata } from "next";
|
||||||
import { useTranslations } from "next-intl";
|
import { useTranslations } from "next-intl";
|
||||||
|
|
||||||
|
export function generateMetadata(): Metadata {
|
||||||
|
return {
|
||||||
|
title: "About Us",
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
export default function AboutPage() {
|
export default function AboutPage() {
|
||||||
const t = useTranslations("about");
|
const t = useTranslations("about");
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="max-w-4xl mx-auto px-4 py-12">
|
<div className="max-w-4xl mx-auto px-4 py-12">
|
||||||
<h1 className="text-4xl font-bold mb-12">{t("title")}</h1>
|
<h1
|
||||||
|
className="text-[clamp(2rem,4vw,3rem)] font-extrabold mb-12"
|
||||||
|
style={{ fontFamily: "var(--font-display)", letterSpacing: "-0.02em" }}
|
||||||
|
>
|
||||||
|
{t("title")}
|
||||||
|
</h1>
|
||||||
|
|
||||||
<section className="mb-12">
|
<section className="mb-12">
|
||||||
<h2 className="text-2xl font-semibold mb-4">{t("mission")}</h2>
|
<h2 className="text-2xl font-semibold mb-4">{t("mission")}</h2>
|
||||||
@@ -22,8 +34,8 @@ export default function AboutPage() {
|
|||||||
<section className="mb-12">
|
<section className="mb-12">
|
||||||
<h2 className="text-2xl font-semibold mb-4">{t("team")}</h2>
|
<h2 className="text-2xl font-semibold mb-4">{t("team")}</h2>
|
||||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
|
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
|
||||||
<div className="bg-gray-900 rounded-lg p-6 border border-white/5">
|
<div className="bg-[#12121a] rounded-lg p-6 border border-[rgba(255,255,255,0.08)]">
|
||||||
<p className="text-gray-500 text-sm">Team members coming soon.</p>
|
<p className="text-[#6b6b75] text-sm">Team members coming soon.</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|||||||
214
apps/web/src/app/[locale]/admin/page.tsx
Normal file
214
apps/web/src/app/[locale]/admin/page.tsx
Normal file
@@ -0,0 +1,214 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useEffect, useState, useMemo } from "react";
|
||||||
|
import { SubscriberChart } from "@/components/admin/SubscriberChart";
|
||||||
|
|
||||||
|
interface Subscriber {
|
||||||
|
id: number;
|
||||||
|
email: string;
|
||||||
|
locale: string;
|
||||||
|
created_at: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface ContactMessage {
|
||||||
|
id: number;
|
||||||
|
name: string;
|
||||||
|
email: string;
|
||||||
|
subject: string | null;
|
||||||
|
message: string;
|
||||||
|
created_at: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
function exportCSV(data: Subscriber[]) {
|
||||||
|
const headers = ["ID", "Email", "Locale", "Created At"];
|
||||||
|
const rows = data.map((s) => [s.id, s.email, s.locale, s.created_at]);
|
||||||
|
const csv = [headers, ...rows].map((r) => r.join(",")).join("\n");
|
||||||
|
const blob = new Blob([csv], { type: "text/csv" });
|
||||||
|
const url = URL.createObjectURL(blob);
|
||||||
|
const a = document.createElement("a");
|
||||||
|
a.href = url;
|
||||||
|
a.download = `subscribers-${new Date().toISOString().split("T")[0]}.csv`;
|
||||||
|
a.click();
|
||||||
|
URL.revokeObjectURL(url);
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatDate(iso: string) {
|
||||||
|
return new Date(iso).toLocaleDateString("es-ES", {
|
||||||
|
year: "numeric",
|
||||||
|
month: "short",
|
||||||
|
day: "numeric",
|
||||||
|
hour: "2-digit",
|
||||||
|
minute: "2-digit",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function AdminPage() {
|
||||||
|
const [apiKey, setApiKey] = useState("");
|
||||||
|
const [subscribers, setSubscribers] = useState<Subscriber[]>([]);
|
||||||
|
const [messages, setMessages] = useState<ContactMessage[]>([]);
|
||||||
|
const [subTotal, setSubTotal] = useState(0);
|
||||||
|
const [msgTotal, setMsgTotal] = useState(0);
|
||||||
|
const [loading, setLoading] = useState(false);
|
||||||
|
const [error, setError] = useState("");
|
||||||
|
|
||||||
|
async function fetchData() {
|
||||||
|
if (!apiKey.trim()) return;
|
||||||
|
setLoading(true);
|
||||||
|
setError("");
|
||||||
|
|
||||||
|
try {
|
||||||
|
const [subRes, msgRes] = await Promise.all([
|
||||||
|
fetch("/api/admin/subscribers?limit=50", {
|
||||||
|
headers: { "x-admin-key": apiKey },
|
||||||
|
}),
|
||||||
|
fetch("/api/admin/messages?limit=50", {
|
||||||
|
headers: { "x-admin-key": apiKey },
|
||||||
|
}),
|
||||||
|
]);
|
||||||
|
|
||||||
|
if (!subRes.ok || !msgRes.ok) {
|
||||||
|
setError("Invalid API key or unauthorized");
|
||||||
|
setLoading(false);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const subData = await subRes.json();
|
||||||
|
const msgData = await msgRes.json();
|
||||||
|
|
||||||
|
setSubscribers(subData.subscribers || []);
|
||||||
|
setSubTotal(subData.total || 0);
|
||||||
|
setMessages(msgData.messages || []);
|
||||||
|
setMsgTotal(msgData.total || 0);
|
||||||
|
} catch {
|
||||||
|
setError("Failed to fetch data");
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="max-w-6xl mx-auto px-4 py-12">
|
||||||
|
<h1
|
||||||
|
className="text-[clamp(1.75rem,3vw,2.5rem)] font-extrabold mb-8"
|
||||||
|
style={{ fontFamily: "var(--font-display)", letterSpacing: "-0.02em" }}
|
||||||
|
>
|
||||||
|
Admin Dashboard
|
||||||
|
</h1>
|
||||||
|
|
||||||
|
<div className="mb-8 flex flex-col sm:flex-row gap-3 items-start">
|
||||||
|
<input
|
||||||
|
type="password"
|
||||||
|
value={apiKey}
|
||||||
|
onChange={(e) => setApiKey(e.target.value)}
|
||||||
|
placeholder="Enter admin API key"
|
||||||
|
className="flex-1 bg-[#12121a] border border-[rgba(255,255,255,0.08)] rounded-lg px-4 py-2.5 text-sm text-[#f5f5f7] focus:outline-none focus:border-[rgba(212,165,116,0.4)]"
|
||||||
|
/>
|
||||||
|
<button
|
||||||
|
onClick={fetchData}
|
||||||
|
disabled={loading}
|
||||||
|
className="px-6 py-2.5 bg-[#d4a574] text-[#0a0a0f] text-sm font-semibold rounded-lg hover:bg-[#e8c4a0] transition-colors disabled:opacity-50"
|
||||||
|
>
|
||||||
|
{loading ? "Loading..." : "Load Data"}
|
||||||
|
</button>
|
||||||
|
{subscribers.length > 0 && (
|
||||||
|
<button
|
||||||
|
onClick={() => exportCSV(subscribers)}
|
||||||
|
className="px-6 py-2.5 bg-emerald-600 text-[#f5f5f7] text-sm font-semibold rounded-lg hover:bg-emerald-500 transition-colors"
|
||||||
|
>
|
||||||
|
Export CSV
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{error && (
|
||||||
|
<div className="mb-6 p-4 rounded-xl bg-red-500/10 border border-red-500/20 text-red-400 text-sm">
|
||||||
|
{error}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Stats */}
|
||||||
|
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4 mb-10">
|
||||||
|
<div className="p-6 rounded-2xl bg-[#12121a] border border-[rgba(255,255,255,0.08)]">
|
||||||
|
<div className="text-3xl font-bold text-[#f5f5f7]">{subTotal}</div>
|
||||||
|
<div className="text-sm text-[#a0a0a8] mt-1">Newsletter Subscribers</div>
|
||||||
|
</div>
|
||||||
|
<div className="p-6 rounded-2xl bg-[#12121a] border border-[rgba(255,255,255,0.08)]">
|
||||||
|
<div className="text-3xl font-bold text-[#f5f5f7]">{msgTotal}</div>
|
||||||
|
<div className="text-sm text-[#a0a0a8] mt-1">Contact Messages</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Subscribers Chart */}
|
||||||
|
{subscribers.length > 0 && (
|
||||||
|
<div className="mb-10 p-6 rounded-2xl bg-[#12121a] border border-[rgba(255,255,255,0.08)]">
|
||||||
|
<h2 className="text-lg font-semibold mb-4">Subscriber Growth</h2>
|
||||||
|
<SubscriberChart
|
||||||
|
data={useMemo(() => {
|
||||||
|
const map = new Map<string, number>();
|
||||||
|
subscribers.forEach((s) => {
|
||||||
|
const date = new Date(s.created_at).toLocaleDateString("en-US", {
|
||||||
|
month: "short",
|
||||||
|
day: "numeric",
|
||||||
|
});
|
||||||
|
map.set(date, (map.get(date) || 0) + 1);
|
||||||
|
});
|
||||||
|
return Array.from(map.entries()).map(([date, count]) => ({ date, count }));
|
||||||
|
}, [subscribers])}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Subscribers Table */}
|
||||||
|
{subscribers.length > 0 && (
|
||||||
|
<div className="mb-10">
|
||||||
|
<h2 className="text-lg font-semibold mb-4">Recent Subscribers</h2>
|
||||||
|
<div className="overflow-x-auto rounded-2xl border border-[rgba(255,255,255,0.08)]">
|
||||||
|
<table className="w-full text-sm">
|
||||||
|
<thead className="bg-[rgba(255,255,255,0.03)] text-[#a0a0a8]">
|
||||||
|
<tr>
|
||||||
|
<th className="text-left px-4 py-3 font-medium">Email</th>
|
||||||
|
<th className="text-left px-4 py-3 font-medium">Locale</th>
|
||||||
|
<th className="text-left px-4 py-3 font-medium">Date</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody className="divide-y divide-[rgba(255,255,255,0.06)]">
|
||||||
|
{subscribers.map((sub) => (
|
||||||
|
<tr key={sub.id} className="hover:bg-[rgba(255,255,255,0.02)]">
|
||||||
|
<td className="px-4 py-3 text-[#a0a0a8]">{sub.email}</td>
|
||||||
|
<td className="px-4 py-3 text-[#6b6b75]">{sub.locale}</td>
|
||||||
|
<td className="px-4 py-3 text-[#6b6b75]">{formatDate(sub.created_at)}</td>
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Messages Table */}
|
||||||
|
{messages.length > 0 && (
|
||||||
|
<div>
|
||||||
|
<h2 className="text-lg font-semibold mb-4">Recent Contact Messages</h2>
|
||||||
|
<div className="space-y-3">
|
||||||
|
{messages.map((msg) => (
|
||||||
|
<div
|
||||||
|
key={msg.id}
|
||||||
|
className="p-4 rounded-xl bg-[#12121a] border border-[rgba(255,255,255,0.08)] hover:border-[rgba(212,165,116,0.2)] transition-colors"
|
||||||
|
>
|
||||||
|
<div className="flex items-center justify-between mb-2">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<span className="font-medium text-[#f5f5f7]">{msg.name}</span>
|
||||||
|
<span className="text-[#6b6b75]"><{msg.email}></span>
|
||||||
|
</div>
|
||||||
|
<span className="text-xs text-[#6b6b75]">{formatDate(msg.created_at)}</span>
|
||||||
|
</div>
|
||||||
|
{msg.subject && <div className="text-sm text-[#a0a0a8] mb-1">{msg.subject}</div>}
|
||||||
|
<div className="text-sm text-[#6b6b75]">{msg.message}</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,33 +0,0 @@
|
|||||||
"use client";
|
|
||||||
|
|
||||||
import Link from "next/link";
|
|
||||||
import { useLocale, useTranslations } from "next-intl";
|
|
||||||
|
|
||||||
export default function BuyFailurePage() {
|
|
||||||
const t = useTranslations("afc");
|
|
||||||
const locale = useLocale();
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="max-w-lg mx-auto px-4 py-20 text-center">
|
|
||||||
<div className="w-20 h-20 mx-auto mb-6 rounded-full bg-red-500/15 border-2 border-red-500/30 flex items-center justify-center">
|
|
||||||
<span className="text-4xl">✕</span>
|
|
||||||
</div>
|
|
||||||
<h1 className="text-3xl font-bold mb-3 text-white">{t("payment_failure_title")}</h1>
|
|
||||||
<p className="text-gray-400 mb-8">{t("payment_failure_description")}</p>
|
|
||||||
<div className="flex flex-col sm:flex-row gap-3 justify-center">
|
|
||||||
<Link
|
|
||||||
href={`/${locale}/afc/buy`}
|
|
||||||
className="px-6 py-3 bg-amber-500 hover:bg-amber-400 text-black font-semibold rounded-xl transition-colors"
|
|
||||||
>
|
|
||||||
{t("try_again")}
|
|
||||||
</Link>
|
|
||||||
<Link
|
|
||||||
href={`/${locale}/afc`}
|
|
||||||
className="px-6 py-3 bg-gray-800 hover:bg-gray-700 text-white font-semibold rounded-xl transition-colors"
|
|
||||||
>
|
|
||||||
{t("back_to_store")}
|
|
||||||
</Link>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,138 +0,0 @@
|
|||||||
"use client";
|
|
||||||
|
|
||||||
import { useState } from "react";
|
|
||||||
import Link from "next/link";
|
|
||||||
import { useLocale, useTranslations } from "next-intl";
|
|
||||||
import { useDiskId } from "@/hooks/useDiskId";
|
|
||||||
import { createPreference } from "@/lib/afc";
|
|
||||||
import { DiskIdInput } from "@/components/afc/DiskIdInput";
|
|
||||||
import { BalanceDisplay } from "@/components/afc/BalanceDisplay";
|
|
||||||
import { AfcPackageCard } from "@/components/afc/AfcPackageCard";
|
|
||||||
|
|
||||||
const PRICE_PER_AFC = 15;
|
|
||||||
|
|
||||||
const PACKAGES = [
|
|
||||||
{ amount: 10, popular: false },
|
|
||||||
{ amount: 25, popular: true },
|
|
||||||
{ amount: 50, popular: false },
|
|
||||||
{ amount: 100, popular: false },
|
|
||||||
];
|
|
||||||
|
|
||||||
export default function BuyAfcPage() {
|
|
||||||
const t = useTranslations("afc");
|
|
||||||
const locale = useLocale();
|
|
||||||
const disk = useDiskId();
|
|
||||||
const [customAmount, setCustomAmount] = useState("");
|
|
||||||
const [loading, setLoading] = useState(false);
|
|
||||||
const [error, setError] = useState<string | null>(null);
|
|
||||||
|
|
||||||
async function handleBuy(amount: number) {
|
|
||||||
if (!disk.verified || !disk.diskId) return;
|
|
||||||
setLoading(true);
|
|
||||||
setError(null);
|
|
||||||
try {
|
|
||||||
const data = await createPreference(disk.diskId, amount);
|
|
||||||
// Redirect to MercadoPago checkout
|
|
||||||
window.location.href = data.initPoint;
|
|
||||||
} catch (e: any) {
|
|
||||||
setError(e.message);
|
|
||||||
setLoading(false);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="max-w-3xl mx-auto px-4 py-12">
|
|
||||||
{/* Back */}
|
|
||||||
<Link
|
|
||||||
href={`/${locale}/afc`}
|
|
||||||
className="inline-flex items-center gap-2 text-sm text-gray-500 hover:text-gray-300 transition-colors mb-8"
|
|
||||||
>
|
|
||||||
← {t("back_to_store")}
|
|
||||||
</Link>
|
|
||||||
|
|
||||||
<h1 className="text-3xl font-bold mb-2">{t("buy_title")}</h1>
|
|
||||||
<p className="text-gray-400 mb-8">{t("buy_subtitle")}</p>
|
|
||||||
|
|
||||||
{/* Disk ID */}
|
|
||||||
<div className="mb-8">
|
|
||||||
<DiskIdInput
|
|
||||||
diskId={disk.diskId}
|
|
||||||
onChange={disk.setDiskId}
|
|
||||||
onVerify={() => disk.verify(disk.diskId)}
|
|
||||||
loading={disk.loading}
|
|
||||||
verified={disk.verified}
|
|
||||||
playerName={disk.playerName}
|
|
||||||
error={disk.error}
|
|
||||||
onClear={disk.clear}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{disk.verified && (
|
|
||||||
<>
|
|
||||||
{/* Balance */}
|
|
||||||
<div className="mb-8">
|
|
||||||
<BalanceDisplay balance={disk.balance} compact />
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Packages */}
|
|
||||||
<div className="space-y-3 mb-8">
|
|
||||||
<h2 className="text-lg font-semibold text-white mb-4">{t("select_package")}</h2>
|
|
||||||
{PACKAGES.map((pkg) => (
|
|
||||||
<AfcPackageCard
|
|
||||||
key={pkg.amount}
|
|
||||||
amount={pkg.amount}
|
|
||||||
priceMxn={pkg.amount * PRICE_PER_AFC}
|
|
||||||
popular={pkg.popular}
|
|
||||||
loading={loading}
|
|
||||||
onSelect={() => handleBuy(pkg.amount)}
|
|
||||||
/>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Custom amount */}
|
|
||||||
<div className="bg-gray-900 border border-white/5 rounded-2xl p-6">
|
|
||||||
<h3 className="text-sm font-medium text-gray-400 mb-3">{t("custom_amount")}</h3>
|
|
||||||
<div className="flex gap-3">
|
|
||||||
<div className="flex-1 relative">
|
|
||||||
<input
|
|
||||||
type="number"
|
|
||||||
min="1"
|
|
||||||
value={customAmount}
|
|
||||||
onChange={(e) => setCustomAmount(e.target.value)}
|
|
||||||
placeholder="AFC"
|
|
||||||
className="w-full bg-gray-800 border border-white/10 rounded-xl px-4 py-3 text-white placeholder:text-gray-600 focus:outline-none focus:border-amber-500/50 transition-all"
|
|
||||||
/>
|
|
||||||
{customAmount && Number(customAmount) > 0 && (
|
|
||||||
<span className="absolute right-4 top-1/2 -translate-y-1/2 text-sm text-gray-500">
|
|
||||||
= ${Number(customAmount) * PRICE_PER_AFC} MXN
|
|
||||||
</span>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
<button
|
|
||||||
onClick={() => {
|
|
||||||
const amt = Number(customAmount);
|
|
||||||
if (amt >= 1) handleBuy(amt);
|
|
||||||
}}
|
|
||||||
disabled={loading || !customAmount || Number(customAmount) < 1}
|
|
||||||
className="px-6 py-3 bg-amber-500 hover:bg-amber-400 disabled:bg-gray-700 disabled:text-gray-500 text-black font-semibold rounded-xl transition-colors"
|
|
||||||
>
|
|
||||||
{t("buy")}
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{error && (
|
|
||||||
<div className="mt-4 bg-red-500/10 border border-red-500/30 rounded-xl p-4 text-sm text-red-400">
|
|
||||||
{error}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Payment info */}
|
|
||||||
<p className="mt-6 text-xs text-gray-600 text-center">
|
|
||||||
{t("payment_info")}
|
|
||||||
</p>
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,39 +0,0 @@
|
|||||||
"use client";
|
|
||||||
|
|
||||||
import Link from "next/link";
|
|
||||||
import { useLocale, useTranslations } from "next-intl";
|
|
||||||
import { useSearchParams } from "next/navigation";
|
|
||||||
|
|
||||||
export default function BuyPendingPage() {
|
|
||||||
const t = useTranslations("afc");
|
|
||||||
const locale = useLocale();
|
|
||||||
const searchParams = useSearchParams();
|
|
||||||
const paymentId = searchParams.get("payment_id");
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="max-w-lg mx-auto px-4 py-20 text-center">
|
|
||||||
<div className="w-20 h-20 mx-auto mb-6 rounded-full bg-yellow-500/15 border-2 border-yellow-500/30 flex items-center justify-center">
|
|
||||||
<span className="text-4xl">⏳</span>
|
|
||||||
</div>
|
|
||||||
<h1 className="text-3xl font-bold mb-3 text-white">{t("payment_pending_title")}</h1>
|
|
||||||
<p className="text-gray-400 mb-2">{t("payment_pending_description")}</p>
|
|
||||||
{paymentId && (
|
|
||||||
<p className="text-xs text-gray-600 mb-8 font-mono">ID: {paymentId}</p>
|
|
||||||
)}
|
|
||||||
<div className="flex flex-col sm:flex-row gap-3 justify-center">
|
|
||||||
<Link
|
|
||||||
href={`/${locale}/afc/history`}
|
|
||||||
className="px-6 py-3 bg-amber-500 hover:bg-amber-400 text-black font-semibold rounded-xl transition-colors"
|
|
||||||
>
|
|
||||||
{t("view_history")}
|
|
||||||
</Link>
|
|
||||||
<Link
|
|
||||||
href={`/${locale}/afc`}
|
|
||||||
className="px-6 py-3 bg-gray-800 hover:bg-gray-700 text-white font-semibold rounded-xl transition-colors"
|
|
||||||
>
|
|
||||||
{t("back_to_store")}
|
|
||||||
</Link>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,39 +0,0 @@
|
|||||||
"use client";
|
|
||||||
|
|
||||||
import Link from "next/link";
|
|
||||||
import { useLocale, useTranslations } from "next-intl";
|
|
||||||
import { useSearchParams } from "next/navigation";
|
|
||||||
|
|
||||||
export default function BuySuccessPage() {
|
|
||||||
const t = useTranslations("afc");
|
|
||||||
const locale = useLocale();
|
|
||||||
const searchParams = useSearchParams();
|
|
||||||
const paymentId = searchParams.get("payment_id");
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="max-w-lg mx-auto px-4 py-20 text-center">
|
|
||||||
<div className="w-20 h-20 mx-auto mb-6 rounded-full bg-green-500/15 border-2 border-green-500/30 flex items-center justify-center">
|
|
||||||
<span className="text-4xl">✓</span>
|
|
||||||
</div>
|
|
||||||
<h1 className="text-3xl font-bold mb-3 text-white">{t("payment_success_title")}</h1>
|
|
||||||
<p className="text-gray-400 mb-2">{t("payment_success_description")}</p>
|
|
||||||
{paymentId && (
|
|
||||||
<p className="text-xs text-gray-600 mb-8 font-mono">ID: {paymentId}</p>
|
|
||||||
)}
|
|
||||||
<div className="flex flex-col sm:flex-row gap-3 justify-center">
|
|
||||||
<Link
|
|
||||||
href={`/${locale}/afc`}
|
|
||||||
className="px-6 py-3 bg-amber-500 hover:bg-amber-400 text-black font-semibold rounded-xl transition-colors"
|
|
||||||
>
|
|
||||||
{t("back_to_store")}
|
|
||||||
</Link>
|
|
||||||
<Link
|
|
||||||
href={`/${locale}/afc/history`}
|
|
||||||
className="px-6 py-3 bg-gray-800 hover:bg-gray-700 text-white font-semibold rounded-xl transition-colors"
|
|
||||||
>
|
|
||||||
{t("view_history")}
|
|
||||||
</Link>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,101 +0,0 @@
|
|||||||
"use client";
|
|
||||||
|
|
||||||
import { useState, useEffect } from "react";
|
|
||||||
import Link from "next/link";
|
|
||||||
import { useLocale, useTranslations } from "next-intl";
|
|
||||||
import { useDiskId } from "@/hooks/useDiskId";
|
|
||||||
import { getPaymentHistory, getRedemptionHistory } from "@/lib/afc";
|
|
||||||
import type { Payment, Redemption } from "@/lib/afc";
|
|
||||||
import { DiskIdInput } from "@/components/afc/DiskIdInput";
|
|
||||||
import { BalanceDisplay } from "@/components/afc/BalanceDisplay";
|
|
||||||
import { PaymentHistoryTable } from "@/components/afc/PaymentHistoryTable";
|
|
||||||
import { RedemptionHistoryTable } from "@/components/afc/RedemptionHistoryTable";
|
|
||||||
|
|
||||||
type Tab = "payments" | "redemptions";
|
|
||||||
|
|
||||||
export default function HistoryPage() {
|
|
||||||
const t = useTranslations("afc");
|
|
||||||
const locale = useLocale();
|
|
||||||
const disk = useDiskId();
|
|
||||||
const [tab, setTab] = useState<Tab>("payments");
|
|
||||||
const [payments, setPayments] = useState<Payment[]>([]);
|
|
||||||
const [redemptions, setRedemptions] = useState<Redemption[]>([]);
|
|
||||||
const [loadingData, setLoadingData] = useState(false);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (!disk.verified || !disk.diskId) return;
|
|
||||||
setLoadingData(true);
|
|
||||||
Promise.all([
|
|
||||||
getPaymentHistory(disk.diskId).then((d) => setPayments(d.payments || [])).catch(() => {}),
|
|
||||||
getRedemptionHistory(disk.diskId).then((d) => setRedemptions(d.redemptions || [])).catch(() => {}),
|
|
||||||
]).finally(() => setLoadingData(false));
|
|
||||||
}, [disk.verified, disk.diskId]);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="max-w-3xl mx-auto px-4 py-12">
|
|
||||||
<Link
|
|
||||||
href={`/${locale}/afc`}
|
|
||||||
className="inline-flex items-center gap-2 text-sm text-gray-500 hover:text-gray-300 transition-colors mb-8"
|
|
||||||
>
|
|
||||||
← {t("back_to_store")}
|
|
||||||
</Link>
|
|
||||||
|
|
||||||
<h1 className="text-3xl font-bold mb-2">{t("history_title")}</h1>
|
|
||||||
<p className="text-gray-400 mb-8">{t("history_subtitle")}</p>
|
|
||||||
|
|
||||||
{/* Disk ID */}
|
|
||||||
<div className="mb-8">
|
|
||||||
<DiskIdInput
|
|
||||||
diskId={disk.diskId}
|
|
||||||
onChange={disk.setDiskId}
|
|
||||||
onVerify={() => disk.verify(disk.diskId)}
|
|
||||||
loading={disk.loading}
|
|
||||||
verified={disk.verified}
|
|
||||||
playerName={disk.playerName}
|
|
||||||
error={disk.error}
|
|
||||||
onClear={disk.clear}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{disk.verified && (
|
|
||||||
<>
|
|
||||||
<div className="mb-8">
|
|
||||||
<BalanceDisplay balance={disk.balance} compact />
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Tabs */}
|
|
||||||
<div className="flex gap-1 bg-gray-900 rounded-xl p-1 mb-6">
|
|
||||||
<button
|
|
||||||
onClick={() => setTab("payments")}
|
|
||||||
className={`flex-1 py-2.5 text-sm font-medium rounded-lg transition-colors ${
|
|
||||||
tab === "payments"
|
|
||||||
? "bg-amber-500 text-black"
|
|
||||||
: "text-gray-400 hover:text-white"
|
|
||||||
}`}
|
|
||||||
>
|
|
||||||
{t("purchases")} ({payments.length})
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
onClick={() => setTab("redemptions")}
|
|
||||||
className={`flex-1 py-2.5 text-sm font-medium rounded-lg transition-colors ${
|
|
||||||
tab === "redemptions"
|
|
||||||
? "bg-amber-500 text-black"
|
|
||||||
: "text-gray-400 hover:text-white"
|
|
||||||
}`}
|
|
||||||
>
|
|
||||||
{t("redemptions")} ({redemptions.length})
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{loadingData ? (
|
|
||||||
<div className="text-center py-12 text-gray-500">{t("loading")}</div>
|
|
||||||
) : tab === "payments" ? (
|
|
||||||
<PaymentHistoryTable payments={payments} />
|
|
||||||
) : (
|
|
||||||
<RedemptionHistoryTable redemptions={redemptions} />
|
|
||||||
)}
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,90 +0,0 @@
|
|||||||
"use client";
|
|
||||||
|
|
||||||
import Link from "next/link";
|
|
||||||
import { useLocale, useTranslations } from "next-intl";
|
|
||||||
import { useDiskId } from "@/hooks/useDiskId";
|
|
||||||
import { DiskIdInput } from "@/components/afc/DiskIdInput";
|
|
||||||
import { BalanceDisplay } from "@/components/afc/BalanceDisplay";
|
|
||||||
|
|
||||||
export default function AfcHubPage() {
|
|
||||||
const t = useTranslations("afc");
|
|
||||||
const locale = useLocale();
|
|
||||||
const disk = useDiskId();
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="max-w-4xl mx-auto px-4 py-12">
|
|
||||||
{/* Header */}
|
|
||||||
<div className="text-center mb-12">
|
|
||||||
<div className="w-20 h-20 mx-auto mb-6 rounded-full bg-gradient-to-br from-amber-400 to-amber-600 flex items-center justify-center shadow-lg shadow-amber-500/20">
|
|
||||||
<span className="text-3xl font-bold text-black">A</span>
|
|
||||||
</div>
|
|
||||||
<h1 className="text-4xl font-bold mb-3">{t("store_title")}</h1>
|
|
||||||
<p className="text-gray-400 text-lg max-w-xl mx-auto">
|
|
||||||
{t("store_subtitle")}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Disk ID */}
|
|
||||||
<div className="mb-10">
|
|
||||||
<DiskIdInput
|
|
||||||
diskId={disk.diskId}
|
|
||||||
onChange={disk.setDiskId}
|
|
||||||
onVerify={() => disk.verify(disk.diskId)}
|
|
||||||
loading={disk.loading}
|
|
||||||
verified={disk.verified}
|
|
||||||
playerName={disk.playerName}
|
|
||||||
error={disk.error}
|
|
||||||
onClear={disk.clear}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Balance */}
|
|
||||||
{disk.verified && (
|
|
||||||
<div className="mb-10">
|
|
||||||
<BalanceDisplay balance={disk.balance} />
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Action cards */}
|
|
||||||
<div className="grid grid-cols-1 md:grid-cols-3 gap-6">
|
|
||||||
<Link
|
|
||||||
href={`/${locale}/afc/buy`}
|
|
||||||
className="group block bg-gray-900 rounded-2xl p-8 border border-white/5 hover:border-amber-500/40 transition-all duration-200 hover:shadow-lg hover:shadow-amber-500/5"
|
|
||||||
>
|
|
||||||
<div className="w-12 h-12 rounded-xl bg-green-500/10 flex items-center justify-center mb-4 group-hover:bg-green-500/20 transition-colors">
|
|
||||||
<span className="text-2xl">+</span>
|
|
||||||
</div>
|
|
||||||
<h2 className="text-xl font-bold mb-2 text-white">{t("buy_title")}</h2>
|
|
||||||
<p className="text-gray-500 text-sm">{t("buy_description")}</p>
|
|
||||||
</Link>
|
|
||||||
|
|
||||||
<Link
|
|
||||||
href={`/${locale}/afc/redeem`}
|
|
||||||
className="group block bg-gray-900 rounded-2xl p-8 border border-white/5 hover:border-amber-500/40 transition-all duration-200 hover:shadow-lg hover:shadow-amber-500/5"
|
|
||||||
>
|
|
||||||
<div className="w-12 h-12 rounded-xl bg-amber-500/10 flex items-center justify-center mb-4 group-hover:bg-amber-500/20 transition-colors">
|
|
||||||
<span className="text-2xl">★</span>
|
|
||||||
</div>
|
|
||||||
<h2 className="text-xl font-bold mb-2 text-white">{t("redeem_title")}</h2>
|
|
||||||
<p className="text-gray-500 text-sm">{t("redeem_description")}</p>
|
|
||||||
</Link>
|
|
||||||
|
|
||||||
<Link
|
|
||||||
href={`/${locale}/afc/history`}
|
|
||||||
className="group block bg-gray-900 rounded-2xl p-8 border border-white/5 hover:border-amber-500/40 transition-all duration-200 hover:shadow-lg hover:shadow-amber-500/5"
|
|
||||||
>
|
|
||||||
<div className="w-12 h-12 rounded-xl bg-blue-500/10 flex items-center justify-center mb-4 group-hover:bg-blue-500/20 transition-colors">
|
|
||||||
<span className="text-2xl">≡</span>
|
|
||||||
</div>
|
|
||||||
<h2 className="text-xl font-bold mb-2 text-white">{t("history_title")}</h2>
|
|
||||||
<p className="text-gray-500 text-sm">{t("history_description")}</p>
|
|
||||||
</Link>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Info */}
|
|
||||||
<div className="mt-12 bg-gray-900/50 border border-white/5 rounded-2xl p-6 text-sm text-gray-500">
|
|
||||||
<p>{t("store_info")}</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,184 +0,0 @@
|
|||||||
"use client";
|
|
||||||
|
|
||||||
import { useState } from "react";
|
|
||||||
import Link from "next/link";
|
|
||||||
import { useLocale, useTranslations } from "next-intl";
|
|
||||||
import { useDiskId } from "@/hooks/useDiskId";
|
|
||||||
import { redeemAfc } from "@/lib/afc";
|
|
||||||
import { DiskIdInput } from "@/components/afc/DiskIdInput";
|
|
||||||
import { BalanceDisplay } from "@/components/afc/BalanceDisplay";
|
|
||||||
import { PrizeCard } from "@/components/afc/PrizeCard";
|
|
||||||
import { RedeemForm } from "@/components/afc/RedeemForm";
|
|
||||||
|
|
||||||
interface Prize {
|
|
||||||
icon: string;
|
|
||||||
brand: string;
|
|
||||||
label: string;
|
|
||||||
costAfc: number;
|
|
||||||
valueMxn: number;
|
|
||||||
prizeType: string;
|
|
||||||
prizeDetail: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
const GIFT_CARDS: Prize[] = [
|
|
||||||
{ icon: "🎮", brand: "Steam", label: "$200 MXN", costAfc: 14, valueMxn: 200, prizeType: "gift_card", prizeDetail: "Steam $200 MXN" },
|
|
||||||
{ icon: "🎮", brand: "Steam", label: "$500 MXN", costAfc: 34, valueMxn: 500, prizeType: "gift_card", prizeDetail: "Steam $500 MXN" },
|
|
||||||
{ icon: "🎮", brand: "Steam", label: "$1,000 MXN", costAfc: 67, valueMxn: 1000, prizeType: "gift_card", prizeDetail: "Steam $1,000 MXN" },
|
|
||||||
{ icon: "🟢", brand: "Xbox", label: "$200 MXN", costAfc: 14, valueMxn: 200, prizeType: "gift_card", prizeDetail: "Xbox $200 MXN" },
|
|
||||||
{ icon: "🟢", brand: "Xbox", label: "$500 MXN", costAfc: 34, valueMxn: 500, prizeType: "gift_card", prizeDetail: "Xbox $500 MXN" },
|
|
||||||
{ icon: "🔵", brand: "PlayStation", label: "$200 MXN", costAfc: 14, valueMxn: 200, prizeType: "gift_card", prizeDetail: "PlayStation $200 MXN" },
|
|
||||||
{ icon: "🔵", brand: "PlayStation", label: "$500 MXN", costAfc: 14, valueMxn: 500, prizeType: "gift_card", prizeDetail: "PlayStation $500 MXN" },
|
|
||||||
{ icon: "📦", brand: "Amazon", label: "$200 MXN", costAfc: 14, valueMxn: 200, prizeType: "gift_card", prizeDetail: "Amazon $200 MXN" },
|
|
||||||
{ icon: "📦", brand: "Amazon", label: "$500 MXN", costAfc: 34, valueMxn: 500, prizeType: "gift_card", prizeDetail: "Amazon $500 MXN" },
|
|
||||||
];
|
|
||||||
|
|
||||||
const CASH_OUT: Prize[] = [
|
|
||||||
{ icon: "🏦", brand: "Banco (CLABE)", label: "$750+ MXN", costAfc: 50, valueMxn: 750, prizeType: "bank_transfer", prizeDetail: "Transferencia bancaria $750 MXN" },
|
|
||||||
{ icon: "💳", brand: "MercadoPago", label: "$750+ MXN", costAfc: 50, valueMxn: 750, prizeType: "mercadopago", prizeDetail: "Retiro MercadoPago $750 MXN" },
|
|
||||||
];
|
|
||||||
|
|
||||||
export default function RedeemPage() {
|
|
||||||
const t = useTranslations("afc");
|
|
||||||
const locale = useLocale();
|
|
||||||
const disk = useDiskId();
|
|
||||||
const [selected, setSelected] = useState<Prize | null>(null);
|
|
||||||
const [loading, setLoading] = useState(false);
|
|
||||||
const [success, setSuccess] = useState(false);
|
|
||||||
const [error, setError] = useState<string | null>(null);
|
|
||||||
|
|
||||||
async function handleRedeem(deliveryInfo: string) {
|
|
||||||
if (!selected || !disk.diskId) return;
|
|
||||||
setLoading(true);
|
|
||||||
setError(null);
|
|
||||||
try {
|
|
||||||
await redeemAfc({
|
|
||||||
diskId: disk.diskId,
|
|
||||||
amountAfc: selected.costAfc,
|
|
||||||
prizeType: selected.prizeType,
|
|
||||||
prizeDetail: selected.prizeDetail,
|
|
||||||
deliveryInfo,
|
|
||||||
});
|
|
||||||
setSuccess(true);
|
|
||||||
disk.refreshBalance();
|
|
||||||
} catch (e: any) {
|
|
||||||
setError(e.message);
|
|
||||||
} finally {
|
|
||||||
setLoading(false);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (success) {
|
|
||||||
return (
|
|
||||||
<div className="max-w-lg mx-auto px-4 py-20 text-center">
|
|
||||||
<div className="w-20 h-20 mx-auto mb-6 rounded-full bg-green-500/15 border-2 border-green-500/30 flex items-center justify-center">
|
|
||||||
<span className="text-4xl">✓</span>
|
|
||||||
</div>
|
|
||||||
<h1 className="text-3xl font-bold mb-3">{t("redeem_success_title")}</h1>
|
|
||||||
<p className="text-gray-400 mb-8">{t("redeem_success_description")}</p>
|
|
||||||
<div className="flex flex-col sm:flex-row gap-3 justify-center">
|
|
||||||
<Link
|
|
||||||
href={`/${locale}/afc/history`}
|
|
||||||
className="px-6 py-3 bg-amber-500 hover:bg-amber-400 text-black font-semibold rounded-xl transition-colors"
|
|
||||||
>
|
|
||||||
{t("view_history")}
|
|
||||||
</Link>
|
|
||||||
<Link
|
|
||||||
href={`/${locale}/afc`}
|
|
||||||
className="px-6 py-3 bg-gray-800 hover:bg-gray-700 text-white font-semibold rounded-xl transition-colors"
|
|
||||||
>
|
|
||||||
{t("back_to_store")}
|
|
||||||
</Link>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="max-w-3xl mx-auto px-4 py-12">
|
|
||||||
<Link
|
|
||||||
href={`/${locale}/afc`}
|
|
||||||
className="inline-flex items-center gap-2 text-sm text-gray-500 hover:text-gray-300 transition-colors mb-8"
|
|
||||||
>
|
|
||||||
← {t("back_to_store")}
|
|
||||||
</Link>
|
|
||||||
|
|
||||||
<h1 className="text-3xl font-bold mb-2">{t("redeem_title")}</h1>
|
|
||||||
<p className="text-gray-400 mb-8">{t("redeem_subtitle")}</p>
|
|
||||||
|
|
||||||
{/* Disk ID */}
|
|
||||||
<div className="mb-8">
|
|
||||||
<DiskIdInput
|
|
||||||
diskId={disk.diskId}
|
|
||||||
onChange={disk.setDiskId}
|
|
||||||
onVerify={() => disk.verify(disk.diskId)}
|
|
||||||
loading={disk.loading}
|
|
||||||
verified={disk.verified}
|
|
||||||
playerName={disk.playerName}
|
|
||||||
error={disk.error}
|
|
||||||
onClear={disk.clear}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{disk.verified && (
|
|
||||||
<>
|
|
||||||
<div className="mb-8">
|
|
||||||
<BalanceDisplay balance={disk.balance} />
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{selected ? (
|
|
||||||
<RedeemForm
|
|
||||||
prizeType={selected.prizeType}
|
|
||||||
prizeDetail={selected.prizeDetail}
|
|
||||||
costAfc={selected.costAfc}
|
|
||||||
onSubmit={handleRedeem}
|
|
||||||
onCancel={() => setSelected(null)}
|
|
||||||
loading={loading}
|
|
||||||
/>
|
|
||||||
) : (
|
|
||||||
<>
|
|
||||||
{/* Gift Cards */}
|
|
||||||
<h2 className="text-lg font-semibold text-white mb-4">{t("gift_cards")}</h2>
|
|
||||||
<div className="space-y-3 mb-8">
|
|
||||||
{GIFT_CARDS.map((prize, i) => (
|
|
||||||
<PrizeCard
|
|
||||||
key={i}
|
|
||||||
icon={prize.icon}
|
|
||||||
brand={prize.brand}
|
|
||||||
label={prize.label}
|
|
||||||
costAfc={prize.costAfc}
|
|
||||||
valueMxn={prize.valueMxn}
|
|
||||||
disabled={disk.balance !== null && disk.balance < prize.costAfc}
|
|
||||||
onSelect={() => setSelected(prize)}
|
|
||||||
/>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Cash Out */}
|
|
||||||
<h2 className="text-lg font-semibold text-white mb-4">{t("cash_out")}</h2>
|
|
||||||
<div className="space-y-3">
|
|
||||||
{CASH_OUT.map((prize, i) => (
|
|
||||||
<PrizeCard
|
|
||||||
key={i}
|
|
||||||
icon={prize.icon}
|
|
||||||
brand={prize.brand}
|
|
||||||
label={prize.label}
|
|
||||||
costAfc={prize.costAfc}
|
|
||||||
valueMxn={prize.valueMxn}
|
|
||||||
disabled={disk.balance !== null && disk.balance < prize.costAfc}
|
|
||||||
onSelect={() => setSelected(prize)}
|
|
||||||
/>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{error && (
|
|
||||||
<div className="mt-4 bg-red-500/10 border border-red-500/30 rounded-xl p-4 text-sm text-red-400">
|
|
||||||
{error}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,8 +1,13 @@
|
|||||||
|
import type { Metadata } from "next";
|
||||||
import { Suspense } from "react";
|
import { Suspense } from "react";
|
||||||
import { getGames } from "@/lib/api";
|
import { getGames } from "@/lib/api";
|
||||||
import { CatalogFilters } from "@/components/catalog/CatalogFilters";
|
import { CatalogFilters } from "@/components/catalog/CatalogFilters";
|
||||||
import { CatalogGrid } from "@/components/catalog/CatalogGrid";
|
import { CatalogGrid } from "@/components/catalog/CatalogGrid";
|
||||||
|
|
||||||
|
export const metadata: Metadata = {
|
||||||
|
title: "Game Catalog | Project Afterlife",
|
||||||
|
};
|
||||||
|
|
||||||
export default async function CatalogPage({
|
export default async function CatalogPage({
|
||||||
params,
|
params,
|
||||||
}: {
|
}: {
|
||||||
@@ -20,8 +25,14 @@ export default async function CatalogPage({
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="max-w-7xl mx-auto px-4 py-12">
|
<div className="max-w-7xl mx-auto px-4 py-12">
|
||||||
<h1 className="text-4xl font-bold mb-8">
|
<h1
|
||||||
{locale === "es" ? "Catálogo de Juegos" : "Game Catalog"}
|
className="text-[clamp(2rem,4vw,3rem)] font-extrabold mb-8"
|
||||||
|
style={{ fontFamily: "var(--font-display)", letterSpacing: "-0.02em" }}
|
||||||
|
>
|
||||||
|
{locale === "es" ? "Catálogo de " : "Game "}
|
||||||
|
<span style={{ color: "var(--accent-primary)" }}>
|
||||||
|
{locale === "es" ? "Juegos" : "Catalog"}
|
||||||
|
</span>
|
||||||
</h1>
|
</h1>
|
||||||
<Suspense>
|
<Suspense>
|
||||||
<CatalogFilters />
|
<CatalogFilters />
|
||||||
|
|||||||
183
apps/web/src/app/[locale]/community/page.tsx
Normal file
183
apps/web/src/app/[locale]/community/page.tsx
Normal file
@@ -0,0 +1,183 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useTranslations } from "next-intl";
|
||||||
|
import { motion } from "framer-motion";
|
||||||
|
import { useLocale } from "next-intl";
|
||||||
|
import Link from "next/link";
|
||||||
|
|
||||||
|
function DiscordIcon({ className }: { className?: string }) {
|
||||||
|
return (
|
||||||
|
<svg className={className} viewBox="0 0 24 24" fill="currentColor">
|
||||||
|
<path d="M20.317 4.37a19.791 19.791 0 0 0-4.885-1.515.074.074 0 0 0-.079.037c-.21.375-.444.864-.608 1.25a18.27 18.27 0 0 0-5.487 0 12.64 12.64 0 0 0-.617-1.25.077.077 0 0 0-.079-.037A19.736 19.736 0 0 0 3.677 4.37a.07.07 0 0 0-.032.027C.533 9.046-.32 13.58.099 18.057a.082.082 0 0 0 .031.057 19.9 19.9 0 0 0 5.993 3.03.078.078 0 0 0 .084-.028c.462-.63.874-1.295 1.226-1.994a.076.076 0 0 0-.041-.106 13.107 13.107 0 0 1-1.872-.892.077.077 0 0 1-.008-.128 10.2 10.2 0 0 0 .372-.292.074.074 0 0 1 .077-.01c3.928 1.793 8.18 1.793 12.062 0a.074.074 0 0 1 .078.01c.12.098.246.198.373.292a.077.077 0 0 1-.006.127 12.299 12.299 0 0 1-1.873.892.077.077 0 0 0-.041.107c.36.698.772 1.362 1.225 1.993a.076.076 0 0 0 .084.028 19.839 19.839 0 0 0 6.002-3.03.077.077 0 0 0 .032-.054c.5-5.177-.838-9.674-3.549-13.66a.061.061 0 0 0-.031-.03zM8.02 15.33c-1.183 0-2.157-1.085-2.157-2.419 0-1.333.956-2.419 2.157-2.419 1.21 0 2.176 1.096 2.157 2.42 0 1.333-.956 2.418-2.157 2.418zm7.975 0c-1.183 0-2.157-1.085-2.157-2.419 0-1.333.955-2.419 2.157-2.419 1.21 0 2.176 1.096 2.157 2.42 0 1.333-.946 2.418-2.157 2.418z" />
|
||||||
|
</svg>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function GitHubIcon({ className }: { className?: string }) {
|
||||||
|
return (
|
||||||
|
<svg className={className} viewBox="0 0 24 24" fill="currentColor">
|
||||||
|
<path d="M12 .297c-6.63 0-12 5.373-12 12 0 5.303 3.438 9.8 8.205 11.385.6.113.82-.258.82-.577 0-.285-.01-1.04-.015-2.04-3.338.724-4.042-1.61-4.042-1.61C4.422 18.07 3.633 17.7 3.633 17.7c-1.087-.744.084-.729.084-.729 1.205.084 1.838 1.236 1.838 1.236 1.07 1.835 2.809 1.305 3.495.998.108-.776.417-1.305.76-1.605-2.665-.3-5.466-1.332-5.466-5.93 0-1.31.465-2.38 1.235-3.22-.135-.303-.54-1.523.105-3.176 0 0 1.005-.322 3.3 1.23.96-.267 1.98-.399 3-.405 1.02.006 2.04.138 3 .405 2.28-1.552 3.285-1.23 3.285-1.23.645 1.653.24 2.873.12 3.176.765.84 1.23 1.91 1.23 3.22 0 4.61-2.805 5.625-5.475 5.92.42.36.81 1.096.81 2.22 0 1.606-.015 2.896-.015 3.286 0 .315.21.69.825.57C20.565 22.092 24 17.592 24 12.297c0-6.627-5.373-12-12-12" />
|
||||||
|
</svg>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function HeartIcon({ className }: { className?: string }) {
|
||||||
|
return (
|
||||||
|
<svg className={className} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
||||||
|
<path d="M20.84 4.61a5.5 5.5 0 0 0-7.78 0L12 5.67l-1.06-1.06a5.5 5.5 0 0 0-7.78 7.78l1.06 1.06L12 21.23l7.78-7.78 1.06-1.06a5.5 5.5 0 0 0 0-7.78z" />
|
||||||
|
</svg>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function MessageIcon({ className }: { className?: string }) {
|
||||||
|
return (
|
||||||
|
<svg className={className} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
||||||
|
<path d="M21 11.5a8.38 8.38 0 0 1-.9 3.8 8.5 8.5 0 0 1-7.6 4.7 8.38 8.38 0 0 1-3.8-.9L3 21l1.9-5.7a8.38 8.38 0 0 1-.9-3.8 8.5 8.5 0 0 1 4.7-7.6 8.38 8.38 0 0 1 3.8-.9h.5a8.48 8.48 0 0 1 8 8v.5z" />
|
||||||
|
</svg>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const cardVariants = {
|
||||||
|
hidden: { opacity: 0, y: 20 },
|
||||||
|
visible: (i: number) => ({
|
||||||
|
opacity: 1,
|
||||||
|
y: 0,
|
||||||
|
transition: { delay: i * 0.1, duration: 0.5, ease: "easeOut" as const },
|
||||||
|
}),
|
||||||
|
};
|
||||||
|
|
||||||
|
const channels = [
|
||||||
|
{
|
||||||
|
name: "Discord",
|
||||||
|
description: "Join our community Discord to chat with other players, get support, and stay updated on new releases.",
|
||||||
|
href: "https://discord.gg/projectafterlife",
|
||||||
|
icon: DiscordIcon,
|
||||||
|
color: "text-[#d4a574]",
|
||||||
|
bg: "bg-[rgba(212,165,116,0.08)] border-[rgba(212,165,116,0.15)] hover:border-[rgba(212,165,116,0.35)]",
|
||||||
|
btn: "bg-[#d4a574] hover:bg-[#e8c4a0] text-[#0a0a0f]",
|
||||||
|
btnText: "text-[#0a0a0f]",
|
||||||
|
label: "Join Discord",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "GitHub",
|
||||||
|
description: "Our code is open source. Contribute to the project, report issues, or explore our repositories.",
|
||||||
|
href: "https://github.com/projectafterlife",
|
||||||
|
icon: GitHubIcon,
|
||||||
|
color: "text-[#a0a0a8]",
|
||||||
|
bg: "bg-[rgba(255,255,255,0.03)] border-[rgba(255,255,255,0.08)] hover:border-[rgba(255,255,255,0.15)]",
|
||||||
|
btn: "bg-[#1a1a24] hover:bg-[#2a2a34] text-[#f5f5f7]",
|
||||||
|
btnText: "text-[#f5f5f7]",
|
||||||
|
label: "View GitHub",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "Forums",
|
||||||
|
description: "Long-form discussions, guides, bug reports, and feature requests. The heart of our community.",
|
||||||
|
href: "#",
|
||||||
|
icon: MessageIcon,
|
||||||
|
color: "text-[#e8c4a0]",
|
||||||
|
bg: "bg-[rgba(232,196,160,0.08)] border-[rgba(232,196,160,0.15)] hover:border-[rgba(232,196,160,0.35)]",
|
||||||
|
btn: "bg-[#e8c4a0] hover:bg-[#d4a574] text-[#0a0a0f]",
|
||||||
|
btnText: "text-[#0a0a0f]",
|
||||||
|
label: "Coming Soon",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "Contribute",
|
||||||
|
description: "Help us preserve gaming history. We need developers, writers, translators, and testers.",
|
||||||
|
href: "/donate",
|
||||||
|
icon: HeartIcon,
|
||||||
|
color: "text-rose-400",
|
||||||
|
bg: "bg-rose-500/8 border-rose-500/15 hover:border-rose-500/35",
|
||||||
|
btn: "bg-rose-600 hover:bg-rose-500 text-white",
|
||||||
|
btnText: "text-white",
|
||||||
|
label: "How to Help",
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
export default function CommunityPage() {
|
||||||
|
const locale = useLocale();
|
||||||
|
const isEs = locale === "es";
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="max-w-6xl mx-auto px-4 py-12">
|
||||||
|
<motion.div
|
||||||
|
initial={{ opacity: 0, y: 20 }}
|
||||||
|
animate={{ opacity: 1, y: 0 }}
|
||||||
|
transition={{ duration: 0.6 }}
|
||||||
|
className="text-center mb-16"
|
||||||
|
>
|
||||||
|
<h1
|
||||||
|
className="text-[clamp(2rem,4vw,3.5rem)] font-extrabold mb-4 tracking-tight"
|
||||||
|
style={{ fontFamily: "var(--font-display)", letterSpacing: "-0.02em" }}
|
||||||
|
>
|
||||||
|
{isEs ? "Comunidad" : "Community"}
|
||||||
|
</h1>
|
||||||
|
<p className="text-lg text-[#a0a0a8] max-w-2xl mx-auto leading-relaxed">
|
||||||
|
{isEs
|
||||||
|
? "Project Afterlife es impulsado por su comunidad. Únete a nosotros para preservar la historia de los juegos juntos."
|
||||||
|
: "Project Afterlife is driven by its community. Join us in preserving gaming history together."}
|
||||||
|
</p>
|
||||||
|
</motion.div>
|
||||||
|
|
||||||
|
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
|
||||||
|
{channels.map((channel, i) => {
|
||||||
|
const Icon = channel.icon;
|
||||||
|
const isExternal = channel.href.startsWith("http");
|
||||||
|
const Wrapper = isExternal ? "a" : Link;
|
||||||
|
const wrapperProps = isExternal
|
||||||
|
? { href: channel.href, target: "_blank", rel: "noopener noreferrer" }
|
||||||
|
: { href: channel.href };
|
||||||
|
|
||||||
|
return (
|
||||||
|
<motion.div
|
||||||
|
key={channel.name}
|
||||||
|
custom={i}
|
||||||
|
initial="hidden"
|
||||||
|
whileInView="visible"
|
||||||
|
viewport={{ once: true, margin: "-50px" }}
|
||||||
|
variants={cardVariants}
|
||||||
|
>
|
||||||
|
<Wrapper
|
||||||
|
{...wrapperProps}
|
||||||
|
className={`block rounded-2xl border p-8 transition-all duration-300 hover:scale-[1.02] ${channel.bg}`}
|
||||||
|
>
|
||||||
|
<div className="flex items-start gap-4">
|
||||||
|
<div className={`p-3 rounded-xl bg-black/30 ${channel.color}`}>
|
||||||
|
<Icon className="w-6 h-6" />
|
||||||
|
</div>
|
||||||
|
<div className="flex-1">
|
||||||
|
<h3 className="text-xl font-semibold text-[#f5f5f7] mb-2">{channel.name}</h3>
|
||||||
|
<p className="text-sm text-[#a0a0a8] leading-relaxed mb-4">
|
||||||
|
{channel.description}
|
||||||
|
</p>
|
||||||
|
<span
|
||||||
|
className={`inline-block px-4 py-1.5 rounded-lg text-sm font-medium transition-colors ${channel.btn}`}
|
||||||
|
>
|
||||||
|
{channel.label}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Wrapper>
|
||||||
|
</motion.div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<motion.div
|
||||||
|
initial={{ opacity: 0 }}
|
||||||
|
whileInView={{ opacity: 1 }}
|
||||||
|
viewport={{ once: true }}
|
||||||
|
transition={{ delay: 0.4, duration: 0.6 }}
|
||||||
|
className="mt-16 rounded-2xl border border-[rgba(255,255,255,0.08)] bg-[#12121a] p-8 text-center"
|
||||||
|
>
|
||||||
|
<h2 className="text-2xl font-semibold mb-3">
|
||||||
|
{isEs ? "Código de Conducta" : "Code of Conduct"}
|
||||||
|
</h2>
|
||||||
|
<p className="text-[#a0a0a8] max-w-2xl mx-auto leading-relaxed">
|
||||||
|
{isEs
|
||||||
|
? "Todos los miembros de nuestra comunidad deben tratar a los demás con respeto. No se tolera el acoso, la discriminación ni el comportamiento tóxico. Queremos que este sea un espacio seguro y acogedor para todos los amantes de los juegos."
|
||||||
|
: "All members of our community must treat others with respect. Harassment, discrimination, and toxic behavior are not tolerated. We want this to be a safe and welcoming space for all game lovers."}
|
||||||
|
</p>
|
||||||
|
</motion.div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
116
apps/web/src/app/[locale]/contact/page.tsx
Normal file
116
apps/web/src/app/[locale]/contact/page.tsx
Normal file
@@ -0,0 +1,116 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useState } from "react";
|
||||||
|
import { useLocale } from "next-intl";
|
||||||
|
import { motion } from "framer-motion";
|
||||||
|
import { useToast } from "@/hooks/useToast";
|
||||||
|
|
||||||
|
export default function ContactPage() {
|
||||||
|
const locale = useLocale();
|
||||||
|
const isEs = locale === "es";
|
||||||
|
const toast = useToast();
|
||||||
|
const [form, setForm] = useState({ name: "", email: "", subject: "", message: "" });
|
||||||
|
const [loading, setLoading] = useState(false);
|
||||||
|
|
||||||
|
async function handleSubmit(e: React.FormEvent) {
|
||||||
|
e.preventDefault();
|
||||||
|
if (!form.name || !form.email || !form.message) return;
|
||||||
|
|
||||||
|
setLoading(true);
|
||||||
|
try {
|
||||||
|
const res = await fetch("/api/contact", {
|
||||||
|
method: "POST",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify(form),
|
||||||
|
});
|
||||||
|
|
||||||
|
const data = await res.json();
|
||||||
|
if (res.ok) {
|
||||||
|
toast.success(isEs ? "¡Mensaje enviado!" : "Message sent!");
|
||||||
|
setForm({ name: "", email: "", subject: "", message: "" });
|
||||||
|
} else {
|
||||||
|
toast.error(data.error || (isEs ? "Error al enviar" : "Failed to send"));
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
toast.error(isEs ? "Error de conexión" : "Connection error");
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="max-w-2xl mx-auto px-4 py-12">
|
||||||
|
<motion.div
|
||||||
|
initial={{ opacity: 0, y: 20 }}
|
||||||
|
animate={{ opacity: 1, y: 0 }}
|
||||||
|
transition={{ duration: 0.6 }}
|
||||||
|
className="text-center mb-10"
|
||||||
|
>
|
||||||
|
<h1
|
||||||
|
className="text-[clamp(2rem,4vw,3rem)] font-extrabold mb-4"
|
||||||
|
style={{ fontFamily: "var(--font-display)", letterSpacing: "-0.02em" }}
|
||||||
|
>
|
||||||
|
{isEs ? "Contacto" : "Contact Us"}
|
||||||
|
</h1>
|
||||||
|
<p className="text-[#a0a0a8]">
|
||||||
|
{isEs
|
||||||
|
? "¿Tienes preguntas, sugerencias o quieres contribuir? Escríbenos."
|
||||||
|
: "Have questions, suggestions, or want to contribute? Reach out to us."}
|
||||||
|
</p>
|
||||||
|
</motion.div>
|
||||||
|
|
||||||
|
<form onSubmit={handleSubmit} className="space-y-5">
|
||||||
|
<div className="grid grid-cols-1 sm:grid-cols-2 gap-5">
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm text-[#a0a0a8] mb-1.5">{isEs ? "Nombre" : "Name"} *</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={form.name}
|
||||||
|
onChange={(e) => setForm({ ...form, name: e.target.value })}
|
||||||
|
required
|
||||||
|
className="w-full bg-[#12121a] border border-[rgba(255,255,255,0.08)] rounded-lg px-4 py-2.5 text-sm text-[#f5f5f7] focus:outline-none focus:border-[rgba(212,165,116,0.4)] transition-colors"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm text-[#a0a0a8] mb-1.5">Email *</label>
|
||||||
|
<input
|
||||||
|
type="email"
|
||||||
|
value={form.email}
|
||||||
|
onChange={(e) => setForm({ ...form, email: e.target.value })}
|
||||||
|
required
|
||||||
|
className="w-full bg-[#12121a] border border-[rgba(255,255,255,0.08)] rounded-lg px-4 py-2.5 text-sm text-[#f5f5f7] focus:outline-none focus:border-[rgba(212,165,116,0.4)] transition-colors"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm text-[#a0a0a8] mb-1.5">{isEs ? "Asunto" : "Subject"}</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={form.subject}
|
||||||
|
onChange={(e) => setForm({ ...form, subject: e.target.value })}
|
||||||
|
className="w-full bg-[#12121a] border border-[rgba(255,255,255,0.08)] rounded-lg px-4 py-2.5 text-sm text-[#f5f5f7] focus:outline-none focus:border-[rgba(212,165,116,0.4)] transition-colors"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm text-[#a0a0a8] mb-1.5">{isEs ? "Mensaje" : "Message"} *</label>
|
||||||
|
<textarea
|
||||||
|
value={form.message}
|
||||||
|
onChange={(e) => setForm({ ...form, message: e.target.value })}
|
||||||
|
required
|
||||||
|
rows={5}
|
||||||
|
className="w-full bg-[#12121a] border border-[rgba(255,255,255,0.08)] rounded-lg px-4 py-2.5 text-sm text-[#f5f5f7] focus:outline-none focus:border-[rgba(212,165,116,0.4)] transition-colors resize-none"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
type="submit"
|
||||||
|
disabled={loading}
|
||||||
|
className="w-full px-6 py-3 bg-white text-black text-sm font-semibold rounded-lg hover:bg-gray-200 transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
|
||||||
|
>
|
||||||
|
{loading
|
||||||
|
? isEs ? "Enviando..." : "Sending..."
|
||||||
|
: isEs ? "Enviar mensaje" : "Send message"}
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,23 +1,35 @@
|
|||||||
|
import type { Metadata } from "next";
|
||||||
import { useTranslations } from "next-intl";
|
import { useTranslations } from "next-intl";
|
||||||
|
|
||||||
|
export function generateMetadata(): Metadata {
|
||||||
|
return {
|
||||||
|
title: "Donations",
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
export default function DonatePage() {
|
export default function DonatePage() {
|
||||||
const t = useTranslations("donate");
|
const t = useTranslations("donate");
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="max-w-4xl mx-auto px-4 py-12">
|
<div className="max-w-4xl mx-auto px-4 py-12">
|
||||||
<h1 className="text-4xl font-bold mb-6">{t("title")}</h1>
|
<h1
|
||||||
<p className="text-lg text-gray-400 mb-12">{t("description")}</p>
|
className="text-[clamp(2rem,4vw,3rem)] font-extrabold mb-6"
|
||||||
|
style={{ fontFamily: "var(--font-display)", letterSpacing: "-0.02em" }}
|
||||||
|
>
|
||||||
|
{t("title")}
|
||||||
|
</h1>
|
||||||
|
<p className="text-lg text-[#a0a0a8] mb-12">{t("description")}</p>
|
||||||
|
|
||||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-6 mb-16">
|
<div className="grid grid-cols-1 md:grid-cols-2 gap-6 mb-16">
|
||||||
<a
|
<a
|
||||||
href="https://patreon.com/projectafterlife"
|
href="https://patreon.com/projectafterlife"
|
||||||
target="_blank"
|
target="_blank"
|
||||||
rel="noopener noreferrer"
|
rel="noopener noreferrer"
|
||||||
className="block bg-gray-900 rounded-lg p-8 border border-white/5 hover:border-orange-500/50 transition-colors text-center"
|
className="block bg-[#12121a] rounded-lg p-8 border border-[rgba(255,255,255,0.08)] hover:border-[rgba(212,165,116,0.3)] transition-colors text-center"
|
||||||
>
|
>
|
||||||
<h3 className="text-2xl font-bold mb-2 text-orange-400">Patreon</h3>
|
<h3 className="text-2xl font-bold mb-2 text-[#d4a574]">Patreon</h3>
|
||||||
<p className="text-gray-400 text-sm mb-4">Donaciones recurrentes mensuales</p>
|
<p className="text-[#a0a0a8] text-sm mb-4">Donaciones recurrentes mensuales</p>
|
||||||
<span className="inline-block px-6 py-2 bg-orange-600 hover:bg-orange-700 text-white rounded-lg font-medium transition-colors">
|
<span className="inline-block px-6 py-2 bg-[#d4a574] hover:bg-[#e8c4a0] text-[#0a0a0f] rounded-lg font-medium transition-colors">
|
||||||
{t("patreon")}
|
{t("patreon")}
|
||||||
</span>
|
</span>
|
||||||
</a>
|
</a>
|
||||||
@@ -26,11 +38,11 @@ export default function DonatePage() {
|
|||||||
href="https://ko-fi.com/projectafterlife"
|
href="https://ko-fi.com/projectafterlife"
|
||||||
target="_blank"
|
target="_blank"
|
||||||
rel="noopener noreferrer"
|
rel="noopener noreferrer"
|
||||||
className="block bg-gray-900 rounded-lg p-8 border border-white/5 hover:border-sky-500/50 transition-colors text-center"
|
className="block bg-[#12121a] rounded-lg p-8 border border-[rgba(255,255,255,0.08)] hover:border-[rgba(212,165,116,0.3)] transition-colors text-center"
|
||||||
>
|
>
|
||||||
<h3 className="text-2xl font-bold mb-2 text-sky-400">Ko-fi</h3>
|
<h3 className="text-2xl font-bold mb-2 text-[#e8c4a0]">Ko-fi</h3>
|
||||||
<p className="text-gray-400 text-sm mb-4">Donaciones puntuales</p>
|
<p className="text-[#a0a0a8] text-sm mb-4">Donaciones puntuales</p>
|
||||||
<span className="inline-block px-6 py-2 bg-sky-600 hover:bg-sky-700 text-white rounded-lg font-medium transition-colors">
|
<span className="inline-block px-6 py-2 bg-[#e8c4a0] hover:bg-[#d4a574] text-[#0a0a0f] rounded-lg font-medium transition-colors">
|
||||||
{t("kofi")}
|
{t("kofi")}
|
||||||
</span>
|
</span>
|
||||||
</a>
|
</a>
|
||||||
|
|||||||
@@ -1,7 +1,27 @@
|
|||||||
|
import type { Metadata } from "next";
|
||||||
import { notFound } from "next/navigation";
|
import { notFound } from "next/navigation";
|
||||||
import { getDocumentaryByGameSlug } from "@/lib/api";
|
import { getDocumentaryByGameSlug } from "@/lib/api";
|
||||||
import { DocumentaryLayout } from "@/components/documentary/DocumentaryLayout";
|
import { DocumentaryLayout } from "@/components/documentary/DocumentaryLayout";
|
||||||
|
|
||||||
|
export async function generateMetadata({
|
||||||
|
params,
|
||||||
|
}: {
|
||||||
|
params: Promise<{ locale: string; slug: string }>;
|
||||||
|
}): Promise<Metadata> {
|
||||||
|
const { slug, locale } = await params;
|
||||||
|
let documentary;
|
||||||
|
try {
|
||||||
|
documentary = await getDocumentaryByGameSlug(slug, locale);
|
||||||
|
} catch {
|
||||||
|
return {};
|
||||||
|
}
|
||||||
|
if (!documentary) return {};
|
||||||
|
return {
|
||||||
|
title: documentary.title,
|
||||||
|
description: documentary.description?.slice(0, 160),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
export default async function DocumentaryPage({
|
export default async function DocumentaryPage({
|
||||||
params,
|
params,
|
||||||
}: {
|
}: {
|
||||||
|
|||||||
@@ -1,8 +1,47 @@
|
|||||||
|
import type { Metadata } from "next";
|
||||||
import { notFound } from "next/navigation";
|
import { notFound } from "next/navigation";
|
||||||
import { getGameBySlug } from "@/lib/api";
|
import { getGameBySlug } from "@/lib/api";
|
||||||
import { GameHeader } from "@/components/game/GameHeader";
|
import { GameHeader } from "@/components/game/GameHeader";
|
||||||
import { GameInfo } from "@/components/game/GameInfo";
|
import { GameInfo } from "@/components/game/GameInfo";
|
||||||
import { ScreenshotGallery } from "@/components/game/ScreenshotGallery";
|
import { ScreenshotGallery } from "@/components/game/ScreenshotGallery";
|
||||||
|
import { SocialShare } from "@/components/social/SocialShare";
|
||||||
|
|
||||||
|
const CMS_URL = process.env.STRAPI_URL || process.env.NEXT_PUBLIC_CMS_URL || "http://localhost:1337";
|
||||||
|
|
||||||
|
export async function generateMetadata({
|
||||||
|
params,
|
||||||
|
}: {
|
||||||
|
params: Promise<{ locale: string; slug: string }>;
|
||||||
|
}): Promise<Metadata> {
|
||||||
|
const { slug, locale } = await params;
|
||||||
|
|
||||||
|
let game;
|
||||||
|
try {
|
||||||
|
const res = await getGameBySlug(slug, locale);
|
||||||
|
game = Array.isArray(res.data) ? res.data[0] : res.data;
|
||||||
|
} catch {
|
||||||
|
return {};
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!game) return {};
|
||||||
|
|
||||||
|
const description = game.description?.slice(0, 160);
|
||||||
|
const images = game.coverImage?.url
|
||||||
|
? [
|
||||||
|
{
|
||||||
|
url: game.coverImage.url.startsWith("http")
|
||||||
|
? game.coverImage.url
|
||||||
|
: `${CMS_URL}${game.coverImage.url}`,
|
||||||
|
},
|
||||||
|
]
|
||||||
|
: undefined;
|
||||||
|
|
||||||
|
return {
|
||||||
|
title: game.title,
|
||||||
|
description,
|
||||||
|
openGraph: images ? { images } : undefined,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
export default async function GamePage({
|
export default async function GamePage({
|
||||||
params,
|
params,
|
||||||
@@ -25,6 +64,9 @@ export default async function GamePage({
|
|||||||
<>
|
<>
|
||||||
<GameHeader game={game} />
|
<GameHeader game={game} />
|
||||||
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-16">
|
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-16">
|
||||||
|
<div className="flex items-center justify-between mb-6">
|
||||||
|
<SocialShare title={game.title} description={game.description?.slice(0, 120)} />
|
||||||
|
</div>
|
||||||
<GameInfo game={game} locale={locale} />
|
<GameInfo game={game} locale={locale} />
|
||||||
{game.screenshots && (
|
{game.screenshots && (
|
||||||
<ScreenshotGallery screenshots={game.screenshots} />
|
<ScreenshotGallery screenshots={game.screenshots} />
|
||||||
|
|||||||
259
apps/web/src/app/[locale]/guides/page.tsx
Normal file
259
apps/web/src/app/[locale]/guides/page.tsx
Normal file
@@ -0,0 +1,259 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useState } from "react";
|
||||||
|
import { useLocale } from "next-intl";
|
||||||
|
import { motion, AnimatePresence } from "framer-motion";
|
||||||
|
|
||||||
|
interface Guide {
|
||||||
|
game: string;
|
||||||
|
steps: string[];
|
||||||
|
requirements: string[];
|
||||||
|
troubleshooting: string[];
|
||||||
|
}
|
||||||
|
|
||||||
|
const guidesEn: Guide[] = [
|
||||||
|
{
|
||||||
|
game: "NieR Reincarnation",
|
||||||
|
steps: [
|
||||||
|
"Download the patched APK from our Discord or website.",
|
||||||
|
"Install the APK on your Android device (enable Unknown Sources).",
|
||||||
|
"Launch the game and tap 'Start Game'.",
|
||||||
|
"When prompted, enter the server address: play.consultoria-as.com",
|
||||||
|
"Create your character and enjoy!",
|
||||||
|
],
|
||||||
|
requirements: ["Android 8.0 or higher", "~2GB free storage", "Stable internet connection"],
|
||||||
|
troubleshooting: [
|
||||||
|
"If you get 'Connection failed', check your internet and try again.",
|
||||||
|
"Make sure you're using the latest patched APK.",
|
||||||
|
"Clear app cache and retry.",
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
game: "Dragon Ball Online",
|
||||||
|
steps: [
|
||||||
|
"Download the game client from our Discord.",
|
||||||
|
"Extract the archive to a folder on your PC.",
|
||||||
|
"Run Launcher.exe as Administrator.",
|
||||||
|
"The launcher will auto-update if needed.",
|
||||||
|
"Click 'Play' and log in with any username (no password required for now).",
|
||||||
|
],
|
||||||
|
requirements: ["Windows 10/11", "DirectX 11", "4GB RAM", "~5GB free storage"],
|
||||||
|
troubleshooting: [
|
||||||
|
"If the launcher freezes, disable your antivirus temporarily.",
|
||||||
|
"Run both launcher and game as Administrator.",
|
||||||
|
"Ensure Windows Defender is not blocking the executable.",
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
game: "MapleStory 2",
|
||||||
|
steps: [
|
||||||
|
"Download the MS2 client from the community portal.",
|
||||||
|
"Install the game to a path without special characters.",
|
||||||
|
"Run the launcher and let it patch.",
|
||||||
|
"Create an account on the portal website.",
|
||||||
|
"Log in and select a channel to start playing.",
|
||||||
|
],
|
||||||
|
requirements: ["Windows 10/11", "8GB RAM recommended", "NVIDIA/AMD GPU", "~10GB free storage"],
|
||||||
|
troubleshooting: [
|
||||||
|
"If you get a black screen, update your GPU drivers.",
|
||||||
|
"Disable fullscreen optimizations in game executable properties.",
|
||||||
|
"Run in compatibility mode for Windows 8 if crashing.",
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
game: "FusionFall",
|
||||||
|
steps: [
|
||||||
|
"Download the OpenFusion client for your platform.",
|
||||||
|
"Extract and run OpenFusion.exe.",
|
||||||
|
"The server should be pre-configured.",
|
||||||
|
"Create a character and enter the Cartoon Network universe!",
|
||||||
|
],
|
||||||
|
requirements: ["Windows 10/11 or Linux (Wine)", "2GB RAM", "~2GB free storage"],
|
||||||
|
troubleshooting: [
|
||||||
|
"If textures are missing, verify file integrity via Discord.",
|
||||||
|
"Linux users may need to install latest Wine/Proton.",
|
||||||
|
],
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
const guidesEs: Guide[] = [
|
||||||
|
{
|
||||||
|
game: "NieR Reincarnation",
|
||||||
|
steps: [
|
||||||
|
"Descarga el APK parcheado desde nuestro Discord o sitio web.",
|
||||||
|
"Instala el APK en tu dispositivo Android (activa Fuentes Desconocidas).",
|
||||||
|
"Abre el juego y toca 'Comenzar'.",
|
||||||
|
"Cuando se solicite, introduce la dirección del servidor: play.consultoria-as.com",
|
||||||
|
"¡Crea tu personaje y disfruta!",
|
||||||
|
],
|
||||||
|
requirements: ["Android 8.0 o superior", "~2GB de almacenamiento libre", "Conexión a internet estable"],
|
||||||
|
troubleshooting: [
|
||||||
|
"Si aparece 'Conexión fallida', verifica tu internet e inténtalo de nuevo.",
|
||||||
|
"Asegúrate de usar el APK parcheado más reciente.",
|
||||||
|
"Limpia la caché de la app e inténtalo de nuevo.",
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
game: "Dragon Ball Online",
|
||||||
|
steps: [
|
||||||
|
"Descarga el cliente del juego desde nuestro Discord.",
|
||||||
|
"Extrae el archivo a una carpeta en tu PC.",
|
||||||
|
"Ejecuta Launcher.exe como Administrador.",
|
||||||
|
"El launcher se actualizará automáticamente si es necesario.",
|
||||||
|
"Haz clic en 'Jugar' e inicia sesión con cualquier usuario (sin contraseña por ahora).",
|
||||||
|
],
|
||||||
|
requirements: ["Windows 10/11", "DirectX 11", "4GB RAM", "~5GB de almacenamiento libre"],
|
||||||
|
troubleshooting: [
|
||||||
|
"Si el launcher se congela, desactiva temporalmente tu antivirus.",
|
||||||
|
"Ejecuta tanto el launcher como el juego como Administrador.",
|
||||||
|
"Asegúrate de que Windows Defender no bloquee el ejecutable.",
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
game: "MapleStory 2",
|
||||||
|
steps: [
|
||||||
|
"Descarga el cliente de MS2 desde el portal de la comunidad.",
|
||||||
|
"Instala el juego en una ruta sin caracteres especiales.",
|
||||||
|
"Ejecuta el launcher y déjalo parchear.",
|
||||||
|
"Crea una cuenta en el sitio web del portal.",
|
||||||
|
"Inicia sesión y selecciona un canal para empezar a jugar.",
|
||||||
|
],
|
||||||
|
requirements: ["Windows 10/11", "8GB RAM recomendados", "GPU NVIDIA/AMD", "~10GB de almacenamiento libre"],
|
||||||
|
troubleshooting: [
|
||||||
|
"Si aparece pantalla negra, actualiza los drivers de tu GPU.",
|
||||||
|
"Desactiva las optimizaciones de pantalla completa en propiedades del ejecutable.",
|
||||||
|
"Ejecuta en modo compatibilidad para Windows 8 si se cierra.",
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
game: "FusionFall",
|
||||||
|
steps: [
|
||||||
|
"Descarga el cliente de OpenFusion para tu plataforma.",
|
||||||
|
"Extrae y ejecuta OpenFusion.exe.",
|
||||||
|
"El servidor debería estar preconfigurado.",
|
||||||
|
"¡Crea un personaje y entra al universo de Cartoon Network!",
|
||||||
|
],
|
||||||
|
requirements: ["Windows 10/11 o Linux (Wine)", "2GB RAM", "~2GB de almacenamiento libre"],
|
||||||
|
troubleshooting: [
|
||||||
|
"Si faltan texturas, verifica la integridad de archivos vía Discord.",
|
||||||
|
"Usuarios de Linux pueden necesitar Wine/Proton más reciente.",
|
||||||
|
],
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
function GuideCard({ guide, index }: { guide: Guide; index: number }) {
|
||||||
|
const [open, setOpen] = useState(index === 0);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<motion.div
|
||||||
|
initial={{ opacity: 0, y: 20 }}
|
||||||
|
whileInView={{ opacity: 1, y: 0 }}
|
||||||
|
viewport={{ once: true }}
|
||||||
|
transition={{ delay: index * 0.1, duration: 0.5 }}
|
||||||
|
className="rounded-2xl border border-[rgba(255,255,255,0.08)] bg-[#12121a] overflow-hidden"
|
||||||
|
>
|
||||||
|
<button
|
||||||
|
onClick={() => setOpen(!open)}
|
||||||
|
className="w-full flex items-center justify-between px-6 py-5 text-left hover:bg-[rgba(255,255,255,0.03)] transition-colors"
|
||||||
|
>
|
||||||
|
<h3 className="text-lg font-semibold text-[#f5f5f7]">{guide.game}</h3>
|
||||||
|
<svg
|
||||||
|
className={`w-5 h-5 text-[#6b6b75] transition-transform duration-300 ${open ? "rotate-180" : ""}`}
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
fill="none"
|
||||||
|
stroke="currentColor"
|
||||||
|
strokeWidth="2"
|
||||||
|
strokeLinecap="round"
|
||||||
|
>
|
||||||
|
<polyline points="6 9 12 15 18 9" />
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<AnimatePresence>
|
||||||
|
{open && (
|
||||||
|
<motion.div
|
||||||
|
initial={{ height: 0, opacity: 0 }}
|
||||||
|
animate={{ height: "auto", opacity: 1 }}
|
||||||
|
exit={{ height: 0, opacity: 0 }}
|
||||||
|
transition={{ duration: 0.3 }}
|
||||||
|
className="overflow-hidden"
|
||||||
|
>
|
||||||
|
<div className="px-6 pb-6 space-y-6">
|
||||||
|
<div>
|
||||||
|
<h4 className="text-sm font-semibold text-[#d4a574] uppercase tracking-wider mb-3">Steps</h4>
|
||||||
|
<ol className="space-y-2">
|
||||||
|
{guide.steps.map((step, i) => (
|
||||||
|
<li key={i} className="flex items-start gap-3 text-sm text-[#a0a0a8]">
|
||||||
|
<span className="flex-shrink-0 w-5 h-5 rounded-full bg-[rgba(212,165,116,0.15)] text-[#d4a574] text-xs font-bold flex items-center justify-center mt-0.5">
|
||||||
|
{i + 1}
|
||||||
|
</span>
|
||||||
|
{step}
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ol>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<h4 className="text-sm font-semibold text-blue-400 uppercase tracking-wider mb-3">Requirements</h4>
|
||||||
|
<ul className="flex flex-wrap gap-2">
|
||||||
|
{guide.requirements.map((req, i) => (
|
||||||
|
<li key={i} className="px-3 py-1 rounded-full bg-blue-500/10 border border-blue-500/20 text-xs text-blue-300">
|
||||||
|
{req}
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<h4 className="text-sm font-semibold text-red-400 uppercase tracking-wider mb-3">Troubleshooting</h4>
|
||||||
|
<ul className="space-y-2">
|
||||||
|
{guide.troubleshooting.map((tip, i) => (
|
||||||
|
<li key={i} className="flex items-start gap-2 text-sm text-[#a0a0a8]">
|
||||||
|
<span className="text-red-400 mt-0.5">•</span>
|
||||||
|
{tip}
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</motion.div>
|
||||||
|
)}
|
||||||
|
</AnimatePresence>
|
||||||
|
</motion.div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function GuidesPage() {
|
||||||
|
const locale = useLocale();
|
||||||
|
const isEs = locale === "es";
|
||||||
|
const guides = isEs ? guidesEs : guidesEn;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="max-w-4xl mx-auto px-4 py-12">
|
||||||
|
<motion.div
|
||||||
|
initial={{ opacity: 0, y: 20 }}
|
||||||
|
animate={{ opacity: 1, y: 0 }}
|
||||||
|
transition={{ duration: 0.6 }}
|
||||||
|
className="text-center mb-12"
|
||||||
|
>
|
||||||
|
<h1
|
||||||
|
className="text-[clamp(2rem,4vw,3.5rem)] font-extrabold mb-4 tracking-tight"
|
||||||
|
style={{ fontFamily: "var(--font-display)", letterSpacing: "-0.02em" }}
|
||||||
|
>
|
||||||
|
{isEs ? "Guías de Conexión" : "Connection Guides"}
|
||||||
|
</h1>
|
||||||
|
<p className="text-lg text-[#a0a0a8] max-w-2xl mx-auto leading-relaxed">
|
||||||
|
{isEs
|
||||||
|
? "Todo lo que necesitas saber para conectarte a nuestros servidores privados."
|
||||||
|
: "Everything you need to know to connect to our private servers."}
|
||||||
|
</p>
|
||||||
|
</motion.div>
|
||||||
|
|
||||||
|
<div className="space-y-4">
|
||||||
|
{guides.map((guide, i) => (
|
||||||
|
<GuideCard key={guide.game} guide={guide} index={i} />
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,21 +1,22 @@
|
|||||||
import type { Metadata } from "next";
|
import type { Metadata } from "next";
|
||||||
import { Playfair_Display, Source_Serif_4, DM_Sans } from "next/font/google";
|
import { Syne, DM_Sans } from "next/font/google";
|
||||||
import { NextIntlClientProvider } from "next-intl";
|
import { NextIntlClientProvider } from "next-intl";
|
||||||
import { getMessages } from "next-intl/server";
|
import { getMessages } from "next-intl/server";
|
||||||
import { notFound } from "next/navigation";
|
import { notFound } from "next/navigation";
|
||||||
import { routing } from "@/i18n/routing";
|
import { routing } from "@/i18n/routing";
|
||||||
import { Navbar } from "@/components/layout/Navbar";
|
import { Navbar } from "@/components/layout/Navbar";
|
||||||
import { Footer } from "@/components/layout/Footer";
|
import { Footer } from "@/components/layout/Footer";
|
||||||
|
import { AuthProvider } from "@/components/auth/AuthProvider";
|
||||||
|
import { ToastProvider } from "@/hooks/useToast";
|
||||||
|
import { CookieConsent } from "@/components/ui/CookieConsent";
|
||||||
|
import { Analytics } from "@vercel/analytics/react";
|
||||||
|
import { ThemeProvider } from "@/components/theme/ThemeProvider";
|
||||||
|
import { Breadcrumb } from "@/components/navigation/Breadcrumb";
|
||||||
|
import { ScrollToTop } from "@/components/ui/ScrollToTop";
|
||||||
|
|
||||||
const playfair = Playfair_Display({
|
const syne = Syne({
|
||||||
subsets: ["latin"],
|
subsets: ["latin"],
|
||||||
variable: "--font-playfair",
|
variable: "--font-syne",
|
||||||
display: "swap",
|
|
||||||
});
|
|
||||||
|
|
||||||
const sourceSerif = Source_Serif_4({
|
|
||||||
subsets: ["latin", "latin-ext"],
|
|
||||||
variable: "--font-source-serif",
|
|
||||||
display: "swap",
|
display: "swap",
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -50,14 +51,24 @@ export default async function LocaleLayout({
|
|||||||
return (
|
return (
|
||||||
<html
|
<html
|
||||||
lang={locale}
|
lang={locale}
|
||||||
className={`${playfair.variable} ${sourceSerif.variable} ${dmSans.variable}`}
|
className={`${syne.variable} ${dmSans.variable}`}
|
||||||
>
|
>
|
||||||
<body className="bg-gray-950 text-white antialiased min-h-screen flex flex-col font-sans">
|
<body className="antialiased min-h-screen flex flex-col font-sans">
|
||||||
<NextIntlClientProvider messages={messages}>
|
<AuthProvider>
|
||||||
<Navbar />
|
<NextIntlClientProvider messages={messages}>
|
||||||
<main className="flex-1 pt-16">{children}</main>
|
<ThemeProvider>
|
||||||
<Footer />
|
<ToastProvider>
|
||||||
</NextIntlClientProvider>
|
<Navbar />
|
||||||
|
<Breadcrumb />
|
||||||
|
<main className="flex-1 pt-12">{children}</main>
|
||||||
|
<Footer />
|
||||||
|
<ScrollToTop />
|
||||||
|
<CookieConsent />
|
||||||
|
<Analytics />
|
||||||
|
</ToastProvider>
|
||||||
|
</ThemeProvider>
|
||||||
|
</NextIntlClientProvider>
|
||||||
|
</AuthProvider>
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
);
|
);
|
||||||
|
|||||||
22
apps/web/src/app/[locale]/login/page.tsx
Normal file
22
apps/web/src/app/[locale]/login/page.tsx
Normal file
@@ -0,0 +1,22 @@
|
|||||||
|
import { getMessages } from "next-intl/server";
|
||||||
|
import { NextIntlClientProvider } from "next-intl";
|
||||||
|
import { LoginForm } from "@/components/auth/LoginForm";
|
||||||
|
|
||||||
|
export default async function LoginPage({
|
||||||
|
params,
|
||||||
|
}: {
|
||||||
|
params: Promise<{ locale: string }>;
|
||||||
|
}) {
|
||||||
|
const { locale } = await params;
|
||||||
|
const messages = await getMessages();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<NextIntlClientProvider messages={messages} locale={locale}>
|
||||||
|
<div className="min-h-screen flex items-center justify-center bg-[#0a0a0f] px-4">
|
||||||
|
<div className="max-w-md w-full">
|
||||||
|
<LoginForm />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</NextIntlClientProvider>
|
||||||
|
);
|
||||||
|
}
|
||||||
37
apps/web/src/app/[locale]/not-found.tsx
Normal file
37
apps/web/src/app/[locale]/not-found.tsx
Normal file
@@ -0,0 +1,37 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import Link from "next/link";
|
||||||
|
import { useLocale } from "next-intl";
|
||||||
|
import { motion } from "framer-motion";
|
||||||
|
|
||||||
|
export default function NotFound() {
|
||||||
|
const locale = useLocale();
|
||||||
|
const isEs = locale === "es";
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="min-h-[70vh] flex items-center justify-center px-4">
|
||||||
|
<motion.div
|
||||||
|
initial={{ opacity: 0, y: 20 }}
|
||||||
|
animate={{ opacity: 1, y: 0 }}
|
||||||
|
transition={{ duration: 0.6 }}
|
||||||
|
className="text-center max-w-lg"
|
||||||
|
>
|
||||||
|
<h1 className="text-8xl font-bold text-[#1a1a24] mb-4">404</h1>
|
||||||
|
<h2 className="text-2xl font-semibold text-[#f5f5f7] mb-4">
|
||||||
|
{isEs ? "Página no encontrada" : "Page Not Found"}
|
||||||
|
</h2>
|
||||||
|
<p className="text-[#a0a0a8] mb-8 leading-relaxed">
|
||||||
|
{isEs
|
||||||
|
? "La página que buscas no existe o ha sido movida."
|
||||||
|
: "The page you are looking for does not exist or has been moved."}
|
||||||
|
</p>
|
||||||
|
<Link
|
||||||
|
href={`/${locale}`}
|
||||||
|
className="inline-block px-8 py-3 bg-[#d4a574] text-[#0a0a0f] font-semibold rounded-xl hover:bg-[#e8c4a0] transition-colors"
|
||||||
|
>
|
||||||
|
{isEs ? "Volver al inicio" : "Go back home"}
|
||||||
|
</Link>
|
||||||
|
</motion.div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,7 +1,10 @@
|
|||||||
import { getGames } from "@/lib/api";
|
import { getGames } from "@/lib/api";
|
||||||
import { HeroSection } from "@/components/home/HeroSection";
|
import { HeroSection } from "@/components/home/HeroSection";
|
||||||
import { LatestGames } from "@/components/home/LatestGames";
|
import { PillarsSection } from "@/components/home/PillarsSection";
|
||||||
import { DonationCTA } from "@/components/home/DonationCTA";
|
import { DocumentaryExperienceSection } from "@/components/home/DocumentaryExperienceSection";
|
||||||
|
import { TechStackSection } from "@/components/home/TechStackSection";
|
||||||
|
import { GamesShowcaseSection } from "@/components/home/GamesShowcaseSection";
|
||||||
|
import { DonationSection } from "@/components/home/DonationSection";
|
||||||
|
|
||||||
export default async function HomePage({
|
export default async function HomePage({
|
||||||
params,
|
params,
|
||||||
@@ -21,8 +24,11 @@ export default async function HomePage({
|
|||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<HeroSection />
|
<HeroSection />
|
||||||
<LatestGames games={games} locale={locale} />
|
<PillarsSection />
|
||||||
<DonationCTA />
|
<DocumentaryExperienceSection />
|
||||||
|
<TechStackSection />
|
||||||
|
<GamesShowcaseSection games={games} />
|
||||||
|
<DonationSection />
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
30
apps/web/src/app/[locale]/profile/page.tsx
Normal file
30
apps/web/src/app/[locale]/profile/page.tsx
Normal file
@@ -0,0 +1,30 @@
|
|||||||
|
import { redirect } from "next/navigation";
|
||||||
|
import { auth } from "@/lib/auth";
|
||||||
|
import { getMessages } from "next-intl/server";
|
||||||
|
import { NextIntlClientProvider } from "next-intl";
|
||||||
|
import { ProfileCard } from "@/components/auth/ProfileCard";
|
||||||
|
|
||||||
|
export default async function ProfilePage({
|
||||||
|
params,
|
||||||
|
}: {
|
||||||
|
params: Promise<{ locale: string }>;
|
||||||
|
}) {
|
||||||
|
const { locale } = await params;
|
||||||
|
const session = await auth();
|
||||||
|
|
||||||
|
if (!session?.user) {
|
||||||
|
redirect(`/${locale}/login`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const messages = await getMessages();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<NextIntlClientProvider messages={messages} locale={locale}>
|
||||||
|
<div className="min-h-screen bg-[#0a0a0f] px-4 py-12">
|
||||||
|
<div className="max-w-2xl mx-auto">
|
||||||
|
<ProfileCard user={session.user} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</NextIntlClientProvider>
|
||||||
|
);
|
||||||
|
}
|
||||||
79
apps/web/src/app/[locale]/server-status/page.tsx
Normal file
79
apps/web/src/app/[locale]/server-status/page.tsx
Normal file
@@ -0,0 +1,79 @@
|
|||||||
|
import { getMessages } from "next-intl/server";
|
||||||
|
import { NextIntlClientProvider } from "next-intl";
|
||||||
|
import { ServerStatusGrid } from "@/components/admin/ServerStatusGrid";
|
||||||
|
import { HealthBanner } from "@/components/admin/HealthBanner";
|
||||||
|
import type { Server } from "@/components/admin/ServerStatusGrid";
|
||||||
|
|
||||||
|
export const metadata = {
|
||||||
|
title: "Server Status",
|
||||||
|
};
|
||||||
|
|
||||||
|
export default async function ServerStatusPage({
|
||||||
|
params,
|
||||||
|
}: {
|
||||||
|
params: Promise<{ locale: string }>;
|
||||||
|
}) {
|
||||||
|
const { locale } = await params;
|
||||||
|
const messages = await getMessages();
|
||||||
|
|
||||||
|
const servers = [
|
||||||
|
{
|
||||||
|
name: "NieR Reincarnation",
|
||||||
|
status: "online",
|
||||||
|
ip: process.env.NEXT_PUBLIC_NIER_IP || "play.consultoria-as.com",
|
||||||
|
ports: "80 / 443 (HTTP/2 gRPC)",
|
||||||
|
type: "Mobile RPG",
|
||||||
|
vm: "vm-nier (10.0.0.70)",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "Dragon Ball Online",
|
||||||
|
status: "maintenance",
|
||||||
|
ip: process.env.NEXT_PUBLIC_DBO_IP || "play.consultoria-as.com",
|
||||||
|
ports: "22000-22010",
|
||||||
|
type: "MMORPG",
|
||||||
|
vm: "vm-dbo (10.0.0.80)",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "MapleStory 2",
|
||||||
|
status: "online",
|
||||||
|
ip: process.env.NEXT_PUBLIC_MAPLE2_IP || "play.consultoria-as.com",
|
||||||
|
ports: "20001",
|
||||||
|
type: "MMORPG",
|
||||||
|
vm: "vm-maple2 (10.0.0.40)",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "FusionFall",
|
||||||
|
status: "online",
|
||||||
|
ip: process.env.NEXT_PUBLIC_FUSIONFALL_IP || "play.consultoria-as.com",
|
||||||
|
ports: "23000",
|
||||||
|
type: "MMORPG",
|
||||||
|
vm: "vm-fusionfall (10.0.0.30)",
|
||||||
|
},
|
||||||
|
] satisfies Server[];
|
||||||
|
|
||||||
|
return (
|
||||||
|
<NextIntlClientProvider messages={messages} locale={locale}>
|
||||||
|
<div className="min-h-screen bg-[#0a0a0f] px-4 py-12">
|
||||||
|
<div className="max-w-6xl mx-auto">
|
||||||
|
<div className="mb-10">
|
||||||
|
<h1
|
||||||
|
className="text-[clamp(1.75rem,3vw,2.5rem)] font-display font-bold text-[#f5f5f7]"
|
||||||
|
style={{ letterSpacing: "-0.02em" }}
|
||||||
|
>
|
||||||
|
{locale === "es" ? "Estado de Servidores" : "Server Status"}
|
||||||
|
</h1>
|
||||||
|
<p className="mt-3 text-[#a0a0a8] max-w-2xl">
|
||||||
|
{locale === "es"
|
||||||
|
? "Información de conexión para todos los servidores de juego de Project Afterlife."
|
||||||
|
: "Connection information for all Project Afterlife game servers."}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<HealthBanner locale={locale} />
|
||||||
|
|
||||||
|
<ServerStatusGrid servers={servers} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</NextIntlClientProvider>
|
||||||
|
);
|
||||||
|
}
|
||||||
22
apps/web/src/app/[locale]/template.tsx
Normal file
22
apps/web/src/app/[locale]/template.tsx
Normal file
@@ -0,0 +1,22 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { motion, AnimatePresence } from "framer-motion";
|
||||||
|
import { usePathname } from "next/navigation";
|
||||||
|
|
||||||
|
export default function Template({ children }: { children: React.ReactNode }) {
|
||||||
|
const pathname = usePathname();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<AnimatePresence mode="wait">
|
||||||
|
<motion.div
|
||||||
|
key={pathname}
|
||||||
|
initial={{ opacity: 0, y: 8 }}
|
||||||
|
animate={{ opacity: 1, y: 0 }}
|
||||||
|
exit={{ opacity: 0, y: -8 }}
|
||||||
|
transition={{ duration: 0.25, ease: "easeInOut" }}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
</motion.div>
|
||||||
|
</AnimatePresence>
|
||||||
|
);
|
||||||
|
}
|
||||||
18
apps/web/src/app/api/activities/route.ts
Normal file
18
apps/web/src/app/api/activities/route.ts
Normal file
@@ -0,0 +1,18 @@
|
|||||||
|
import { NextRequest, NextResponse } from "next/server";
|
||||||
|
import { getRecentActivities } from "@/lib/activity";
|
||||||
|
import { rateLimit } from "@/lib/rate-limit/simple";
|
||||||
|
|
||||||
|
export async function GET(req: NextRequest) {
|
||||||
|
const ip = req.headers.get("x-forwarded-for") || "anonymous";
|
||||||
|
const limit = rateLimit(`activities-${ip}`, 30, 60000);
|
||||||
|
|
||||||
|
if (!limit.success) {
|
||||||
|
return NextResponse.json({ error: "Rate limit exceeded" }, { status: 429 });
|
||||||
|
}
|
||||||
|
|
||||||
|
const { searchParams } = new URL(req.url);
|
||||||
|
const count = Math.min(Number(searchParams.get("limit") || "20"), 50);
|
||||||
|
|
||||||
|
const activities = await getRecentActivities(count);
|
||||||
|
return NextResponse.json({ activities });
|
||||||
|
}
|
||||||
41
apps/web/src/app/api/admin/messages/route.ts
Normal file
41
apps/web/src/app/api/admin/messages/route.ts
Normal file
@@ -0,0 +1,41 @@
|
|||||||
|
import { NextRequest, NextResponse } from "next/server";
|
||||||
|
import { Pool } from "pg";
|
||||||
|
|
||||||
|
const pool = new Pool({
|
||||||
|
host: process.env.DATABASE_HOST || "postgres",
|
||||||
|
port: Number(process.env.DATABASE_PORT || "5432"),
|
||||||
|
database: process.env.DATABASE_NAME || "afterlife",
|
||||||
|
user: process.env.DATABASE_USERNAME || "afterlife",
|
||||||
|
password: process.env.DATABASE_PASSWORD || "afterlife",
|
||||||
|
});
|
||||||
|
|
||||||
|
export async function GET(req: NextRequest) {
|
||||||
|
const apiKey = req.headers.get("x-admin-key");
|
||||||
|
if (apiKey !== process.env.ADMIN_API_KEY) {
|
||||||
|
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const { searchParams } = new URL(req.url);
|
||||||
|
const limit = Math.min(Number(searchParams.get("limit") || "50"), 200);
|
||||||
|
const offset = Number(searchParams.get("offset") || "0");
|
||||||
|
|
||||||
|
const [messagesRes, countRes] = await Promise.all([
|
||||||
|
pool.query(
|
||||||
|
"SELECT id, name, email, subject, message, created_at FROM contact_messages ORDER BY created_at DESC LIMIT $1 OFFSET $2",
|
||||||
|
[limit, offset]
|
||||||
|
),
|
||||||
|
pool.query("SELECT COUNT(*) FROM contact_messages"),
|
||||||
|
]);
|
||||||
|
|
||||||
|
return NextResponse.json({
|
||||||
|
messages: messagesRes.rows,
|
||||||
|
total: Number(countRes.rows[0].count),
|
||||||
|
limit,
|
||||||
|
offset,
|
||||||
|
});
|
||||||
|
} catch (err) {
|
||||||
|
console.error("Admin messages error:", err);
|
||||||
|
return NextResponse.json({ error: "Failed to fetch messages" }, { status: 500 });
|
||||||
|
}
|
||||||
|
}
|
||||||
41
apps/web/src/app/api/admin/subscribers/route.ts
Normal file
41
apps/web/src/app/api/admin/subscribers/route.ts
Normal file
@@ -0,0 +1,41 @@
|
|||||||
|
import { NextRequest, NextResponse } from "next/server";
|
||||||
|
import { Pool } from "pg";
|
||||||
|
|
||||||
|
const pool = new Pool({
|
||||||
|
host: process.env.DATABASE_HOST || "postgres",
|
||||||
|
port: Number(process.env.DATABASE_PORT || "5432"),
|
||||||
|
database: process.env.DATABASE_NAME || "afterlife",
|
||||||
|
user: process.env.DATABASE_USERNAME || "afterlife",
|
||||||
|
password: process.env.DATABASE_PASSWORD || "afterlife",
|
||||||
|
});
|
||||||
|
|
||||||
|
export async function GET(req: NextRequest) {
|
||||||
|
const apiKey = req.headers.get("x-admin-key");
|
||||||
|
if (apiKey !== process.env.ADMIN_API_KEY) {
|
||||||
|
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const { searchParams } = new URL(req.url);
|
||||||
|
const limit = Math.min(Number(searchParams.get("limit") || "50"), 200);
|
||||||
|
const offset = Number(searchParams.get("offset") || "0");
|
||||||
|
|
||||||
|
const [subscribersRes, countRes] = await Promise.all([
|
||||||
|
pool.query(
|
||||||
|
"SELECT id, email, locale, created_at FROM newsletter_subscribers ORDER BY created_at DESC LIMIT $1 OFFSET $2",
|
||||||
|
[limit, offset]
|
||||||
|
),
|
||||||
|
pool.query("SELECT COUNT(*) FROM newsletter_subscribers"),
|
||||||
|
]);
|
||||||
|
|
||||||
|
return NextResponse.json({
|
||||||
|
subscribers: subscribersRes.rows,
|
||||||
|
total: Number(countRes.rows[0].count),
|
||||||
|
limit,
|
||||||
|
offset,
|
||||||
|
});
|
||||||
|
} catch (err) {
|
||||||
|
console.error("Admin subscribers error:", err);
|
||||||
|
return NextResponse.json({ error: "Failed to fetch subscribers" }, { status: 500 });
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,16 +0,0 @@
|
|||||||
import { NextRequest, NextResponse } from "next/server";
|
|
||||||
import { bridgeGet } from "../lib/bridge";
|
|
||||||
|
|
||||||
export async function GET(req: NextRequest) {
|
|
||||||
const diskId = req.nextUrl.searchParams.get("diskId");
|
|
||||||
if (!diskId) {
|
|
||||||
return NextResponse.json({ error: "diskId is required" }, { status: 400 });
|
|
||||||
}
|
|
||||||
try {
|
|
||||||
const data = await bridgeGet(`/api/balance/${diskId}`);
|
|
||||||
return NextResponse.json({ balance: data.balance });
|
|
||||||
} catch (e: unknown) {
|
|
||||||
const message = e instanceof Error ? e.message : "Unknown error";
|
|
||||||
return NextResponse.json({ error: message }, { status: 500 });
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,71 +0,0 @@
|
|||||||
import { NextRequest, NextResponse } from "next/server";
|
|
||||||
import { randomUUID } from "crypto";
|
|
||||||
import { preferenceClient } from "../lib/mercadopago";
|
|
||||||
import { bridgePost, bridgePatch } from "../lib/bridge";
|
|
||||||
|
|
||||||
const PRICE_MXN = Number(process.env.AFC_PRICE_MXN) || 15;
|
|
||||||
const BASE_URL = process.env.NEXT_PUBLIC_SITE_URL || "http://localhost:3000";
|
|
||||||
|
|
||||||
export async function POST(req: NextRequest) {
|
|
||||||
try {
|
|
||||||
const { diskId, amountAfc } = await req.json();
|
|
||||||
|
|
||||||
if (!diskId || !amountAfc || amountAfc < 1) {
|
|
||||||
return NextResponse.json(
|
|
||||||
{ error: "diskId and amountAfc (>=1) required" },
|
|
||||||
{ status: 400 }
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
const amountMxn = amountAfc * PRICE_MXN;
|
|
||||||
const paymentId = randomUUID();
|
|
||||||
|
|
||||||
// Create payment record in bridge
|
|
||||||
await bridgePost("/api/payments", {
|
|
||||||
id: paymentId,
|
|
||||||
diskId,
|
|
||||||
amountAfc,
|
|
||||||
amountMxn,
|
|
||||||
});
|
|
||||||
|
|
||||||
// Create MercadoPago preference
|
|
||||||
const preference = await preferenceClient.create({
|
|
||||||
body: {
|
|
||||||
items: [
|
|
||||||
{
|
|
||||||
id: paymentId,
|
|
||||||
title: `${amountAfc} AfterCoin (AFC)`,
|
|
||||||
quantity: 1,
|
|
||||||
unit_price: amountMxn,
|
|
||||||
currency_id: "MXN",
|
|
||||||
},
|
|
||||||
],
|
|
||||||
external_reference: paymentId,
|
|
||||||
back_urls: {
|
|
||||||
success: `${BASE_URL}/afc/buy/success?payment_id=${paymentId}`,
|
|
||||||
failure: `${BASE_URL}/afc/buy/failure?payment_id=${paymentId}`,
|
|
||||||
pending: `${BASE_URL}/afc/buy/pending?payment_id=${paymentId}`,
|
|
||||||
},
|
|
||||||
auto_return: "approved",
|
|
||||||
notification_url:
|
|
||||||
process.env.MERCADOPAGO_WEBHOOK_URL ||
|
|
||||||
`${BASE_URL}/api/afc/webhook`,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
// Store the MP preference ID
|
|
||||||
await bridgePatch(`/api/payments/${paymentId}`, {
|
|
||||||
mp_preference_id: preference.id,
|
|
||||||
});
|
|
||||||
|
|
||||||
return NextResponse.json({
|
|
||||||
paymentId,
|
|
||||||
initPoint: preference.init_point,
|
|
||||||
sandboxInitPoint: preference.sandbox_init_point,
|
|
||||||
});
|
|
||||||
} catch (e: unknown) {
|
|
||||||
const message = e instanceof Error ? e.message : "Unknown error";
|
|
||||||
console.error("create-preference error:", e);
|
|
||||||
return NextResponse.json({ error: message }, { status: 500 });
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,50 +0,0 @@
|
|||||||
const BRIDGE_URL = process.env.AFC_BRIDGE_URL || "http://afc-bridge:3001";
|
|
||||||
const BRIDGE_SECRET = process.env.AFC_BRIDGE_SECRET || "";
|
|
||||||
|
|
||||||
export async function bridgeGet(path: string) {
|
|
||||||
const res = await fetch(`${BRIDGE_URL}${path}`, {
|
|
||||||
cache: "no-store",
|
|
||||||
});
|
|
||||||
if (!res.ok) {
|
|
||||||
const body = await res.json().catch(() => ({}));
|
|
||||||
throw new Error(body.error || `Bridge error: ${res.status}`);
|
|
||||||
}
|
|
||||||
return res.json();
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function bridgePost(path: string, body: Record<string, unknown>) {
|
|
||||||
const res = await fetch(`${BRIDGE_URL}${path}`, {
|
|
||||||
method: "POST",
|
|
||||||
headers: {
|
|
||||||
"Content-Type": "application/json",
|
|
||||||
"x-bridge-secret": BRIDGE_SECRET,
|
|
||||||
},
|
|
||||||
body: JSON.stringify(body),
|
|
||||||
cache: "no-store",
|
|
||||||
});
|
|
||||||
if (!res.ok) {
|
|
||||||
const data = await res.json().catch(() => ({}));
|
|
||||||
throw new Error(data.error || `Bridge error: ${res.status}`);
|
|
||||||
}
|
|
||||||
return res.json();
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function bridgePatch(
|
|
||||||
path: string,
|
|
||||||
body: Record<string, unknown>
|
|
||||||
) {
|
|
||||||
const res = await fetch(`${BRIDGE_URL}${path}`, {
|
|
||||||
method: "PATCH",
|
|
||||||
headers: {
|
|
||||||
"Content-Type": "application/json",
|
|
||||||
"x-bridge-secret": BRIDGE_SECRET,
|
|
||||||
},
|
|
||||||
body: JSON.stringify(body),
|
|
||||||
cache: "no-store",
|
|
||||||
});
|
|
||||||
if (!res.ok) {
|
|
||||||
const data = await res.json().catch(() => ({}));
|
|
||||||
throw new Error(data.error || `Bridge error: ${res.status}`);
|
|
||||||
}
|
|
||||||
return res.json();
|
|
||||||
}
|
|
||||||
@@ -1,9 +0,0 @@
|
|||||||
import { MercadoPagoConfig, Preference, Payment } from "mercadopago";
|
|
||||||
|
|
||||||
const ACCESS_TOKEN = process.env.MERCADOPAGO_ACCESS_TOKEN || "";
|
|
||||||
|
|
||||||
const client = new MercadoPagoConfig({ accessToken: ACCESS_TOKEN });
|
|
||||||
|
|
||||||
export const preferenceClient = new Preference(client);
|
|
||||||
export const paymentClient = new Payment(client);
|
|
||||||
export { client as mpClient };
|
|
||||||
@@ -1,16 +0,0 @@
|
|||||||
import { NextRequest, NextResponse } from "next/server";
|
|
||||||
import { bridgeGet } from "../lib/bridge";
|
|
||||||
|
|
||||||
export async function GET(req: NextRequest) {
|
|
||||||
const diskId = req.nextUrl.searchParams.get("diskId");
|
|
||||||
if (!diskId) {
|
|
||||||
return NextResponse.json({ error: "diskId is required" }, { status: 400 });
|
|
||||||
}
|
|
||||||
try {
|
|
||||||
const data = await bridgeGet(`/api/payments/history/${diskId}`);
|
|
||||||
return NextResponse.json(data);
|
|
||||||
} catch (e: unknown) {
|
|
||||||
const message = e instanceof Error ? e.message : "Unknown error";
|
|
||||||
return NextResponse.json({ error: message }, { status: 500 });
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,49 +0,0 @@
|
|||||||
import { NextRequest, NextResponse } from "next/server";
|
|
||||||
import { randomUUID } from "crypto";
|
|
||||||
import { bridgePost } from "../lib/bridge";
|
|
||||||
|
|
||||||
export async function POST(req: NextRequest) {
|
|
||||||
try {
|
|
||||||
const { diskId, amountAfc, prizeType, prizeDetail, deliveryInfo } =
|
|
||||||
await req.json();
|
|
||||||
|
|
||||||
if (!diskId || !amountAfc || !prizeType || !prizeDetail) {
|
|
||||||
return NextResponse.json(
|
|
||||||
{
|
|
||||||
error:
|
|
||||||
"diskId, amountAfc, prizeType, and prizeDetail are required",
|
|
||||||
},
|
|
||||||
{ status: 400 }
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Burn the AFC via withdraw (burn) endpoint
|
|
||||||
const burnResult = await bridgePost("/api/withdraw", {
|
|
||||||
diskId,
|
|
||||||
amount: amountAfc,
|
|
||||||
});
|
|
||||||
|
|
||||||
const redemptionId = randomUUID();
|
|
||||||
|
|
||||||
// Create redemption record
|
|
||||||
await bridgePost("/api/redemptions", {
|
|
||||||
id: redemptionId,
|
|
||||||
diskId,
|
|
||||||
amountAfc,
|
|
||||||
prizeType,
|
|
||||||
prizeDetail,
|
|
||||||
deliveryInfo: deliveryInfo || "",
|
|
||||||
burnTxHash: burnResult.txHash,
|
|
||||||
});
|
|
||||||
|
|
||||||
return NextResponse.json({
|
|
||||||
redemptionId,
|
|
||||||
burnTxHash: burnResult.txHash,
|
|
||||||
balance: burnResult.balance,
|
|
||||||
});
|
|
||||||
} catch (e: unknown) {
|
|
||||||
const message = e instanceof Error ? e.message : "Unknown error";
|
|
||||||
console.error("redeem error:", e);
|
|
||||||
return NextResponse.json({ error: message }, { status: 500 });
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,16 +0,0 @@
|
|||||||
import { NextRequest, NextResponse } from "next/server";
|
|
||||||
import { bridgeGet } from "../lib/bridge";
|
|
||||||
|
|
||||||
export async function GET(req: NextRequest) {
|
|
||||||
const diskId = req.nextUrl.searchParams.get("diskId");
|
|
||||||
if (!diskId) {
|
|
||||||
return NextResponse.json({ error: "diskId is required" }, { status: 400 });
|
|
||||||
}
|
|
||||||
try {
|
|
||||||
const data = await bridgeGet(`/api/redemptions/history/${diskId}`);
|
|
||||||
return NextResponse.json(data);
|
|
||||||
} catch (e: unknown) {
|
|
||||||
const message = e instanceof Error ? e.message : "Unknown error";
|
|
||||||
return NextResponse.json({ error: message }, { status: 500 });
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,15 +0,0 @@
|
|||||||
import { NextRequest, NextResponse } from "next/server";
|
|
||||||
import { bridgeGet } from "../lib/bridge";
|
|
||||||
|
|
||||||
export async function GET(req: NextRequest) {
|
|
||||||
const diskId = req.nextUrl.searchParams.get("diskId");
|
|
||||||
if (!diskId) {
|
|
||||||
return NextResponse.json({ error: "diskId is required" }, { status: 400 });
|
|
||||||
}
|
|
||||||
try {
|
|
||||||
const data = await bridgeGet(`/api/wallet/${diskId}`);
|
|
||||||
return NextResponse.json({ valid: true, name: data.name || null });
|
|
||||||
} catch {
|
|
||||||
return NextResponse.json({ valid: false, name: null });
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,113 +0,0 @@
|
|||||||
import { NextRequest, NextResponse } from "next/server";
|
|
||||||
import { createHmac } from "crypto";
|
|
||||||
import { paymentClient } from "../lib/mercadopago";
|
|
||||||
import { bridgeGet, bridgePost, bridgePatch } from "../lib/bridge";
|
|
||||||
|
|
||||||
const WEBHOOK_SECRET = process.env.MERCADOPAGO_WEBHOOK_SECRET || "";
|
|
||||||
|
|
||||||
function verifySignature(req: NextRequest): boolean {
|
|
||||||
if (!WEBHOOK_SECRET) return true; // Skip in dev if no secret configured
|
|
||||||
|
|
||||||
const xSignature = req.headers.get("x-signature") || "";
|
|
||||||
const xRequestId = req.headers.get("x-request-id") || "";
|
|
||||||
|
|
||||||
// MercadoPago v2 signature: ts=xxx,v1=xxx
|
|
||||||
const parts = Object.fromEntries(
|
|
||||||
xSignature.split(",").map((p) => {
|
|
||||||
const [k, ...v] = p.trim().split("=");
|
|
||||||
return [k, v.join("=")];
|
|
||||||
})
|
|
||||||
);
|
|
||||||
|
|
||||||
const dataId = new URL(req.url).searchParams.get("data.id") || "";
|
|
||||||
const manifest = `id:${dataId};request-id:${xRequestId};ts:${parts.ts};`;
|
|
||||||
const hmac = createHmac("sha256", WEBHOOK_SECRET)
|
|
||||||
.update(manifest)
|
|
||||||
.digest("hex");
|
|
||||||
|
|
||||||
return hmac === parts.v1;
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function POST(req: NextRequest) {
|
|
||||||
try {
|
|
||||||
const body = await req.text();
|
|
||||||
|
|
||||||
if (!verifySignature(req)) {
|
|
||||||
return NextResponse.json(
|
|
||||||
{ error: "Invalid signature" },
|
|
||||||
{ status: 401 }
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
const data = JSON.parse(body);
|
|
||||||
|
|
||||||
// Only process payment notifications
|
|
||||||
if (data.type !== "payment") {
|
|
||||||
return NextResponse.json({ ok: true });
|
|
||||||
}
|
|
||||||
|
|
||||||
const mpPaymentId = String(data.data?.id);
|
|
||||||
if (!mpPaymentId) {
|
|
||||||
return NextResponse.json({ ok: true });
|
|
||||||
}
|
|
||||||
|
|
||||||
// Fetch payment details from MercadoPago
|
|
||||||
const mpPayment = await paymentClient.get({ id: mpPaymentId });
|
|
||||||
|
|
||||||
if (mpPayment.status !== "approved") {
|
|
||||||
// Update our record status but don't mint
|
|
||||||
const externalRef = mpPayment.external_reference;
|
|
||||||
if (externalRef) {
|
|
||||||
await bridgePatch(`/api/payments/${externalRef}`, {
|
|
||||||
status: mpPayment.status,
|
|
||||||
mp_payment_id: mpPaymentId,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
return NextResponse.json({ ok: true });
|
|
||||||
}
|
|
||||||
|
|
||||||
const paymentId = mpPayment.external_reference;
|
|
||||||
if (!paymentId) {
|
|
||||||
console.error("webhook: no external_reference in MP payment");
|
|
||||||
return NextResponse.json({ ok: true });
|
|
||||||
}
|
|
||||||
|
|
||||||
// Get our payment record
|
|
||||||
let payment;
|
|
||||||
try {
|
|
||||||
payment = (await bridgeGet(`/api/payments/${paymentId}`)).payment;
|
|
||||||
} catch {
|
|
||||||
console.error("webhook: payment not found:", paymentId);
|
|
||||||
return NextResponse.json({ ok: true });
|
|
||||||
}
|
|
||||||
|
|
||||||
// Idempotency: if already minted, skip
|
|
||||||
if (payment.status === "completed" && payment.tx_hash) {
|
|
||||||
return NextResponse.json({ ok: true, already_processed: true });
|
|
||||||
}
|
|
||||||
|
|
||||||
// Mint AFC via bridge deposit endpoint
|
|
||||||
const mintResult = await bridgePost("/api/deposit", {
|
|
||||||
diskId: payment.disk_id,
|
|
||||||
amount: payment.amount_afc,
|
|
||||||
});
|
|
||||||
|
|
||||||
// Update payment record as completed
|
|
||||||
await bridgePatch(`/api/payments/${paymentId}`, {
|
|
||||||
status: "completed",
|
|
||||||
mp_payment_id: mpPaymentId,
|
|
||||||
tx_hash: mintResult.txHash,
|
|
||||||
});
|
|
||||||
|
|
||||||
console.log(
|
|
||||||
`webhook: minted ${payment.amount_afc} AFC for disk ${payment.disk_id}, tx: ${mintResult.txHash}`
|
|
||||||
);
|
|
||||||
|
|
||||||
return NextResponse.json({ ok: true, minted: true });
|
|
||||||
} catch (e: unknown) {
|
|
||||||
const message = e instanceof Error ? e.message : "Unknown error";
|
|
||||||
console.error("webhook error:", e);
|
|
||||||
// Always return 200 to MP so it doesn't retry endlessly
|
|
||||||
return NextResponse.json({ ok: true, error: message });
|
|
||||||
}
|
|
||||||
}
|
|
||||||
3
apps/web/src/app/api/auth/[...nextauth]/route.ts
Normal file
3
apps/web/src/app/api/auth/[...nextauth]/route.ts
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
import { GET, POST } from "@/lib/auth";
|
||||||
|
|
||||||
|
export { GET, POST };
|
||||||
48
apps/web/src/app/api/contact/route.ts
Normal file
48
apps/web/src/app/api/contact/route.ts
Normal file
@@ -0,0 +1,48 @@
|
|||||||
|
import { NextRequest, NextResponse } from "next/server";
|
||||||
|
import { Pool } from "pg";
|
||||||
|
import { rateLimit } from "@/lib/rate-limit/simple";
|
||||||
|
import { logActivity } from "@/lib/activity";
|
||||||
|
import { sendContactNotification } from "@/lib/email";
|
||||||
|
|
||||||
|
const pool = new Pool({
|
||||||
|
host: process.env.DATABASE_HOST || "postgres",
|
||||||
|
port: Number(process.env.DATABASE_PORT || "5432"),
|
||||||
|
database: process.env.DATABASE_NAME || "afterlife",
|
||||||
|
user: process.env.DATABASE_USERNAME || "afterlife",
|
||||||
|
password: process.env.DATABASE_PASSWORD || "afterlife",
|
||||||
|
});
|
||||||
|
|
||||||
|
export async function POST(req: NextRequest) {
|
||||||
|
const ip = req.headers.get("x-forwarded-for") || "anonymous";
|
||||||
|
const limit = rateLimit(`contact-${ip}`, 3, 300000);
|
||||||
|
|
||||||
|
if (!limit.success) {
|
||||||
|
return NextResponse.json({ error: "Too many messages. Please try again later." }, { status: 429 });
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const { name, email, subject, message } = await req.json();
|
||||||
|
|
||||||
|
if (!name || !email || !message) {
|
||||||
|
return NextResponse.json({ error: "Name, email, and message are required" }, { status: 400 });
|
||||||
|
}
|
||||||
|
|
||||||
|
const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
|
||||||
|
if (!emailRegex.test(email.trim())) {
|
||||||
|
return NextResponse.json({ error: "Invalid email address" }, { status: 400 });
|
||||||
|
}
|
||||||
|
|
||||||
|
await pool.query(
|
||||||
|
`INSERT INTO contact_messages (name, email, subject, message) VALUES ($1, $2, $3, $4)`,
|
||||||
|
[name.trim(), email.trim().toLowerCase(), subject?.trim() || null, message.trim()]
|
||||||
|
);
|
||||||
|
|
||||||
|
sendContactNotification({ name: name.trim(), email: email.trim(), subject: subject?.trim(), message: message.trim() }).catch(() => {});
|
||||||
|
logActivity("contact_message", { name: name.trim(), email: email.trim(), subject: subject?.trim() }).catch(() => {});
|
||||||
|
|
||||||
|
return NextResponse.json({ success: true, message: "Message sent successfully" });
|
||||||
|
} catch (err) {
|
||||||
|
console.error("Contact form error:", err);
|
||||||
|
return NextResponse.json({ error: "Failed to send message" }, { status: 500 });
|
||||||
|
}
|
||||||
|
}
|
||||||
97
apps/web/src/app/api/health/route.ts
Normal file
97
apps/web/src/app/api/health/route.ts
Normal file
@@ -0,0 +1,97 @@
|
|||||||
|
import { NextRequest, NextResponse } from "next/server";
|
||||||
|
import * as net from "net";
|
||||||
|
import { rateLimit } from "@/lib/rate-limit/simple";
|
||||||
|
|
||||||
|
const CMS_URL = process.env.STRAPI_URL || process.env.NEXT_PUBLIC_CMS_URL || "http://localhost:1337";
|
||||||
|
|
||||||
|
interface ServerConfig {
|
||||||
|
name: string;
|
||||||
|
host: string;
|
||||||
|
port: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
const GAME_SERVERS: ServerConfig[] = [
|
||||||
|
{ name: "NieR Reincarnation", host: process.env.NEXT_PUBLIC_NIER_IP || "play.consultoria-as.com", port: 443 },
|
||||||
|
{ name: "Dragon Ball Online", host: process.env.NEXT_PUBLIC_DBO_IP || "play.consultoria-as.com", port: 22000 },
|
||||||
|
{ name: "MapleStory 2", host: process.env.NEXT_PUBLIC_MAPLE2_IP || "play.consultoria-as.com", port: 20001 },
|
||||||
|
{ name: "FusionFall", host: process.env.NEXT_PUBLIC_FUSIONFALL_IP || "play.consultoria-as.com", port: 23000 },
|
||||||
|
];
|
||||||
|
|
||||||
|
function tcpPing(host: string, port: number, timeoutMs = 3000): Promise<{ status: "up" | "down"; latencyMs: number }> {
|
||||||
|
return new Promise((resolve) => {
|
||||||
|
const start = Date.now();
|
||||||
|
const socket = new net.Socket();
|
||||||
|
|
||||||
|
socket.setTimeout(timeoutMs);
|
||||||
|
|
||||||
|
socket.on("connect", () => {
|
||||||
|
const latencyMs = Date.now() - start;
|
||||||
|
socket.destroy();
|
||||||
|
resolve({ status: "up", latencyMs });
|
||||||
|
});
|
||||||
|
|
||||||
|
socket.on("timeout", () => {
|
||||||
|
socket.destroy();
|
||||||
|
resolve({ status: "down", latencyMs: Date.now() - start });
|
||||||
|
});
|
||||||
|
|
||||||
|
socket.on("error", () => {
|
||||||
|
socket.destroy();
|
||||||
|
resolve({ status: "down", latencyMs: Date.now() - start });
|
||||||
|
});
|
||||||
|
|
||||||
|
socket.connect(port, host);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function GET(req: NextRequest) {
|
||||||
|
const ip = req.headers.get("x-forwarded-for") || "anonymous";
|
||||||
|
const limit = rateLimit(`health-${ip}`, 60, 60000);
|
||||||
|
|
||||||
|
if (!limit.success) {
|
||||||
|
return NextResponse.json({ error: "Rate limit exceeded" }, { status: 429 });
|
||||||
|
}
|
||||||
|
|
||||||
|
const start = Date.now();
|
||||||
|
let cmsStatus: "up" | "down" = "down";
|
||||||
|
let cmsLatency = 0;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const controller = new AbortController();
|
||||||
|
const timeout = setTimeout(() => controller.abort(), 5000);
|
||||||
|
|
||||||
|
const res = await fetch(`${CMS_URL}/api/games?pagination[pageSize]=1`, {
|
||||||
|
signal: controller.signal,
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
});
|
||||||
|
|
||||||
|
clearTimeout(timeout);
|
||||||
|
cmsLatency = Date.now() - start;
|
||||||
|
cmsStatus = res.ok ? "up" : "down";
|
||||||
|
} catch {
|
||||||
|
cmsLatency = Date.now() - start;
|
||||||
|
cmsStatus = "down";
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check game servers in parallel via TCP
|
||||||
|
const serverChecks = await Promise.all(
|
||||||
|
GAME_SERVERS.map(async (server) => {
|
||||||
|
const check = await tcpPing(server.host, server.port);
|
||||||
|
return { name: server.name, ...check };
|
||||||
|
})
|
||||||
|
);
|
||||||
|
|
||||||
|
const allUp = cmsStatus === "up" && serverChecks.every((s) => s.status === "up");
|
||||||
|
|
||||||
|
return NextResponse.json({
|
||||||
|
status: allUp ? "healthy" : "degraded",
|
||||||
|
checks: {
|
||||||
|
cms: {
|
||||||
|
status: cmsStatus,
|
||||||
|
latencyMs: cmsLatency,
|
||||||
|
},
|
||||||
|
servers: serverChecks,
|
||||||
|
timestamp: new Date().toISOString(),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
49
apps/web/src/app/api/newsletter/route.ts
Normal file
49
apps/web/src/app/api/newsletter/route.ts
Normal file
@@ -0,0 +1,49 @@
|
|||||||
|
import { NextRequest, NextResponse } from "next/server";
|
||||||
|
import { Pool } from "pg";
|
||||||
|
import { rateLimit } from "@/lib/rate-limit/simple";
|
||||||
|
import { logActivity } from "@/lib/activity";
|
||||||
|
import { sendSubscriberNotification } from "@/lib/email";
|
||||||
|
|
||||||
|
const pool = new Pool({
|
||||||
|
host: process.env.DATABASE_HOST || "postgres",
|
||||||
|
port: Number(process.env.DATABASE_PORT || "5432"),
|
||||||
|
database: process.env.DATABASE_NAME || "afterlife",
|
||||||
|
user: process.env.DATABASE_USERNAME || "afterlife",
|
||||||
|
password: process.env.DATABASE_PASSWORD || "afterlife",
|
||||||
|
});
|
||||||
|
|
||||||
|
export async function POST(req: NextRequest) {
|
||||||
|
const ip = req.headers.get("x-forwarded-for") || "anonymous";
|
||||||
|
const limit = rateLimit(`newsletter-${ip}`, 3, 300000);
|
||||||
|
|
||||||
|
if (!limit.success) {
|
||||||
|
return NextResponse.json({ error: "Too many subscriptions. Please try again later." }, { status: 429 });
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const { email, locale } = await req.json();
|
||||||
|
|
||||||
|
if (!email || typeof email !== "string") {
|
||||||
|
return NextResponse.json({ error: "Email is required" }, { status: 400 });
|
||||||
|
}
|
||||||
|
|
||||||
|
const normalizedEmail = email.trim().toLowerCase();
|
||||||
|
const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
|
||||||
|
if (!emailRegex.test(normalizedEmail)) {
|
||||||
|
return NextResponse.json({ error: "Invalid email address" }, { status: 400 });
|
||||||
|
}
|
||||||
|
|
||||||
|
await pool.query(
|
||||||
|
"INSERT INTO newsletter_subscribers (email, locale) VALUES ($1, $2) ON CONFLICT (email) DO NOTHING",
|
||||||
|
[normalizedEmail, locale || "es"]
|
||||||
|
);
|
||||||
|
|
||||||
|
sendSubscriberNotification(normalizedEmail, locale || "es").catch(() => {});
|
||||||
|
logActivity("newsletter_subscribe", { email: normalizedEmail, locale: locale || "es" }).catch(() => {});
|
||||||
|
|
||||||
|
return NextResponse.json({ success: true, message: "Subscribed successfully" });
|
||||||
|
} catch (err) {
|
||||||
|
console.error("Newsletter subscription error:", err);
|
||||||
|
return NextResponse.json({ error: "Failed to subscribe" }, { status: 500 });
|
||||||
|
}
|
||||||
|
}
|
||||||
47
apps/web/src/app/api/sse/route.ts
Normal file
47
apps/web/src/app/api/sse/route.ts
Normal file
@@ -0,0 +1,47 @@
|
|||||||
|
import { NextRequest } from "next/server";
|
||||||
|
import { redis } from "@/lib/redis";
|
||||||
|
|
||||||
|
export const dynamic = "force-dynamic";
|
||||||
|
|
||||||
|
export async function GET(req: NextRequest) {
|
||||||
|
const stream = new ReadableStream({
|
||||||
|
async start(controller) {
|
||||||
|
const encoder = new TextEncoder();
|
||||||
|
let lastActivityCount = 0;
|
||||||
|
|
||||||
|
// Send initial connection event
|
||||||
|
controller.enqueue(encoder.encode("event: connected\ndata: \"ok\"\n\n"));
|
||||||
|
|
||||||
|
const interval = setInterval(async () => {
|
||||||
|
try {
|
||||||
|
// Get latest activity count from Redis or fallback
|
||||||
|
const count = await redis.get("activity:count").catch(() => null);
|
||||||
|
const current = count ? parseInt(count, 10) : lastActivityCount;
|
||||||
|
|
||||||
|
if (current !== lastActivityCount) {
|
||||||
|
lastActivityCount = current;
|
||||||
|
controller.enqueue(
|
||||||
|
encoder.encode(`event: update\ndata: ${JSON.stringify({ count: current, time: Date.now() })}\n\n`)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// ignore
|
||||||
|
}
|
||||||
|
}, 5000);
|
||||||
|
|
||||||
|
// Cleanup on close
|
||||||
|
req.signal.addEventListener("abort", () => {
|
||||||
|
clearInterval(interval);
|
||||||
|
controller.close();
|
||||||
|
});
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
return new Response(stream, {
|
||||||
|
headers: {
|
||||||
|
"Content-Type": "text/event-stream",
|
||||||
|
"Cache-Control": "no-cache",
|
||||||
|
Connection: "keep-alive",
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -2,17 +2,78 @@
|
|||||||
|
|
||||||
@theme {
|
@theme {
|
||||||
--font-sans: var(--font-dm-sans), system-ui, sans-serif;
|
--font-sans: var(--font-dm-sans), system-ui, sans-serif;
|
||||||
--font-display: var(--font-playfair), Georgia, serif;
|
--font-display: var(--font-syne), system-ui, sans-serif;
|
||||||
--font-body: var(--font-source-serif), Georgia, serif;
|
--font-body: var(--font-dm-sans), system-ui, sans-serif;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* ── Editorial prose — game descriptions ────────────────── */
|
/* Dark mode is the only mode (matches reference design) */
|
||||||
|
@variant dark (&:where([data-theme=dark], [data-theme=dark] *));
|
||||||
|
|
||||||
|
/* ── Theme variables ───────────────────────────────────── */
|
||||||
|
|
||||||
|
:root {
|
||||||
|
--bg-primary: #0a0a0f;
|
||||||
|
--bg-secondary: #12121a;
|
||||||
|
--bg-card: #1a1a24;
|
||||||
|
--bg-elevated: #22222e;
|
||||||
|
--accent-primary: #d4a574;
|
||||||
|
--accent-secondary: #e8c4a0;
|
||||||
|
--accent-glow: rgba(212, 165, 116, 0.3);
|
||||||
|
--text-primary: #f5f5f7;
|
||||||
|
--text-secondary: #a0a0a8;
|
||||||
|
--text-muted: #6b6b75;
|
||||||
|
--border-color: rgba(255, 255, 255, 0.08);
|
||||||
|
--border-hover: rgba(212, 165, 116, 0.3);
|
||||||
|
--ease-out-expo: cubic-bezier(0.16, 1, 0.3, 1);
|
||||||
|
--ease-out-back: cubic-bezier(0.34, 1.56, 0.64, 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
[data-theme="dark"] {
|
||||||
|
--bg-primary: #0a0a0f;
|
||||||
|
--bg-secondary: #12121a;
|
||||||
|
--bg-card: #1a1a24;
|
||||||
|
--bg-elevated: #22222e;
|
||||||
|
--accent-primary: #d4a574;
|
||||||
|
--accent-secondary: #e8c4a0;
|
||||||
|
--accent-glow: rgba(212, 165, 116, 0.3);
|
||||||
|
--text-primary: #f5f5f7;
|
||||||
|
--text-secondary: #a0a0a8;
|
||||||
|
--text-muted: #6b6b75;
|
||||||
|
--border-color: rgba(255, 255, 255, 0.08);
|
||||||
|
--border-hover: rgba(212, 165, 116, 0.3);
|
||||||
|
}
|
||||||
|
|
||||||
|
body {
|
||||||
|
background-color: var(--bg-primary);
|
||||||
|
color: var(--text-primary);
|
||||||
|
transition: background-color 0.3s ease, color 0.3s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Scrollbar styling ─────────────────────────────────── */
|
||||||
|
|
||||||
|
::-webkit-scrollbar {
|
||||||
|
width: 8px;
|
||||||
|
}
|
||||||
|
::-webkit-scrollbar-track {
|
||||||
|
background: var(--bg-primary);
|
||||||
|
}
|
||||||
|
::-webkit-scrollbar-thumb {
|
||||||
|
background: var(--accent-primary);
|
||||||
|
border-radius: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
::selection {
|
||||||
|
background: var(--accent-primary);
|
||||||
|
color: var(--bg-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Editorial prose ───────────────────────────────────── */
|
||||||
|
|
||||||
.prose-editorial p {
|
.prose-editorial p {
|
||||||
font-family: var(--font-body);
|
font-family: var(--font-body);
|
||||||
font-size: 1.125rem;
|
font-size: 1.125rem;
|
||||||
line-height: 1.85;
|
line-height: 1.85;
|
||||||
color: #d1d5db;
|
color: var(--text-secondary);
|
||||||
margin-bottom: 1.5em;
|
margin-bottom: 1.5em;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -26,7 +87,7 @@
|
|||||||
font-family: var(--font-body);
|
font-family: var(--font-body);
|
||||||
font-size: 1.1875rem;
|
font-size: 1.1875rem;
|
||||||
line-height: 1.9;
|
line-height: 1.9;
|
||||||
color: #e5e7eb;
|
color: var(--text-secondary);
|
||||||
margin-bottom: 1.75em;
|
margin-bottom: 1.75em;
|
||||||
letter-spacing: 0.005em;
|
letter-spacing: 0.005em;
|
||||||
}
|
}
|
||||||
@@ -39,16 +100,180 @@
|
|||||||
padding-right: 0.5rem;
|
padding-right: 0.5rem;
|
||||||
margin-top: 0.1rem;
|
margin-top: 0.1rem;
|
||||||
font-weight: 700;
|
font-weight: 700;
|
||||||
color: #f59e0b;
|
color: var(--accent-primary);
|
||||||
}
|
}
|
||||||
|
|
||||||
.chapter-prose p:last-child {
|
.chapter-prose p:last-child {
|
||||||
margin-bottom: 0;
|
margin-bottom: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* ── Em-dash and quotation styling inside prose ─────────── */
|
|
||||||
|
|
||||||
.chapter-prose p em {
|
.chapter-prose p em {
|
||||||
font-style: italic;
|
font-style: italic;
|
||||||
color: #fbbf24;
|
color: var(--accent-secondary);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Hero dot grid fade animation ───────────────────────── */
|
||||||
|
|
||||||
|
@keyframes dotGridFade {
|
||||||
|
0%, 100% {
|
||||||
|
opacity: 0.25;
|
||||||
|
}
|
||||||
|
50% {
|
||||||
|
opacity: 0.55;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.dot-grid-fade {
|
||||||
|
animation: dotGridFade 6s ease-in-out infinite;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Gradient pulse animation ───────────────────────────── */
|
||||||
|
|
||||||
|
@keyframes gradientPulse {
|
||||||
|
0%, 100% {
|
||||||
|
opacity: 0.5;
|
||||||
|
transform: scale(1);
|
||||||
|
}
|
||||||
|
50% {
|
||||||
|
opacity: 1;
|
||||||
|
transform: scale(1.1);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.gradient-pulse {
|
||||||
|
animation: gradientPulse 8s ease-in-out infinite;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Float animation for particles/icons ───────────────── */
|
||||||
|
|
||||||
|
@keyframes float {
|
||||||
|
0%, 100% {
|
||||||
|
transform: translateY(0);
|
||||||
|
}
|
||||||
|
50% {
|
||||||
|
transform: translateY(-10px);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.float-animation {
|
||||||
|
animation: float 4s ease-in-out infinite;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Particles animation ────────────────────────────────── */
|
||||||
|
|
||||||
|
@keyframes particleFloat {
|
||||||
|
0% {
|
||||||
|
opacity: 0;
|
||||||
|
transform: translateY(100vh) scale(0);
|
||||||
|
}
|
||||||
|
10% {
|
||||||
|
opacity: 0.6;
|
||||||
|
}
|
||||||
|
90% {
|
||||||
|
opacity: 0.6;
|
||||||
|
}
|
||||||
|
100% {
|
||||||
|
opacity: 0;
|
||||||
|
transform: translateY(-100vh) scale(1);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Slide up entrance animation ────────────────────────── */
|
||||||
|
|
||||||
|
@keyframes slideUp {
|
||||||
|
from {
|
||||||
|
opacity: 0;
|
||||||
|
transform: translateY(40px);
|
||||||
|
}
|
||||||
|
to {
|
||||||
|
opacity: 1;
|
||||||
|
transform: translateY(0);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.animate-slide-up {
|
||||||
|
animation: slideUp 1s var(--ease-out-expo) forwards;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Blink animation for badge dot ──────────────────────── */
|
||||||
|
|
||||||
|
@keyframes blink {
|
||||||
|
0%, 100% {
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
|
50% {
|
||||||
|
opacity: 0.3;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.animate-blink {
|
||||||
|
animation: blink 2s infinite;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Gradient shift for CTA backgrounds ─────────────────── */
|
||||||
|
|
||||||
|
@keyframes gradientShift {
|
||||||
|
0% {
|
||||||
|
background-position: 0% 50%;
|
||||||
|
}
|
||||||
|
50% {
|
||||||
|
background-position: 100% 50%;
|
||||||
|
}
|
||||||
|
100% {
|
||||||
|
background-position: 0% 50%;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.animated-gradient-bg {
|
||||||
|
background: linear-gradient(
|
||||||
|
135deg,
|
||||||
|
rgba(212, 165, 116, 0.2),
|
||||||
|
rgba(232, 196, 160, 0.15),
|
||||||
|
rgba(10, 10, 15, 0.3),
|
||||||
|
rgba(212, 165, 116, 0.2)
|
||||||
|
);
|
||||||
|
background-size: 300% 300%;
|
||||||
|
animation: gradientShift 12s ease infinite;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Accent gradient text ───────────────────────────────── */
|
||||||
|
|
||||||
|
.accent-gradient-text {
|
||||||
|
background: linear-gradient(135deg, var(--accent-primary), var(--accent-secondary));
|
||||||
|
-webkit-background-clip: text;
|
||||||
|
-webkit-text-fill-color: transparent;
|
||||||
|
background-clip: text;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Card hover glow ────────────────────────────────────── */
|
||||||
|
|
||||||
|
.card-hover-glow {
|
||||||
|
transition: all 0.4s var(--ease-out-expo);
|
||||||
|
}
|
||||||
|
|
||||||
|
.card-hover-glow:hover {
|
||||||
|
border-color: var(--border-hover);
|
||||||
|
transform: translateY(-5px);
|
||||||
|
box-shadow: 0 20px 60px rgba(0, 0, 0, 0.3);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Link underline animation ───────────────────────────── */
|
||||||
|
|
||||||
|
.link-underline {
|
||||||
|
position: relative;
|
||||||
|
}
|
||||||
|
|
||||||
|
.link-underline::after {
|
||||||
|
content: '';
|
||||||
|
position: absolute;
|
||||||
|
bottom: -4px;
|
||||||
|
left: 0;
|
||||||
|
width: 0;
|
||||||
|
height: 2px;
|
||||||
|
background: var(--accent-primary);
|
||||||
|
transition: width 0.3s var(--ease-out-expo);
|
||||||
|
}
|
||||||
|
|
||||||
|
.link-underline:hover::after {
|
||||||
|
width: 100%;
|
||||||
}
|
}
|
||||||
|
|||||||
30
apps/web/src/app/manifest.ts
Normal file
30
apps/web/src/app/manifest.ts
Normal file
@@ -0,0 +1,30 @@
|
|||||||
|
import type { MetadataRoute } from "next";
|
||||||
|
|
||||||
|
function svgIcon(size: number): string {
|
||||||
|
const svg = `<svg xmlns="http://www.w3.org/2000/svg" width="${size}" height="${size}" viewBox="0 0 ${size} ${size}"><rect width="100%" height="100%" fill="%230f172a"/><text x="50%" y="50%" dominant-baseline="middle" text-anchor="middle" fill="%23ffffff" font-size="${size / 4}" font-family="sans-serif">PA</text></svg>`;
|
||||||
|
return `data:image/svg+xml,${encodeURIComponent(svg)}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function manifest(): MetadataRoute.Manifest {
|
||||||
|
return {
|
||||||
|
name: "Project Afterlife",
|
||||||
|
short_name: "Afterlife",
|
||||||
|
description: "Preserving online games",
|
||||||
|
start_url: "/",
|
||||||
|
display: "standalone",
|
||||||
|
background_color: "#0f172a",
|
||||||
|
theme_color: "#0f172a",
|
||||||
|
icons: [
|
||||||
|
{
|
||||||
|
src: svgIcon(192),
|
||||||
|
sizes: "192x192",
|
||||||
|
type: "image/svg+xml",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
src: svgIcon(512),
|
||||||
|
sizes: "512x512",
|
||||||
|
type: "image/svg+xml",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -1,13 +1,5 @@
|
|||||||
|
import { redirect } from "next/navigation";
|
||||||
|
|
||||||
export default function RootNotFound() {
|
export default function RootNotFound() {
|
||||||
return (
|
redirect("/es");
|
||||||
<html lang="es">
|
|
||||||
<body style={{ backgroundColor: "#030712", color: "#fff", fontFamily: "system-ui", display: "flex", justifyContent: "center", alignItems: "center", minHeight: "100vh", margin: 0 }}>
|
|
||||||
<div style={{ textAlign: "center" }}>
|
|
||||||
<h1 style={{ fontSize: "3rem", marginBottom: "1rem" }}>404</h1>
|
|
||||||
<p style={{ color: "#9ca3af" }}>Page not found</p>
|
|
||||||
<a href="/es" style={{ color: "#60a5fa", marginTop: "1rem", display: "inline-block" }}>Go home</a>
|
|
||||||
</div>
|
|
||||||
</body>
|
|
||||||
</html>
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|||||||
13
apps/web/src/app/robots.ts
Normal file
13
apps/web/src/app/robots.ts
Normal file
@@ -0,0 +1,13 @@
|
|||||||
|
import type { MetadataRoute } from "next";
|
||||||
|
|
||||||
|
const BASE_URL = process.env.NEXT_PUBLIC_SITE_URL || "https://projectafterlife.dev";
|
||||||
|
|
||||||
|
export default function robots(): MetadataRoute.Robots {
|
||||||
|
return {
|
||||||
|
rules: {
|
||||||
|
userAgent: "*",
|
||||||
|
allow: "/",
|
||||||
|
},
|
||||||
|
sitemap: `${BASE_URL}/sitemap.xml`,
|
||||||
|
};
|
||||||
|
}
|
||||||
41
apps/web/src/app/sitemap.ts
Normal file
41
apps/web/src/app/sitemap.ts
Normal file
@@ -0,0 +1,41 @@
|
|||||||
|
import type { MetadataRoute } from "next";
|
||||||
|
import { getGames } from "@/lib/api";
|
||||||
|
|
||||||
|
const BASE_URL = process.env.NEXT_PUBLIC_SITE_URL || "https://projectafterlife.dev";
|
||||||
|
const LOCALES = ["es", "en"] as const;
|
||||||
|
|
||||||
|
export default async function sitemap(): Promise<MetadataRoute.Sitemap> {
|
||||||
|
const staticRoutes = ["", "catalog", "about", "donate", "server-status", "community"];
|
||||||
|
const entries: MetadataRoute.Sitemap = [];
|
||||||
|
|
||||||
|
for (const locale of LOCALES) {
|
||||||
|
for (const route of staticRoutes) {
|
||||||
|
const url = route ? `${BASE_URL}/${locale}/${route}` : `${BASE_URL}/${locale}`;
|
||||||
|
entries.push({
|
||||||
|
url,
|
||||||
|
lastModified: new Date(),
|
||||||
|
priority: route === "" ? 1.0 : 0.8,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const locale of LOCALES) {
|
||||||
|
try {
|
||||||
|
const res = await getGames(locale);
|
||||||
|
const games = res.data ?? [];
|
||||||
|
for (const game of games) {
|
||||||
|
if (game.slug) {
|
||||||
|
entries.push({
|
||||||
|
url: `${BASE_URL}/${locale}/games/${game.slug}`,
|
||||||
|
lastModified: game.updatedAt ? new Date(game.updatedAt) : new Date(),
|
||||||
|
priority: 0.7,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// Strapi not available — skip game entries for this locale
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return entries;
|
||||||
|
}
|
||||||
98
apps/web/src/components/activity/ActivityFeed.tsx
Normal file
98
apps/web/src/components/activity/ActivityFeed.tsx
Normal file
@@ -0,0 +1,98 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useEffect, useState } from "react";
|
||||||
|
import { motion } from "framer-motion";
|
||||||
|
import { LiveIndicator } from "@/components/live/LiveIndicator";
|
||||||
|
|
||||||
|
interface Activity {
|
||||||
|
id: number;
|
||||||
|
type: string;
|
||||||
|
details: Record<string, unknown>;
|
||||||
|
created_at: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
const typeConfig: Record<string, { label: string; color: string; icon: string }> = {
|
||||||
|
newsletter_subscribe: { label: "New subscriber", color: "text-emerald-400 bg-emerald-400/10", icon: "✉️" },
|
||||||
|
contact_message: { label: "Contact message", color: "text-blue-400 bg-blue-400/10", icon: "💬" },
|
||||||
|
server_online: { label: "Server online", color: "text-green-400 bg-green-400/10", icon: "🟢" },
|
||||||
|
server_offline: { label: "Server offline", color: "text-red-400 bg-red-400/10", icon: "🔴" },
|
||||||
|
};
|
||||||
|
|
||||||
|
function formatTimeAgo(iso: string) {
|
||||||
|
const diff = Date.now() - new Date(iso).getTime();
|
||||||
|
const minutes = Math.floor(diff / 60000);
|
||||||
|
const hours = Math.floor(diff / 3600000);
|
||||||
|
const days = Math.floor(diff / 86400000);
|
||||||
|
|
||||||
|
if (minutes < 1) return "just now";
|
||||||
|
if (minutes < 60) return `${minutes}m ago`;
|
||||||
|
if (hours < 24) return `${hours}h ago`;
|
||||||
|
return `${days}d ago`;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function ActivityFeed() {
|
||||||
|
const [activities, setActivities] = useState<Activity[]>([]);
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
fetch("/api/activities?limit=10")
|
||||||
|
.then((r) => r.json())
|
||||||
|
.then((data) => {
|
||||||
|
setActivities(data.activities || []);
|
||||||
|
setLoading(false);
|
||||||
|
})
|
||||||
|
.catch(() => setLoading(false));
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
if (loading) {
|
||||||
|
return (
|
||||||
|
<div className="max-w-2xl mx-auto px-4 py-8">
|
||||||
|
<div className="space-y-3">
|
||||||
|
{Array.from({ length: 3 }).map((_, i) => (
|
||||||
|
<div key={i} className="h-12 bg-[#1a1a24] rounded-lg animate-pulse" />
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (activities.length === 0) return null;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="max-w-2xl mx-auto px-4 py-8">
|
||||||
|
<div className="flex items-center justify-between mb-4">
|
||||||
|
<h3 className="text-sm font-semibold text-[#a0a0a8] uppercase tracking-wider">Recent Activity</h3>
|
||||||
|
<LiveIndicator />
|
||||||
|
</div>
|
||||||
|
<div className="space-y-2">
|
||||||
|
{activities.map((activity, i) => {
|
||||||
|
const config = typeConfig[activity.type] || { label: activity.type, color: "text-[#a0a0a8] bg-[rgba(160,160,168,0.1)]", icon: "•" };
|
||||||
|
const details = activity.details as Record<string, string>;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<motion.div
|
||||||
|
key={activity.id}
|
||||||
|
initial={{ opacity: 0, x: -10 }}
|
||||||
|
animate={{ opacity: 1, x: 0 }}
|
||||||
|
transition={{ delay: i * 0.05, duration: 0.3 }}
|
||||||
|
className="flex items-center gap-3 px-4 py-3 rounded-lg bg-[rgba(255,255,255,0.02)] border border-[rgba(255,255,255,0.04)] hover:border-[rgba(255,255,255,0.08)] transition-colors"
|
||||||
|
>
|
||||||
|
<span className={`flex-shrink-0 w-8 h-8 rounded-full flex items-center justify-center text-xs ${config.color}`}>
|
||||||
|
{config.icon}
|
||||||
|
</span>
|
||||||
|
<div className="flex-1 min-w-0">
|
||||||
|
<p className="text-sm text-[#a0a0a8] truncate">
|
||||||
|
<span className="font-medium">{config.label}</span>
|
||||||
|
{details.email && <span className="text-[#6b6b75]"> — {details.email}</span>}
|
||||||
|
{details.name && <span className="text-[#6b6b75]"> — {details.name}</span>}
|
||||||
|
{details.server && <span className="text-[#6b6b75]"> — {details.server}</span>}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<span className="text-xs text-[#3a3a44] flex-shrink-0">{formatTimeAgo(activity.created_at)}</span>
|
||||||
|
</motion.div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
140
apps/web/src/components/admin/HealthBanner.tsx
Normal file
140
apps/web/src/components/admin/HealthBanner.tsx
Normal file
@@ -0,0 +1,140 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useEffect, useState } from "react";
|
||||||
|
|
||||||
|
interface ServerCheck {
|
||||||
|
name: string;
|
||||||
|
status: "up" | "down";
|
||||||
|
latencyMs: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface HealthData {
|
||||||
|
status: "healthy" | "degraded";
|
||||||
|
checks: {
|
||||||
|
cms: {
|
||||||
|
status: "up" | "down";
|
||||||
|
latencyMs: number;
|
||||||
|
};
|
||||||
|
servers: ServerCheck[];
|
||||||
|
timestamp: string;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function HealthBanner({ locale }: { locale: string }) {
|
||||||
|
const [health, setHealth] = useState<HealthData | null>(null);
|
||||||
|
const [lastUpdated, setLastUpdated] = useState<Date | null>(null);
|
||||||
|
|
||||||
|
const fetchHealth = async () => {
|
||||||
|
try {
|
||||||
|
const res = await fetch("/api/health");
|
||||||
|
if (res.ok) {
|
||||||
|
const data = (await res.json()) as HealthData;
|
||||||
|
setHealth(data);
|
||||||
|
setLastUpdated(new Date());
|
||||||
|
} else {
|
||||||
|
setHealth({
|
||||||
|
status: "degraded",
|
||||||
|
checks: {
|
||||||
|
cms: { status: "down", latencyMs: 0 },
|
||||||
|
servers: [],
|
||||||
|
timestamp: new Date().toISOString(),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
setLastUpdated(new Date());
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
setHealth({
|
||||||
|
status: "degraded",
|
||||||
|
checks: {
|
||||||
|
cms: { status: "down", latencyMs: 0 },
|
||||||
|
servers: [],
|
||||||
|
timestamp: new Date().toISOString(),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
setLastUpdated(new Date());
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
fetchHealth();
|
||||||
|
const interval = setInterval(fetchHealth, 30000);
|
||||||
|
return () => clearInterval(interval);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
if (!health) return null;
|
||||||
|
|
||||||
|
const isHealthy = health.status === "healthy";
|
||||||
|
const cmsUp = health.checks.cms.status === "up";
|
||||||
|
|
||||||
|
const formatTime = (date: Date) =>
|
||||||
|
date.toLocaleTimeString(locale === "es" ? "es-ES" : "en-US", {
|
||||||
|
hour: "2-digit",
|
||||||
|
minute: "2-digit",
|
||||||
|
second: "2-digit",
|
||||||
|
});
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className={`mb-8 rounded-2xl border p-6 ${
|
||||||
|
isHealthy
|
||||||
|
? "bg-emerald-400/5 border-emerald-400/20"
|
||||||
|
: "bg-red-400/5 border-red-400/20"
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<div className="flex flex-col sm:flex-row sm:items-center sm:justify-between gap-4 mb-4">
|
||||||
|
<div>
|
||||||
|
<h2 className="text-lg font-semibold text-[#f5f5f7]">
|
||||||
|
{locale === "es" ? "Salud del Sistema" : "System Health"}
|
||||||
|
</h2>
|
||||||
|
<div className="mt-2 flex items-center gap-4 text-sm">
|
||||||
|
<span className="flex items-center gap-2">
|
||||||
|
<span
|
||||||
|
className={`w-2 h-2 rounded-full ${
|
||||||
|
cmsUp ? "bg-emerald-400" : "bg-red-400"
|
||||||
|
}`}
|
||||||
|
/>
|
||||||
|
<span className="text-[#a0a0a8]">CMS {cmsUp ? "UP" : "DOWN"}</span>
|
||||||
|
</span>
|
||||||
|
<span className="text-[#6b6b75]">
|
||||||
|
{cmsUp ? `${health.checks.cms.latencyMs}ms` : "-"}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{lastUpdated && (
|
||||||
|
<div className="text-xs text-[#6b6b75]">
|
||||||
|
{locale === "es" ? "Actualizado" : "Last updated"}: {formatTime(lastUpdated)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Per-server status */}
|
||||||
|
{health.checks.servers.length > 0 && (
|
||||||
|
<div className="border-t border-[rgba(255,255,255,0.05)] pt-4">
|
||||||
|
<h3 className="text-xs font-semibold text-[#6b6b75] uppercase tracking-wider mb-3">
|
||||||
|
{locale === "es" ? "Servidores de Juego" : "Game Servers"}
|
||||||
|
</h3>
|
||||||
|
<div className="grid grid-cols-1 sm:grid-cols-2 gap-3">
|
||||||
|
{health.checks.servers.map((server) => (
|
||||||
|
<div
|
||||||
|
key={server.name}
|
||||||
|
className="flex items-center justify-between px-3 py-2 rounded-lg bg-[rgba(255,255,255,0.03)] border border-[rgba(255,255,255,0.05)]"
|
||||||
|
>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<span
|
||||||
|
className={`w-2 h-2 rounded-full ${
|
||||||
|
server.status === "up" ? "bg-emerald-400" : "bg-red-400"
|
||||||
|
}`}
|
||||||
|
/>
|
||||||
|
<span className="text-sm text-[#a0a0a8]">{server.name}</span>
|
||||||
|
</div>
|
||||||
|
<span className="text-xs text-[#6b6b75] font-mono">
|
||||||
|
{server.status === "up" ? `${server.latencyMs}ms` : "OFFLINE"}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
75
apps/web/src/components/admin/ServerStatusGrid.tsx
Normal file
75
apps/web/src/components/admin/ServerStatusGrid.tsx
Normal file
@@ -0,0 +1,75 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
export interface Server {
|
||||||
|
name: string;
|
||||||
|
status: "online" | "maintenance" | "offline";
|
||||||
|
ip: string;
|
||||||
|
ports: string;
|
||||||
|
type: string;
|
||||||
|
vm: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface ServerStatusGridProps {
|
||||||
|
servers: Server[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export function ServerStatusGrid({ servers }: ServerStatusGridProps) {
|
||||||
|
const statusConfig = {
|
||||||
|
online: {
|
||||||
|
label: "En línea",
|
||||||
|
dot: "bg-emerald-400",
|
||||||
|
bg: "bg-emerald-400/10 border-emerald-400/20",
|
||||||
|
},
|
||||||
|
maintenance: {
|
||||||
|
label: "Mantenimiento",
|
||||||
|
dot: "bg-amber-400",
|
||||||
|
bg: "bg-amber-400/10 border-amber-400/20",
|
||||||
|
},
|
||||||
|
offline: {
|
||||||
|
label: "Fuera de línea",
|
||||||
|
dot: "bg-red-400",
|
||||||
|
bg: "bg-red-400/10 border-red-400/20",
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
|
||||||
|
{servers.map((server) => {
|
||||||
|
const status = statusConfig[server.status];
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
key={server.name}
|
||||||
|
className={`rounded-2xl border p-6 ${status.bg} transition-all hover:scale-[1.02]`}
|
||||||
|
>
|
||||||
|
<div className="flex items-center justify-between mb-4">
|
||||||
|
<h3 className="text-lg font-semibold text-[#f5f5f7]">{server.name}</h3>
|
||||||
|
<span className="flex items-center gap-1.5 text-xs font-medium">
|
||||||
|
<span className={`w-2 h-2 rounded-full ${status.dot}`} />
|
||||||
|
<span className="text-[#a0a0a8]">{status.label}</span>
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-2 text-sm">
|
||||||
|
<div className="flex justify-between">
|
||||||
|
<span className="text-[#6b6b75]">IP / Dominio</span>
|
||||||
|
<span className="text-[#a0a0a8] font-mono">{server.ip}</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex justify-between">
|
||||||
|
<span className="text-[#6b6b75]">Puerto</span>
|
||||||
|
<span className="text-[#a0a0a8] font-mono">{server.ports}</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex justify-between">
|
||||||
|
<span className="text-[#6b6b75]">Género</span>
|
||||||
|
<span className="text-[#a0a0a8]">{server.type}</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex justify-between">
|
||||||
|
<span className="text-[#6b6b75]">VM</span>
|
||||||
|
<span className="text-[#6b6b75] text-xs">{server.vm}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
83
apps/web/src/components/admin/SubscriberChart.tsx
Normal file
83
apps/web/src/components/admin/SubscriberChart.tsx
Normal file
@@ -0,0 +1,83 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useEffect, useRef } from "react";
|
||||||
|
import {
|
||||||
|
Chart,
|
||||||
|
BarController,
|
||||||
|
BarElement,
|
||||||
|
CategoryScale,
|
||||||
|
LinearScale,
|
||||||
|
Tooltip,
|
||||||
|
Legend,
|
||||||
|
} from "chart.js";
|
||||||
|
|
||||||
|
Chart.register(BarController, BarElement, CategoryScale, LinearScale, Tooltip, Legend);
|
||||||
|
|
||||||
|
interface SubscriberChartProps {
|
||||||
|
data: Array<{ date: string; count: number }>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function SubscriberChart({ data }: SubscriberChartProps) {
|
||||||
|
const canvasRef = useRef<HTMLCanvasElement>(null);
|
||||||
|
const chartRef = useRef<Chart | null>(null);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!canvasRef.current) return;
|
||||||
|
if (chartRef.current) chartRef.current.destroy();
|
||||||
|
|
||||||
|
const ctx = canvasRef.current.getContext("2d");
|
||||||
|
if (!ctx) return;
|
||||||
|
|
||||||
|
chartRef.current = new Chart(ctx, {
|
||||||
|
type: "bar",
|
||||||
|
data: {
|
||||||
|
labels: data.map((d) => d.date),
|
||||||
|
datasets: [
|
||||||
|
{
|
||||||
|
label: "Subscribers",
|
||||||
|
data: data.map((d) => d.count),
|
||||||
|
backgroundColor: "rgba(16, 185, 129, 0.6)",
|
||||||
|
borderColor: "rgba(16, 185, 129, 1)",
|
||||||
|
borderWidth: 1,
|
||||||
|
borderRadius: 4,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
options: {
|
||||||
|
responsive: true,
|
||||||
|
maintainAspectRatio: false,
|
||||||
|
plugins: {
|
||||||
|
legend: { display: false },
|
||||||
|
tooltip: {
|
||||||
|
backgroundColor: "rgba(0, 0, 0, 0.8)",
|
||||||
|
titleColor: "#fff",
|
||||||
|
bodyColor: "#fff",
|
||||||
|
borderColor: "rgba(255, 255, 255, 0.1)",
|
||||||
|
borderWidth: 1,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
scales: {
|
||||||
|
x: {
|
||||||
|
grid: { color: "rgba(255, 255, 255, 0.05)" },
|
||||||
|
ticks: { color: "#6b7280" },
|
||||||
|
},
|
||||||
|
y: {
|
||||||
|
grid: { color: "rgba(255, 255, 255, 0.05)" },
|
||||||
|
ticks: { color: "#6b7280", stepSize: 1 },
|
||||||
|
beginAtZero: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
chartRef.current?.destroy();
|
||||||
|
};
|
||||||
|
}, [data]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="h-64">
|
||||||
|
<canvas ref={canvasRef} />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,49 +0,0 @@
|
|||||||
"use client";
|
|
||||||
|
|
||||||
interface AfcPackageCardProps {
|
|
||||||
amount: number;
|
|
||||||
priceMxn: number;
|
|
||||||
popular?: boolean;
|
|
||||||
loading?: boolean;
|
|
||||||
onSelect: () => void;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function AfcPackageCard({
|
|
||||||
amount,
|
|
||||||
priceMxn,
|
|
||||||
popular,
|
|
||||||
loading,
|
|
||||||
onSelect,
|
|
||||||
}: AfcPackageCardProps) {
|
|
||||||
return (
|
|
||||||
<button
|
|
||||||
onClick={onSelect}
|
|
||||||
disabled={loading}
|
|
||||||
className={`relative group block w-full text-left bg-gray-900 rounded-2xl p-6 border transition-all duration-200 hover:scale-[1.02] active:scale-[0.98] disabled:opacity-50 ${
|
|
||||||
popular
|
|
||||||
? "border-amber-500/50 shadow-lg shadow-amber-500/10"
|
|
||||||
: "border-white/5 hover:border-amber-500/30"
|
|
||||||
}`}
|
|
||||||
>
|
|
||||||
{popular && (
|
|
||||||
<span className="absolute -top-3 left-1/2 -translate-x-1/2 bg-amber-500 text-black text-xs font-bold px-3 py-1 rounded-full">
|
|
||||||
POPULAR
|
|
||||||
</span>
|
|
||||||
)}
|
|
||||||
|
|
||||||
<div className="flex items-center gap-4">
|
|
||||||
<div className="w-14 h-14 rounded-full bg-amber-500/10 border border-amber-500/30 flex items-center justify-center shrink-0 group-hover:bg-amber-500/20 transition-colors">
|
|
||||||
<span className="text-xl font-bold text-amber-400">{amount}</span>
|
|
||||||
</div>
|
|
||||||
<div className="flex-1">
|
|
||||||
<p className="text-white font-semibold text-lg">{amount} AFC</p>
|
|
||||||
<p className="text-gray-500 text-sm">AfterCoin</p>
|
|
||||||
</div>
|
|
||||||
<div className="text-right">
|
|
||||||
<p className="text-2xl font-bold text-white">${priceMxn}</p>
|
|
||||||
<p className="text-xs text-gray-500">MXN</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</button>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,36 +0,0 @@
|
|||||||
"use client";
|
|
||||||
|
|
||||||
import { useTranslations } from "next-intl";
|
|
||||||
|
|
||||||
interface BalanceDisplayProps {
|
|
||||||
balance: number | null;
|
|
||||||
compact?: boolean;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function BalanceDisplay({ balance, compact }: BalanceDisplayProps) {
|
|
||||||
const t = useTranslations("afc");
|
|
||||||
|
|
||||||
if (balance === null) return null;
|
|
||||||
|
|
||||||
if (compact) {
|
|
||||||
return (
|
|
||||||
<span className="inline-flex items-center gap-1.5 text-amber-400 font-semibold">
|
|
||||||
<span className="w-4 h-4 rounded-full bg-amber-500/20 border border-amber-500/40 inline-flex items-center justify-center text-[10px]">
|
|
||||||
A
|
|
||||||
</span>
|
|
||||||
{balance} AFC
|
|
||||||
</span>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="bg-gradient-to-br from-amber-500/10 to-amber-600/5 border border-amber-500/20 rounded-2xl p-6 text-center">
|
|
||||||
<div className="w-16 h-16 mx-auto mb-3 rounded-full bg-amber-500/15 border-2 border-amber-500/30 flex items-center justify-center">
|
|
||||||
<span className="text-2xl font-bold text-amber-400">A</span>
|
|
||||||
</div>
|
|
||||||
<p className="text-sm text-gray-400 mb-1">{t("your_balance")}</p>
|
|
||||||
<p className="text-4xl font-bold text-white">{balance}</p>
|
|
||||||
<p className="text-sm text-amber-400 mt-1">AfterCoin</p>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,77 +0,0 @@
|
|||||||
"use client";
|
|
||||||
|
|
||||||
import { useTranslations } from "next-intl";
|
|
||||||
|
|
||||||
interface DiskIdInputProps {
|
|
||||||
diskId: string;
|
|
||||||
onChange: (value: string) => void;
|
|
||||||
onVerify: () => void;
|
|
||||||
loading: boolean;
|
|
||||||
verified: boolean;
|
|
||||||
playerName: string | null;
|
|
||||||
error: string | null;
|
|
||||||
onClear?: () => void;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function DiskIdInput({
|
|
||||||
diskId,
|
|
||||||
onChange,
|
|
||||||
onVerify,
|
|
||||||
loading,
|
|
||||||
verified,
|
|
||||||
playerName,
|
|
||||||
error,
|
|
||||||
onClear,
|
|
||||||
}: DiskIdInputProps) {
|
|
||||||
const t = useTranslations("afc");
|
|
||||||
|
|
||||||
if (verified && playerName) {
|
|
||||||
return (
|
|
||||||
<div className="flex items-center gap-4 bg-amber-500/10 border border-amber-500/30 rounded-xl px-5 py-4">
|
|
||||||
<div className="w-10 h-10 rounded-full bg-amber-500/20 flex items-center justify-center text-amber-400 font-bold text-lg shrink-0">
|
|
||||||
{playerName.charAt(0).toUpperCase()}
|
|
||||||
</div>
|
|
||||||
<div className="flex-1 min-w-0">
|
|
||||||
<p className="text-sm text-amber-400/70">{t("disk_id")}: {diskId}</p>
|
|
||||||
<p className="text-white font-semibold truncate">{playerName}</p>
|
|
||||||
</div>
|
|
||||||
{onClear && (
|
|
||||||
<button
|
|
||||||
onClick={onClear}
|
|
||||||
className="text-xs text-gray-500 hover:text-gray-300 transition-colors"
|
|
||||||
>
|
|
||||||
{t("change")}
|
|
||||||
</button>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="space-y-3">
|
|
||||||
<label className="block text-sm font-medium text-gray-400">
|
|
||||||
{t("enter_disk_id")}
|
|
||||||
</label>
|
|
||||||
<div className="flex gap-3">
|
|
||||||
<input
|
|
||||||
type="text"
|
|
||||||
value={diskId}
|
|
||||||
onChange={(e) => onChange(e.target.value)}
|
|
||||||
onKeyDown={(e) => e.key === "Enter" && onVerify()}
|
|
||||||
placeholder={t("disk_id_placeholder")}
|
|
||||||
className="flex-1 bg-gray-900 border border-white/10 rounded-xl px-4 py-3 text-white placeholder:text-gray-600 focus:outline-none focus:border-amber-500/50 focus:ring-1 focus:ring-amber-500/25 transition-all"
|
|
||||||
/>
|
|
||||||
<button
|
|
||||||
onClick={onVerify}
|
|
||||||
disabled={loading || !diskId.trim()}
|
|
||||||
className="px-6 py-3 bg-amber-500 hover:bg-amber-400 disabled:bg-gray-700 disabled:text-gray-500 text-black font-semibold rounded-xl transition-colors"
|
|
||||||
>
|
|
||||||
{loading ? "..." : t("verify")}
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
{error && (
|
|
||||||
<p className="text-sm text-red-400">{error}</p>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,52 +0,0 @@
|
|||||||
"use client";
|
|
||||||
|
|
||||||
import { useTranslations } from "next-intl";
|
|
||||||
import { StatusBadge } from "./StatusBadge";
|
|
||||||
import type { Payment } from "@/lib/afc";
|
|
||||||
|
|
||||||
interface PaymentHistoryTableProps {
|
|
||||||
payments: Payment[];
|
|
||||||
}
|
|
||||||
|
|
||||||
export function PaymentHistoryTable({ payments }: PaymentHistoryTableProps) {
|
|
||||||
const t = useTranslations("afc");
|
|
||||||
|
|
||||||
if (payments.length === 0) {
|
|
||||||
return (
|
|
||||||
<p className="text-center text-gray-500 py-8">{t("no_payments")}</p>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="overflow-x-auto">
|
|
||||||
<table className="w-full text-sm">
|
|
||||||
<thead>
|
|
||||||
<tr className="border-b border-white/10 text-gray-400">
|
|
||||||
<th className="text-left py-3 px-2 font-medium">{t("date")}</th>
|
|
||||||
<th className="text-right py-3 px-2 font-medium">AFC</th>
|
|
||||||
<th className="text-right py-3 px-2 font-medium">MXN</th>
|
|
||||||
<th className="text-center py-3 px-2 font-medium">{t("status")}</th>
|
|
||||||
</tr>
|
|
||||||
</thead>
|
|
||||||
<tbody>
|
|
||||||
{payments.map((p) => (
|
|
||||||
<tr key={p.id} className="border-b border-white/5 hover:bg-white/[0.02]">
|
|
||||||
<td className="py-3 px-2 text-gray-300">
|
|
||||||
{new Date(p.created_at).toLocaleDateString()}
|
|
||||||
</td>
|
|
||||||
<td className="py-3 px-2 text-right text-amber-400 font-medium">
|
|
||||||
+{p.amount_afc}
|
|
||||||
</td>
|
|
||||||
<td className="py-3 px-2 text-right text-gray-400">
|
|
||||||
${p.amount_mxn}
|
|
||||||
</td>
|
|
||||||
<td className="py-3 px-2 text-center">
|
|
||||||
<StatusBadge status={p.status} />
|
|
||||||
</td>
|
|
||||||
</tr>
|
|
||||||
))}
|
|
||||||
</tbody>
|
|
||||||
</table>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,41 +0,0 @@
|
|||||||
"use client";
|
|
||||||
|
|
||||||
interface PrizeCardProps {
|
|
||||||
icon: string;
|
|
||||||
brand: string;
|
|
||||||
label: string;
|
|
||||||
costAfc: number;
|
|
||||||
valueMxn: number;
|
|
||||||
disabled?: boolean;
|
|
||||||
onSelect: () => void;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function PrizeCard({
|
|
||||||
icon,
|
|
||||||
brand,
|
|
||||||
label,
|
|
||||||
costAfc,
|
|
||||||
valueMxn,
|
|
||||||
disabled,
|
|
||||||
onSelect,
|
|
||||||
}: PrizeCardProps) {
|
|
||||||
return (
|
|
||||||
<button
|
|
||||||
onClick={onSelect}
|
|
||||||
disabled={disabled}
|
|
||||||
className="group block w-full text-left bg-gray-900 rounded-2xl p-5 border border-white/5 hover:border-amber-500/30 transition-all duration-200 hover:scale-[1.02] active:scale-[0.98] disabled:opacity-40 disabled:pointer-events-none"
|
|
||||||
>
|
|
||||||
<div className="flex items-center gap-4">
|
|
||||||
<span className="text-3xl">{icon}</span>
|
|
||||||
<div className="flex-1 min-w-0">
|
|
||||||
<p className="text-white font-semibold">{brand}</p>
|
|
||||||
<p className="text-gray-500 text-sm truncate">{label}</p>
|
|
||||||
</div>
|
|
||||||
<div className="text-right shrink-0">
|
|
||||||
<p className="text-lg font-bold text-amber-400">{costAfc} AFC</p>
|
|
||||||
<p className="text-xs text-gray-500">${valueMxn} MXN</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</button>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,82 +0,0 @@
|
|||||||
"use client";
|
|
||||||
|
|
||||||
import { useState } from "react";
|
|
||||||
import { useTranslations } from "next-intl";
|
|
||||||
|
|
||||||
interface RedeemFormProps {
|
|
||||||
prizeType: string;
|
|
||||||
prizeDetail: string;
|
|
||||||
costAfc: number;
|
|
||||||
onSubmit: (deliveryInfo: string) => void;
|
|
||||||
onCancel: () => void;
|
|
||||||
loading: boolean;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function RedeemForm({
|
|
||||||
prizeType,
|
|
||||||
prizeDetail,
|
|
||||||
costAfc,
|
|
||||||
onSubmit,
|
|
||||||
onCancel,
|
|
||||||
loading,
|
|
||||||
}: RedeemFormProps) {
|
|
||||||
const t = useTranslations("afc");
|
|
||||||
const [deliveryInfo, setDeliveryInfo] = useState("");
|
|
||||||
|
|
||||||
const isBankTransfer = prizeType === "bank_transfer";
|
|
||||||
const isMercadoPago = prizeType === "mercadopago";
|
|
||||||
|
|
||||||
const placeholder = isBankTransfer
|
|
||||||
? t("clabe_placeholder")
|
|
||||||
: isMercadoPago
|
|
||||||
? t("mp_account_placeholder")
|
|
||||||
: t("delivery_placeholder");
|
|
||||||
|
|
||||||
const label = isBankTransfer
|
|
||||||
? t("clabe_label")
|
|
||||||
: isMercadoPago
|
|
||||||
? t("mp_account_label")
|
|
||||||
: t("delivery_label");
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="bg-gray-900 border border-white/10 rounded-2xl p-6 space-y-5">
|
|
||||||
<div className="flex items-center justify-between">
|
|
||||||
<div>
|
|
||||||
<h3 className="text-lg font-semibold text-white">{prizeDetail}</h3>
|
|
||||||
<p className="text-sm text-amber-400">{costAfc} AFC</p>
|
|
||||||
</div>
|
|
||||||
<button
|
|
||||||
onClick={onCancel}
|
|
||||||
className="text-sm text-gray-500 hover:text-gray-300 transition-colors"
|
|
||||||
>
|
|
||||||
{t("cancel")}
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="space-y-2">
|
|
||||||
<label className="block text-sm font-medium text-gray-400">
|
|
||||||
{label}
|
|
||||||
</label>
|
|
||||||
<input
|
|
||||||
type="text"
|
|
||||||
value={deliveryInfo}
|
|
||||||
onChange={(e) => setDeliveryInfo(e.target.value)}
|
|
||||||
placeholder={placeholder}
|
|
||||||
className="w-full bg-gray-800 border border-white/10 rounded-xl px-4 py-3 text-white placeholder:text-gray-600 focus:outline-none focus:border-amber-500/50 focus:ring-1 focus:ring-amber-500/25 transition-all"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="bg-amber-500/10 border border-amber-500/20 rounded-xl p-4 text-sm text-amber-300/80">
|
|
||||||
{t("redeem_warning")}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<button
|
|
||||||
onClick={() => onSubmit(deliveryInfo)}
|
|
||||||
disabled={loading || !deliveryInfo.trim()}
|
|
||||||
className="w-full py-3 bg-amber-500 hover:bg-amber-400 disabled:bg-gray-700 disabled:text-gray-500 text-black font-bold rounded-xl transition-colors"
|
|
||||||
>
|
|
||||||
{loading ? t("processing") : t("confirm_redeem")}
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,52 +0,0 @@
|
|||||||
"use client";
|
|
||||||
|
|
||||||
import { useTranslations } from "next-intl";
|
|
||||||
import { StatusBadge } from "./StatusBadge";
|
|
||||||
import type { Redemption } from "@/lib/afc";
|
|
||||||
|
|
||||||
interface RedemptionHistoryTableProps {
|
|
||||||
redemptions: Redemption[];
|
|
||||||
}
|
|
||||||
|
|
||||||
export function RedemptionHistoryTable({ redemptions }: RedemptionHistoryTableProps) {
|
|
||||||
const t = useTranslations("afc");
|
|
||||||
|
|
||||||
if (redemptions.length === 0) {
|
|
||||||
return (
|
|
||||||
<p className="text-center text-gray-500 py-8">{t("no_redemptions")}</p>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="overflow-x-auto">
|
|
||||||
<table className="w-full text-sm">
|
|
||||||
<thead>
|
|
||||||
<tr className="border-b border-white/10 text-gray-400">
|
|
||||||
<th className="text-left py-3 px-2 font-medium">{t("date")}</th>
|
|
||||||
<th className="text-left py-3 px-2 font-medium">{t("prize")}</th>
|
|
||||||
<th className="text-right py-3 px-2 font-medium">AFC</th>
|
|
||||||
<th className="text-center py-3 px-2 font-medium">{t("status")}</th>
|
|
||||||
</tr>
|
|
||||||
</thead>
|
|
||||||
<tbody>
|
|
||||||
{redemptions.map((r) => (
|
|
||||||
<tr key={r.id} className="border-b border-white/5 hover:bg-white/[0.02]">
|
|
||||||
<td className="py-3 px-2 text-gray-300">
|
|
||||||
{new Date(r.created_at).toLocaleDateString()}
|
|
||||||
</td>
|
|
||||||
<td className="py-3 px-2 text-white">
|
|
||||||
{r.prize_detail}
|
|
||||||
</td>
|
|
||||||
<td className="py-3 px-2 text-right text-red-400 font-medium">
|
|
||||||
-{r.amount_afc}
|
|
||||||
</td>
|
|
||||||
<td className="py-3 px-2 text-center">
|
|
||||||
<StatusBadge status={r.status} />
|
|
||||||
</td>
|
|
||||||
</tr>
|
|
||||||
))}
|
|
||||||
</tbody>
|
|
||||||
</table>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,26 +0,0 @@
|
|||||||
"use client";
|
|
||||||
|
|
||||||
interface StatusBadgeProps {
|
|
||||||
status: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
const STATUS_STYLES: Record<string, string> = {
|
|
||||||
pending: "bg-yellow-500/15 text-yellow-400 border-yellow-500/30",
|
|
||||||
completed: "bg-green-500/15 text-green-400 border-green-500/30",
|
|
||||||
approved: "bg-green-500/15 text-green-400 border-green-500/30",
|
|
||||||
fulfilled: "bg-green-500/15 text-green-400 border-green-500/30",
|
|
||||||
rejected: "bg-red-500/15 text-red-400 border-red-500/30",
|
|
||||||
failed: "bg-red-500/15 text-red-400 border-red-500/30",
|
|
||||||
};
|
|
||||||
|
|
||||||
const DEFAULT_STYLE = "bg-gray-500/15 text-gray-400 border-gray-500/30";
|
|
||||||
|
|
||||||
export function StatusBadge({ status }: StatusBadgeProps) {
|
|
||||||
const style = STATUS_STYLES[status] || DEFAULT_STYLE;
|
|
||||||
|
|
||||||
return (
|
|
||||||
<span className={`inline-block px-2.5 py-0.5 rounded-full text-xs font-medium border ${style}`}>
|
|
||||||
{status}
|
|
||||||
</span>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
8
apps/web/src/components/auth/AuthProvider.tsx
Normal file
8
apps/web/src/components/auth/AuthProvider.tsx
Normal file
@@ -0,0 +1,8 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { SessionProvider } from "next-auth/react";
|
||||||
|
import { ReactNode } from "react";
|
||||||
|
|
||||||
|
export function AuthProvider({ children }: { children: ReactNode }) {
|
||||||
|
return <SessionProvider>{children}</SessionProvider>;
|
||||||
|
}
|
||||||
58
apps/web/src/components/auth/LoginForm.tsx
Normal file
58
apps/web/src/components/auth/LoginForm.tsx
Normal file
@@ -0,0 +1,58 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { signIn } from "next-auth/react";
|
||||||
|
import { useState } from "react";
|
||||||
|
|
||||||
|
export function LoginForm() {
|
||||||
|
const [isLoading, setIsLoading] = useState(false);
|
||||||
|
|
||||||
|
const handleLogin = async () => {
|
||||||
|
setIsLoading(true);
|
||||||
|
await signIn("authentik", { callbackUrl: "/es" });
|
||||||
|
setIsLoading(false);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="bg-[#12121a] border border-[rgba(255,255,255,0.08)] rounded-2xl p-8 shadow-xl">
|
||||||
|
<div className="text-center mb-8">
|
||||||
|
<h1 className="text-2xl font-display font-bold text-[#f5f5f7]">
|
||||||
|
Project Afterlife
|
||||||
|
</h1>
|
||||||
|
<p className="mt-2 text-[#a0a0a8] text-sm">
|
||||||
|
Inicia sesión para acceder a tu cuenta
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<button
|
||||||
|
onClick={handleLogin}
|
||||||
|
disabled={isLoading}
|
||||||
|
className="w-full flex items-center justify-center gap-3 bg-[#d4a574] text-[#0a0a0f] font-medium py-3 px-4 rounded-xl hover:bg-[#e8c4a0] transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
|
||||||
|
>
|
||||||
|
{isLoading ? (
|
||||||
|
<span className="animate-spin h-5 w-5 border-2 border-[#a0a0a8] border-t-[#0a0a0f] rounded-full" />
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<svg
|
||||||
|
className="w-5 h-5"
|
||||||
|
fill="none"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
stroke="currentColor"
|
||||||
|
strokeWidth={2}
|
||||||
|
>
|
||||||
|
<path
|
||||||
|
strokeLinecap="round"
|
||||||
|
strokeLinejoin="round"
|
||||||
|
d="M16 7a4 4 0 11-8 0 4 4 0 018 0zM12 14a7 7 0 00-7 7h14a7 7 0 00-7-7z"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
Iniciar sesión con Authentik
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<p className="mt-6 text-center text-xs text-[#6b6b75]">
|
||||||
|
Al iniciar sesión, aceptas nuestros términos y condiciones.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
105
apps/web/src/components/auth/ProfileCard.tsx
Normal file
105
apps/web/src/components/auth/ProfileCard.tsx
Normal file
@@ -0,0 +1,105 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { signOut } from "next-auth/react";
|
||||||
|
import { useState } from "react";
|
||||||
|
|
||||||
|
interface ProfileCardProps {
|
||||||
|
user: {
|
||||||
|
name?: string | null;
|
||||||
|
email?: string | null;
|
||||||
|
image?: string | null;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function ProfileCard({ user }: ProfileCardProps) {
|
||||||
|
const [isSigningOut, setIsSigningOut] = useState(false);
|
||||||
|
|
||||||
|
const handleSignOut = async () => {
|
||||||
|
setIsSigningOut(true);
|
||||||
|
await signOut({ callbackUrl: "/es" });
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="bg-[#12121a] border border-[rgba(255,255,255,0.08)] rounded-2xl p-8 shadow-xl">
|
||||||
|
<div className="flex items-center gap-4 mb-6">
|
||||||
|
{user.image ? (
|
||||||
|
<img
|
||||||
|
src={user.image}
|
||||||
|
alt={user.name || "User"}
|
||||||
|
className="w-16 h-16 rounded-full object-cover border-2 border-[rgba(255,255,255,0.08)]"
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<div className="w-16 h-16 rounded-full bg-[#1a1a24] border-2 border-[rgba(255,255,255,0.08)] flex items-center justify-center">
|
||||||
|
<svg
|
||||||
|
className="w-8 h-8 text-[#a0a0a8]"
|
||||||
|
fill="none"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
stroke="currentColor"
|
||||||
|
strokeWidth={1.5}
|
||||||
|
>
|
||||||
|
<path
|
||||||
|
strokeLinecap="round"
|
||||||
|
strokeLinejoin="round"
|
||||||
|
d="M15.75 6a3.75 3.75 0 11-7.5 0 3.75 3.75 0 017.5 0zM4.501 20.118a7.5 7.5 0 0114.998 0A17.933 17.933 0 0112 21.75c-2.676 0-5.216-.584-7.499-1.632z"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<div>
|
||||||
|
<h1 className="text-xl font-display font-bold text-[#f5f5f7]">
|
||||||
|
{user.name || "Usuario"}
|
||||||
|
</h1>
|
||||||
|
<p className="text-[#a0a0a8] text-sm">{user.email}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="border-t border-[rgba(255,255,255,0.08)] pt-6">
|
||||||
|
<h2 className="text-sm font-semibold text-[#a0a0a8] uppercase tracking-wider mb-4">
|
||||||
|
Tu cuenta
|
||||||
|
</h2>
|
||||||
|
<div className="space-y-3">
|
||||||
|
<div className="flex items-center justify-between py-2 px-3 rounded-lg bg-[#1a1a24]">
|
||||||
|
<span className="text-[#a0a0a8] text-sm">Método de inicio</span>
|
||||||
|
<span className="text-[#f5f5f7] text-sm font-medium">Authentik SSO</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center justify-between py-2 px-3 rounded-lg bg-[#1a1a24]">
|
||||||
|
<span className="text-[#a0a0a8] text-sm">Estado</span>
|
||||||
|
<span className="text-emerald-400 text-sm font-medium flex items-center gap-1.5">
|
||||||
|
<span className="w-1.5 h-1.5 rounded-full bg-emerald-400" />
|
||||||
|
Activo
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="mt-8">
|
||||||
|
<button
|
||||||
|
onClick={handleSignOut}
|
||||||
|
disabled={isSigningOut}
|
||||||
|
className="w-full flex items-center justify-center gap-2 bg-red-500/10 text-red-400 border border-red-500/20 font-medium py-2.5 px-4 rounded-xl hover:bg-red-500/20 transition-colors disabled:opacity-50"
|
||||||
|
>
|
||||||
|
{isSigningOut ? (
|
||||||
|
<span className="animate-spin h-4 w-4 border-2 border-red-400 border-t-transparent rounded-full" />
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<svg
|
||||||
|
className="w-4 h-4"
|
||||||
|
fill="none"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
stroke="currentColor"
|
||||||
|
strokeWidth={2}
|
||||||
|
>
|
||||||
|
<path
|
||||||
|
strokeLinecap="round"
|
||||||
|
strokeLinejoin="round"
|
||||||
|
d="M15.75 9V5.25A2.25 2.25 0 0013.5 3h-6a2.25 2.25 0 00-2.25 2.25v13.5A2.25 2.25 0 007.5 21h6a2.25 2.25 0 002.25-2.25V15m3 0l3-3m0 0l-3-3m3 3H9"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
Cerrar sesión
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
126
apps/web/src/components/bookmark/BookmarkButton.tsx
Normal file
126
apps/web/src/components/bookmark/BookmarkButton.tsx
Normal file
@@ -0,0 +1,126 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useState, useEffect } from "react";
|
||||||
|
import { useToast } from "@/hooks/useToast";
|
||||||
|
|
||||||
|
interface BookmarkButtonProps {
|
||||||
|
docId: number;
|
||||||
|
chapterId: number;
|
||||||
|
chapterTitle: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
function BookmarkIcon({ className, filled }: { className?: string; filled?: boolean }) {
|
||||||
|
return (
|
||||||
|
<svg
|
||||||
|
className={className}
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
fill={filled ? "currentColor" : "none"}
|
||||||
|
stroke="currentColor"
|
||||||
|
strokeWidth="2"
|
||||||
|
strokeLinecap="round"
|
||||||
|
strokeLinejoin="round"
|
||||||
|
>
|
||||||
|
<path d="M19 21l-7-5-7 5V5a2 2 0 0 1 2-2h10a2 2 0 0 1 2 2z" />
|
||||||
|
</svg>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function getBookmarks(): Array<{ docId: number; chapterId: number; title: string; date: string }> {
|
||||||
|
try {
|
||||||
|
const stored = localStorage.getItem("bookmarks");
|
||||||
|
return stored ? JSON.parse(stored) : [];
|
||||||
|
} catch {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function saveBookmarks(bookmarks: Array<{ docId: number; chapterId: number; title: string; date: string }>) {
|
||||||
|
try {
|
||||||
|
localStorage.setItem("bookmarks", JSON.stringify(bookmarks));
|
||||||
|
} catch {
|
||||||
|
// ignore
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function BookmarkButton({ docId, chapterId, chapterTitle }: BookmarkButtonProps) {
|
||||||
|
const toast = useToast();
|
||||||
|
const [isBookmarked, setIsBookmarked] = useState(false);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const bookmarks = getBookmarks();
|
||||||
|
setIsBookmarked(bookmarks.some((b) => b.docId === docId && b.chapterId === chapterId));
|
||||||
|
}, [docId, chapterId]);
|
||||||
|
|
||||||
|
function toggleBookmark() {
|
||||||
|
const bookmarks = getBookmarks();
|
||||||
|
const index = bookmarks.findIndex((b) => b.docId === docId && b.chapterId === chapterId);
|
||||||
|
|
||||||
|
if (index !== -1) {
|
||||||
|
bookmarks.splice(index, 1);
|
||||||
|
saveBookmarks(bookmarks);
|
||||||
|
setIsBookmarked(false);
|
||||||
|
toast.success("Bookmark removed");
|
||||||
|
} else {
|
||||||
|
bookmarks.push({ docId, chapterId, title: chapterTitle, date: new Date().toISOString() });
|
||||||
|
saveBookmarks(bookmarks);
|
||||||
|
setIsBookmarked(true);
|
||||||
|
toast.success("Chapter bookmarked");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
onClick={toggleBookmark}
|
||||||
|
className={`p-2 rounded-lg transition-all duration-200 ${
|
||||||
|
isBookmarked
|
||||||
|
? "text-[#d4a574] bg-[rgba(212,165,116,0.1)] hover:bg-[rgba(212,165,116,0.2)]"
|
||||||
|
: "text-[#6b6b75] hover:text-[#d4a574] hover:bg-[rgba(212,165,116,0.08)]"
|
||||||
|
}`}
|
||||||
|
title={isBookmarked ? "Remove bookmark" : "Bookmark chapter"}
|
||||||
|
aria-label={isBookmarked ? "Remove bookmark" : "Bookmark chapter"}
|
||||||
|
>
|
||||||
|
<BookmarkIcon className="w-4 h-4" filled={isBookmarked} />
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function BookmarksList() {
|
||||||
|
const [bookmarks, setBookmarks] = useState<Array<{ docId: number; chapterId: number; title: string; date: string }>>([]);
|
||||||
|
const [mounted, setMounted] = useState(false);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
setMounted(true);
|
||||||
|
setBookmarks(getBookmarks());
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
function removeBookmark(docId: number, chapterId: number) {
|
||||||
|
const updated = bookmarks.filter((b) => !(b.docId === docId && b.chapterId === chapterId));
|
||||||
|
saveBookmarks(updated);
|
||||||
|
setBookmarks(updated);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!mounted) return null;
|
||||||
|
if (bookmarks.length === 0) return null;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="mt-8 p-4 rounded-xl bg-[#12121a] border border-[rgba(255,255,255,0.08)]">
|
||||||
|
<h3 className="text-sm font-semibold text-[#a0a0a8] uppercase tracking-wider mb-3">Bookmarks</h3>
|
||||||
|
<ul className="space-y-2">
|
||||||
|
{bookmarks.map((b) => (
|
||||||
|
<li key={`${b.docId}-${b.chapterId}`} className="flex items-center justify-between text-sm">
|
||||||
|
<span className="text-[#a0a0a8] truncate">{b.title}</span>
|
||||||
|
<button
|
||||||
|
onClick={() => removeBookmark(b.docId, b.chapterId)}
|
||||||
|
className="text-[#6b6b75] hover:text-red-400 transition-colors ml-2"
|
||||||
|
>
|
||||||
|
<svg className="w-4 h-4" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round">
|
||||||
|
<line x1="18" y1="6" x2="6" y2="18" />
|
||||||
|
<line x1="6" y1="6" x2="18" y2="18" />
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -3,6 +3,7 @@
|
|||||||
import { useTranslations } from "next-intl";
|
import { useTranslations } from "next-intl";
|
||||||
import { useRouter, useSearchParams, usePathname } from "next/navigation";
|
import { useRouter, useSearchParams, usePathname } from "next/navigation";
|
||||||
import type { Genre, ServerStatus } from "@afterlife/shared";
|
import type { Genre, ServerStatus } from "@afterlife/shared";
|
||||||
|
import { SearchInput } from "@/components/search/SearchInput";
|
||||||
|
|
||||||
const GENRES: Genre[] = ["MMORPG", "FPS", "Casual", "Strategy", "Sports", "Other"];
|
const GENRES: Genre[] = ["MMORPG", "FPS", "Casual", "Strategy", "Sports", "Other"];
|
||||||
const STATUSES: ServerStatus[] = ["online", "maintenance", "coming_soon"];
|
const STATUSES: ServerStatus[] = ["online", "maintenance", "coming_soon"];
|
||||||
@@ -28,21 +29,24 @@ export function CatalogFilters() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex flex-wrap gap-4 mb-8">
|
<div className="flex flex-col sm:flex-row flex-wrap gap-4 mb-8">
|
||||||
|
<SearchInput placeholder={t("search_placeholder")} />
|
||||||
|
|
||||||
<select
|
<select
|
||||||
value={currentGenre}
|
value={currentGenre}
|
||||||
onChange={(e) => setFilter("genre", e.target.value)}
|
onChange={(e) => setFilter("genre", e.target.value)}
|
||||||
className="bg-gray-900 border border-white/10 rounded-lg px-4 py-2 text-sm text-white"
|
className="bg-[#12121a] border border-[rgba(255,255,255,0.08)] rounded-lg px-4 py-2 text-sm text-[#f5f5f7] focus:outline-none focus:border-[rgba(212,165,116,0.4)] transition-colors"
|
||||||
>
|
>
|
||||||
<option value="">{t("filter_genre")}: {t("all")}</option>
|
<option value="">{t("filter_genre")}: {t("all")}</option>
|
||||||
{GENRES.map((g) => (
|
{GENRES.map((g) => (
|
||||||
<option key={g} value={g}>{g}</option>
|
<option key={g} value={g}>{g}</option>
|
||||||
))}
|
))}
|
||||||
</select>
|
</select>
|
||||||
|
|
||||||
<select
|
<select
|
||||||
value={currentStatus}
|
value={currentStatus}
|
||||||
onChange={(e) => setFilter("status", e.target.value)}
|
onChange={(e) => setFilter("status", e.target.value)}
|
||||||
className="bg-gray-900 border border-white/10 rounded-lg px-4 py-2 text-sm text-white"
|
className="bg-[#12121a] border border-[rgba(255,255,255,0.08)] rounded-lg px-4 py-2 text-sm text-[#f5f5f7] focus:outline-none focus:border-[rgba(212,165,116,0.4)] transition-colors"
|
||||||
>
|
>
|
||||||
<option value="">{t("filter_status")}: {t("all")}</option>
|
<option value="">{t("filter_status")}: {t("all")}</option>
|
||||||
{STATUSES.map((s) => (
|
{STATUSES.map((s) => (
|
||||||
|
|||||||
@@ -1,4 +1,7 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
import { useTranslations } from "next-intl";
|
import { useTranslations } from "next-intl";
|
||||||
|
import { useSearchParams } from "next/navigation";
|
||||||
import type { Game } from "@afterlife/shared";
|
import type { Game } from "@afterlife/shared";
|
||||||
import { GameCard } from "../shared/GameCard";
|
import { GameCard } from "../shared/GameCard";
|
||||||
|
|
||||||
@@ -9,18 +12,51 @@ interface CatalogGridProps {
|
|||||||
|
|
||||||
export function CatalogGrid({ games, locale }: CatalogGridProps) {
|
export function CatalogGrid({ games, locale }: CatalogGridProps) {
|
||||||
const t = useTranslations("catalog");
|
const t = useTranslations("catalog");
|
||||||
|
const searchParams = useSearchParams();
|
||||||
|
|
||||||
|
const genreFilter = searchParams.get("genre") || "";
|
||||||
|
const statusFilter = searchParams.get("status") || "";
|
||||||
|
const searchQuery = searchParams.get("search") || "";
|
||||||
|
|
||||||
|
const filtered = games.filter((game) => {
|
||||||
|
if (genreFilter && game.genre !== genreFilter) return false;
|
||||||
|
if (statusFilter && game.serverStatus !== statusFilter) return false;
|
||||||
|
if (searchQuery) {
|
||||||
|
const q = searchQuery.toLowerCase();
|
||||||
|
const matchTitle = game.title.toLowerCase().includes(q);
|
||||||
|
const matchDev = game.developer?.toLowerCase().includes(q);
|
||||||
|
const matchGenre = game.genre?.toLowerCase().includes(q);
|
||||||
|
if (!matchTitle && !matchDev && !matchGenre) return false;
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
});
|
||||||
|
|
||||||
if (games.length === 0) {
|
if (games.length === 0) {
|
||||||
return (
|
return (
|
||||||
<div className="text-center py-20">
|
<div className="text-center py-20">
|
||||||
<p className="text-gray-500 text-lg">{t("no_results")}</p>
|
<p className="text-lg" style={{ color: "var(--text-muted)" }}>
|
||||||
|
{t("no_results")}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (filtered.length === 0) {
|
||||||
|
return (
|
||||||
|
<div className="text-center py-20">
|
||||||
|
<p className="text-lg mb-2" style={{ color: "var(--text-muted)" }}>
|
||||||
|
{t("no_results")}
|
||||||
|
</p>
|
||||||
|
<p className="text-sm" style={{ color: "var(--text-muted)" }}>
|
||||||
|
Try adjusting your filters or search query.
|
||||||
|
</p>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
|
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
|
||||||
{games.map((game) => (
|
{filtered.map((game) => (
|
||||||
<GameCard key={game.id} game={game} locale={locale} />
|
<GameCard key={game.id} game={game} locale={locale} />
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
18
apps/web/src/components/catalog/CatalogSkeleton.tsx
Normal file
18
apps/web/src/components/catalog/CatalogSkeleton.tsx
Normal file
@@ -0,0 +1,18 @@
|
|||||||
|
import { GameCardSkeleton } from "../shared/GameCardSkeleton";
|
||||||
|
|
||||||
|
export function CatalogSkeleton() {
|
||||||
|
return (
|
||||||
|
<div className="space-y-8">
|
||||||
|
<div className="flex flex-col sm:flex-row flex-wrap gap-4">
|
||||||
|
<div className="flex-1 min-w-[200px] h-10 bg-[#1a1a24] rounded-lg animate-pulse" />
|
||||||
|
<div className="w-40 h-10 bg-[#1a1a24] rounded-lg animate-pulse" />
|
||||||
|
<div className="w-40 h-10 bg-[#1a1a24] rounded-lg animate-pulse" />
|
||||||
|
</div>
|
||||||
|
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
|
||||||
|
{Array.from({ length: 6 }).map((_, i) => (
|
||||||
|
<GameCardSkeleton key={i} />
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -42,11 +42,11 @@ export function AudioPlayer({
|
|||||||
const progressPercent = duration > 0 ? (progress / duration) * 100 : 0;
|
const progressPercent = duration > 0 ? (progress / duration) * 100 : 0;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="fixed bottom-0 left-0 right-0 z-50 bg-gray-900/95 backdrop-blur-sm border-t border-white/10">
|
<div className="fixed bottom-0 left-0 right-0 z-50 bg-[#0a0a0f]/95 backdrop-blur-sm border-t border-[rgba(255,255,255,0.08)]">
|
||||||
<div className="max-w-7xl mx-auto px-4 py-3">
|
<div className="max-w-7xl mx-auto px-4 py-3">
|
||||||
{/* Progress bar */}
|
{/* Progress bar */}
|
||||||
<div
|
<div
|
||||||
className="w-full h-1 bg-gray-700 rounded-full mb-3 cursor-pointer"
|
className="w-full h-1 bg-[#1a1a24] rounded-full mb-3 cursor-pointer"
|
||||||
onClick={(e) => {
|
onClick={(e) => {
|
||||||
const rect = e.currentTarget.getBoundingClientRect();
|
const rect = e.currentTarget.getBoundingClientRect();
|
||||||
const ratio = (e.clientX - rect.left) / rect.width;
|
const ratio = (e.clientX - rect.left) / rect.width;
|
||||||
@@ -54,7 +54,7 @@ export function AudioPlayer({
|
|||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<div
|
<div
|
||||||
className="h-full bg-blue-500 rounded-full transition-all"
|
className="h-full bg-[#d4a574] rounded-full transition-all"
|
||||||
style={{ width: `${progressPercent}%` }}
|
style={{ width: `${progressPercent}%` }}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
@@ -63,7 +63,7 @@ export function AudioPlayer({
|
|||||||
{/* Play/Pause */}
|
{/* Play/Pause */}
|
||||||
<button
|
<button
|
||||||
onClick={onToggle}
|
onClick={onToggle}
|
||||||
className="w-10 h-10 flex items-center justify-center bg-white rounded-full text-black hover:bg-gray-200 transition-colors"
|
className="w-10 h-10 flex items-center justify-center bg-[#d4a574] rounded-full text-[#0a0a0f] hover:bg-[#e8c4a0] transition-colors"
|
||||||
aria-label={isPlaying ? t("pause") : t("play")}
|
aria-label={isPlaying ? t("pause") : t("play")}
|
||||||
>
|
>
|
||||||
{isPlaying ? "\u23F8" : "\u25B6"}
|
{isPlaying ? "\u23F8" : "\u25B6"}
|
||||||
@@ -71,19 +71,19 @@ export function AudioPlayer({
|
|||||||
|
|
||||||
{/* Track info */}
|
{/* Track info */}
|
||||||
<div className="flex-1 min-w-0">
|
<div className="flex-1 min-w-0">
|
||||||
<p className="text-sm text-white truncate">{trackTitle}</p>
|
<p className="text-sm text-[#f5f5f7] truncate">{trackTitle}</p>
|
||||||
<p className="text-xs text-gray-500">
|
<p className="text-xs text-[#6b6b75]">
|
||||||
{formatTime(progress)} / {formatTime(duration)}
|
{formatTime(progress)} / {formatTime(duration)}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Speed selector */}
|
{/* Speed selector */}
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<span className="text-xs text-gray-500">{t("speed")}:</span>
|
<span className="text-xs text-[#6b6b75]">{t("speed")}:</span>
|
||||||
<select
|
<select
|
||||||
value={playbackRate}
|
value={playbackRate}
|
||||||
onChange={(e) => onChangeRate(Number(e.target.value))}
|
onChange={(e) => onChangeRate(Number(e.target.value))}
|
||||||
className="bg-gray-800 border border-white/10 rounded px-2 py-1 text-xs text-white"
|
className="bg-[#12121a] border border-[rgba(255,255,255,0.08)] rounded px-2 py-1 text-xs text-[#f5f5f7]"
|
||||||
>
|
>
|
||||||
{RATES.map((r) => (
|
{RATES.map((r) => (
|
||||||
<option key={r} value={r}>
|
<option key={r} value={r}>
|
||||||
@@ -98,8 +98,8 @@ export function AudioPlayer({
|
|||||||
onClick={onToggleContinuous}
|
onClick={onToggleContinuous}
|
||||||
className={`text-xs px-3 py-1 rounded border transition-colors ${
|
className={`text-xs px-3 py-1 rounded border transition-colors ${
|
||||||
continuousMode
|
continuousMode
|
||||||
? "border-blue-500 text-blue-400"
|
? "border-[#d4a574] text-[#d4a574]"
|
||||||
: "border-white/10 text-gray-500 hover:text-white"
|
: "border-[rgba(255,255,255,0.08)] text-[#6b6b75] hover:text-[#a0a0a8]"
|
||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
{continuousMode ? t("continuous_mode") : t("chapter_mode")}
|
{continuousMode ? t("continuous_mode") : t("chapter_mode")}
|
||||||
|
|||||||
@@ -1,23 +1,106 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useEffect, useState } from "react";
|
||||||
import Image from "next/image";
|
import Image from "next/image";
|
||||||
import type { Chapter } from "@afterlife/shared";
|
import type { Chapter } from "@afterlife/shared";
|
||||||
import { formatTextToHtml } from "@/lib/format";
|
import { formatTextToHtml } from "@/lib/format";
|
||||||
|
import { useToast } from "@/hooks/useToast";
|
||||||
|
import { getImageUrl } from "@/lib/images";
|
||||||
|
import { BookmarkButton } from "@/components/bookmark/BookmarkButton";
|
||||||
|
|
||||||
interface ChapterContentProps {
|
interface ChapterContentProps {
|
||||||
chapter: Chapter;
|
chapter: Chapter;
|
||||||
|
readingMode?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function ChapterContent({ chapter }: ChapterContentProps) {
|
function estimateReadingTime(content: string): number {
|
||||||
|
const words = content.trim().split(/\s+/).length;
|
||||||
|
return Math.max(1, Math.round(words / 200));
|
||||||
|
}
|
||||||
|
|
||||||
|
export function ChapterContent({
|
||||||
|
chapter,
|
||||||
|
readingMode = false,
|
||||||
|
}: ChapterContentProps) {
|
||||||
|
const [visible, setVisible] = useState(false);
|
||||||
|
const toast = useToast();
|
||||||
|
|
||||||
|
// Fade in when chapter enters viewport
|
||||||
|
useEffect(() => {
|
||||||
|
const el = document.getElementById(`chapter-${chapter.id}`);
|
||||||
|
if (!el) return;
|
||||||
|
|
||||||
|
const observer = new IntersectionObserver(
|
||||||
|
([entry]) => {
|
||||||
|
if (entry.isIntersecting) {
|
||||||
|
setVisible(true);
|
||||||
|
observer.disconnect();
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{ threshold: 0.05 }
|
||||||
|
);
|
||||||
|
observer.observe(el);
|
||||||
|
return () => observer.disconnect();
|
||||||
|
}, [chapter.id]);
|
||||||
|
|
||||||
|
async function copyLink() {
|
||||||
|
try {
|
||||||
|
const url = `${window.location.origin}${window.location.pathname}#chapter-${chapter.id}`;
|
||||||
|
await navigator.clipboard.writeText(url);
|
||||||
|
toast.success("Link copied to clipboard");
|
||||||
|
} catch {
|
||||||
|
toast.error("Failed to copy link");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const readingTime = estimateReadingTime(chapter.content);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<article className="max-w-2xl mx-auto">
|
<article
|
||||||
|
id={`chapter-${chapter.id}`}
|
||||||
|
className={`max-w-2xl mx-auto transition-all duration-500 ${
|
||||||
|
visible ? "opacity-100 translate-y-0" : "opacity-0 translate-y-4"
|
||||||
|
}`}
|
||||||
|
>
|
||||||
{/* Chapter indicator */}
|
{/* Chapter indicator */}
|
||||||
<div className="mb-10">
|
<div className="mb-10">
|
||||||
<div className="flex items-center gap-4 mb-4">
|
<div className="flex items-center gap-4 mb-4">
|
||||||
<span className="text-amber-500/80 font-display text-sm tracking-[0.2em]">
|
<span className="text-[#d4a574]/80 font-display text-sm tracking-[0.2em]">
|
||||||
{String(chapter.order).padStart(2, "0")}
|
{String(chapter.order).padStart(2, "0")}
|
||||||
</span>
|
</span>
|
||||||
<div className="h-px flex-1 bg-white/10" />
|
<div className="h-px flex-1 bg-[rgba(255,255,255,0.08)]" />
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<span className="text-xs text-[#6b6b75]">{readingTime} min read</span>
|
||||||
|
<BookmarkButton docId={chapter.id} chapterId={chapter.id} chapterTitle={chapter.title} />
|
||||||
|
<button
|
||||||
|
onClick={copyLink}
|
||||||
|
className="text-xs text-[#6b6b75] hover:text-[#a0a0a8] transition-colors flex items-center gap-1"
|
||||||
|
title="Copy link to chapter"
|
||||||
|
>
|
||||||
|
<svg
|
||||||
|
width="12"
|
||||||
|
height="12"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
fill="none"
|
||||||
|
stroke="currentColor"
|
||||||
|
strokeWidth="2"
|
||||||
|
strokeLinecap="round"
|
||||||
|
strokeLinejoin="round"
|
||||||
|
>
|
||||||
|
<path d="M10 13a5 5 0 0 0 7.54.54l3-3a5 5 0 0 0-7.07-7.07l-1.72 1.71" />
|
||||||
|
<path d="M14 11a5 5 0 0 0-7.54-.54l-3 3a5 5 0 0 0 7.07 7.07l1.71-1.71" />
|
||||||
|
</svg>
|
||||||
|
Copy link
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<h2 className="text-3xl sm:text-4xl font-display font-bold leading-tight tracking-tight">
|
<h2
|
||||||
|
className={`font-display font-bold leading-tight tracking-tight ${
|
||||||
|
readingMode ? "text-4xl sm:text-5xl" : "text-3xl sm:text-4xl"
|
||||||
|
}`}
|
||||||
|
>
|
||||||
{chapter.title}
|
{chapter.title}
|
||||||
</h2>
|
</h2>
|
||||||
</div>
|
</div>
|
||||||
@@ -25,18 +108,25 @@ export function ChapterContent({ chapter }: ChapterContentProps) {
|
|||||||
{chapter.coverImage && (
|
{chapter.coverImage && (
|
||||||
<div className="relative aspect-video rounded-lg overflow-hidden mb-10">
|
<div className="relative aspect-video rounded-lg overflow-hidden mb-10">
|
||||||
<Image
|
<Image
|
||||||
src={chapter.coverImage.url}
|
src={getImageUrl(chapter.coverImage.url)}
|
||||||
alt={chapter.coverImage.alternativeText || chapter.title}
|
alt={chapter.coverImage.alternativeText || chapter.title}
|
||||||
fill
|
fill
|
||||||
|
unoptimized
|
||||||
className="object-cover"
|
className="object-cover"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<div
|
<div
|
||||||
className="chapter-prose"
|
className={`chapter-prose transition-all duration-300 ${
|
||||||
dangerouslySetInnerHTML={{ __html: formatTextToHtml(chapter.content) }}
|
readingMode ? "text-lg leading-relaxed" : ""
|
||||||
|
}`}
|
||||||
|
dangerouslySetInnerHTML={{
|
||||||
|
__html: formatTextToHtml(chapter.content),
|
||||||
|
}}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
<div className="mt-16 mb-8 h-px bg-[rgba(255,255,255,0.05)]" />
|
||||||
</article>
|
</article>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -6,46 +6,84 @@ import { useTranslations } from "next-intl";
|
|||||||
interface ChapterNavProps {
|
interface ChapterNavProps {
|
||||||
chapters: Chapter[];
|
chapters: Chapter[];
|
||||||
activeChapterId: number;
|
activeChapterId: number;
|
||||||
onSelectChapter: (id: number, index: number) => void;
|
onSelectChapter: (id: number) => void;
|
||||||
|
chapterProgress: Record<number, number>;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function ChapterNav({
|
export function ChapterNav({
|
||||||
chapters,
|
chapters,
|
||||||
activeChapterId,
|
activeChapterId,
|
||||||
onSelectChapter,
|
onSelectChapter,
|
||||||
|
chapterProgress,
|
||||||
}: ChapterNavProps) {
|
}: ChapterNavProps) {
|
||||||
const t = useTranslations("documentary");
|
const t = useTranslations("documentary");
|
||||||
|
|
||||||
|
function handleClick(id: number) {
|
||||||
|
const el = document.getElementById(`chapter-${id}`);
|
||||||
|
if (el) {
|
||||||
|
el.scrollIntoView({ behavior: "smooth", block: "start" });
|
||||||
|
}
|
||||||
|
onSelectChapter(id);
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<nav className="w-72 flex-shrink-0 hidden lg:block">
|
<nav className="w-72 flex-shrink-0 hidden lg:block">
|
||||||
<div className="sticky top-24">
|
<div className="sticky top-24">
|
||||||
<h3 className="text-xs font-semibold text-gray-500 uppercase tracking-[0.15em] mb-5">
|
<h3 className="text-xs font-semibold text-[#6b6b75] uppercase tracking-[0.15em] mb-5">
|
||||||
{t("chapters")}
|
{t("chapters")}
|
||||||
</h3>
|
</h3>
|
||||||
<ol className="space-y-0.5">
|
<ol className="space-y-0.5">
|
||||||
{chapters.map((chapter, index) => (
|
{chapters.map((chapter, index) => {
|
||||||
<li key={chapter.id}>
|
const progress = chapterProgress[chapter.id] ?? 0;
|
||||||
<button
|
const isCompleted = progress >= 90;
|
||||||
onClick={() => onSelectChapter(chapter.id, index)}
|
const isActive = chapter.id === activeChapterId;
|
||||||
className={`w-full text-left px-3 py-2.5 rounded-lg text-sm transition-all duration-200 ${
|
|
||||||
chapter.id === activeChapterId
|
return (
|
||||||
? "bg-amber-500/10 text-amber-400 font-medium border-l-2 border-amber-500 rounded-l-none"
|
<li key={chapter.id}>
|
||||||
: "text-gray-400 hover:text-gray-200 hover:bg-white/[0.03]"
|
<button
|
||||||
}`}
|
onClick={() => handleClick(chapter.id)}
|
||||||
>
|
className={`w-full text-left px-3 py-2.5 rounded-lg text-sm transition-all duration-200 ${
|
||||||
<span
|
isActive
|
||||||
className={`text-xs mr-2 tabular-nums ${
|
? "bg-[rgba(212,165,116,0.1)] text-[#d4a574] font-medium border-l-2 border-[#d4a574] rounded-l-none"
|
||||||
chapter.id === activeChapterId
|
: "text-[#6b6b75] hover:text-[#a0a0a8] hover:bg-[rgba(255,255,255,0.03)]"
|
||||||
? "text-amber-500/70"
|
|
||||||
: "text-gray-600"
|
|
||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
{String(index + 1).padStart(2, "0")}
|
<div className="flex items-center gap-2">
|
||||||
</span>
|
<span
|
||||||
{chapter.title}
|
className={`text-xs tabular-nums ${
|
||||||
</button>
|
isActive ? "text-[#d4a574]/70" : "text-[#3a3a44]"
|
||||||
</li>
|
}`}
|
||||||
))}
|
>
|
||||||
|
{String(index + 1).padStart(2, "0")}
|
||||||
|
</span>
|
||||||
|
<span className="flex-1 truncate">{chapter.title}</span>
|
||||||
|
{isCompleted && (
|
||||||
|
<svg
|
||||||
|
className="w-3.5 h-3.5 text-emerald-500 flex-shrink-0"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
fill="none"
|
||||||
|
stroke="currentColor"
|
||||||
|
strokeWidth="3"
|
||||||
|
strokeLinecap="round"
|
||||||
|
strokeLinejoin="round"
|
||||||
|
>
|
||||||
|
<polyline points="20 6 9 17 4 12" />
|
||||||
|
</svg>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
{/* Progress indicator */}
|
||||||
|
<div className="mt-1.5 ml-5 h-[2px] bg-[#1a1a24] rounded-full overflow-hidden">
|
||||||
|
<div
|
||||||
|
className={`h-full rounded-full transition-all duration-300 ${
|
||||||
|
isActive ? "bg-[#d4a574]/60" : "bg-[#3a3a44]/40"
|
||||||
|
}`}
|
||||||
|
style={{ width: `${progress}%` }}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</button>
|
||||||
|
</li>
|
||||||
|
);
|
||||||
|
})}
|
||||||
</ol>
|
</ol>
|
||||||
</div>
|
</div>
|
||||||
</nav>
|
</nav>
|
||||||
|
|||||||
@@ -1,23 +1,67 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { useEffect, useState } from "react";
|
import { useEffect, useRef, useState } from "react";
|
||||||
import type { Documentary, Chapter } from "@afterlife/shared";
|
import type { Documentary, Chapter } from "@afterlife/shared";
|
||||||
import { ChapterNav } from "./ChapterNav";
|
import { ChapterNav } from "./ChapterNav";
|
||||||
import { ChapterContent } from "./ChapterContent";
|
import { ChapterContent } from "./ChapterContent";
|
||||||
import { AudioPlayer } from "./AudioPlayer";
|
import { AudioPlayer } from "./AudioPlayer";
|
||||||
import { ReadingProgress } from "./ReadingProgress";
|
import { ReadingProgress } from "./ReadingProgress";
|
||||||
|
import { GiscusComments } from "./GiscusComments";
|
||||||
import { useAudioPlayer } from "@/hooks/useAudioPlayer";
|
import { useAudioPlayer } from "@/hooks/useAudioPlayer";
|
||||||
|
|
||||||
interface DocumentaryLayoutProps {
|
interface DocumentaryLayoutProps {
|
||||||
documentary: Documentary;
|
documentary: Documentary;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function getProgressKey(docId: number) {
|
||||||
|
return `doc-progress-${docId}`;
|
||||||
|
}
|
||||||
|
|
||||||
export function DocumentaryLayout({ documentary }: DocumentaryLayoutProps) {
|
export function DocumentaryLayout({ documentary }: DocumentaryLayoutProps) {
|
||||||
const chapters = [...documentary.chapters].sort((a, b) => a.order - b.order);
|
const chapters = [...documentary.chapters].sort((a, b) => a.order - b.order);
|
||||||
const [activeChapter, setActiveChapter] = useState<Chapter>(chapters[0]);
|
const [activeChapterId, setActiveChapterId] = useState<number>(
|
||||||
|
chapters[0]?.id ?? 0
|
||||||
|
);
|
||||||
|
const [readingMode, setReadingMode] = useState(false);
|
||||||
|
const [chapterProgress, setChapterProgress] = useState<
|
||||||
|
Record<number, number>
|
||||||
|
>({});
|
||||||
|
const chapterRefs = useRef<Map<number, HTMLElement>>(new Map());
|
||||||
|
const progressRef = useRef<Record<number, number>>({});
|
||||||
|
const rafRef = useRef<number>(0);
|
||||||
const audio = useAudioPlayer();
|
const audio = useAudioPlayer();
|
||||||
|
|
||||||
|
// Load progress from localStorage
|
||||||
|
useEffect(() => {
|
||||||
|
try {
|
||||||
|
const stored = localStorage.getItem(getProgressKey(documentary.id));
|
||||||
|
if (stored) {
|
||||||
|
const parsed = JSON.parse(stored) as Record<number, number>;
|
||||||
|
progressRef.current = parsed;
|
||||||
|
setChapterProgress(parsed);
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// ignore
|
||||||
|
}
|
||||||
|
}, [documentary.id]);
|
||||||
|
|
||||||
|
// Save progress on unload
|
||||||
|
useEffect(() => {
|
||||||
|
function handleBeforeUnload() {
|
||||||
|
try {
|
||||||
|
localStorage.setItem(
|
||||||
|
getProgressKey(documentary.id),
|
||||||
|
JSON.stringify(progressRef.current)
|
||||||
|
);
|
||||||
|
} catch {
|
||||||
|
// ignore
|
||||||
|
}
|
||||||
|
}
|
||||||
|
window.addEventListener("beforeunload", handleBeforeUnload);
|
||||||
|
return () => window.removeEventListener("beforeunload", handleBeforeUnload);
|
||||||
|
}, [documentary.id]);
|
||||||
|
|
||||||
|
// Setup audio tracks
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const audioTracks = chapters
|
const audioTracks = chapters
|
||||||
.filter((ch) => ch.audioFile)
|
.filter((ch) => ch.audioFile)
|
||||||
@@ -33,60 +77,299 @@ export function DocumentaryLayout({ documentary }: DocumentaryLayoutProps) {
|
|||||||
}
|
}
|
||||||
}, []); // eslint-disable-line react-hooks/exhaustive-deps
|
}, []); // eslint-disable-line react-hooks/exhaustive-deps
|
||||||
|
|
||||||
function handleSelectChapter(chapterId: number, index: number) {
|
// IntersectionObserver for scroll spy
|
||||||
|
useEffect(() => {
|
||||||
|
const observer = new IntersectionObserver(
|
||||||
|
(entries) => {
|
||||||
|
let best: IntersectionObserverEntry | null = null;
|
||||||
|
for (const entry of entries) {
|
||||||
|
if (!best || entry.intersectionRatio > best.intersectionRatio) {
|
||||||
|
best = entry;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (best && best.intersectionRatio > 0) {
|
||||||
|
const id = Number(best.target.getAttribute("data-chapter-id"));
|
||||||
|
if (!isNaN(id)) {
|
||||||
|
setActiveChapterId(id);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{ threshold: [0, 0.25, 0.5, 0.75, 1.0], rootMargin: "-10% 0px -40% 0px" }
|
||||||
|
);
|
||||||
|
|
||||||
|
chapterRefs.current.forEach((el) => observer.observe(el));
|
||||||
|
return () => observer.disconnect();
|
||||||
|
}, [chapters.length]);
|
||||||
|
|
||||||
|
// Scroll progress tracking per chapter (throttled with rAF)
|
||||||
|
useEffect(() => {
|
||||||
|
function handleScroll() {
|
||||||
|
if (rafRef.current) return;
|
||||||
|
rafRef.current = requestAnimationFrame(() => {
|
||||||
|
rafRef.current = 0;
|
||||||
|
const updates: Record<number, number> = {};
|
||||||
|
chapterRefs.current.forEach((el, id) => {
|
||||||
|
const rect = el.getBoundingClientRect();
|
||||||
|
const elHeight = el.offsetHeight;
|
||||||
|
const viewportHeight = window.innerHeight;
|
||||||
|
if (elHeight <= 0) return;
|
||||||
|
const scrolled = Math.min(
|
||||||
|
Math.max(
|
||||||
|
(viewportHeight - rect.top) / (elHeight + viewportHeight),
|
||||||
|
0
|
||||||
|
),
|
||||||
|
1
|
||||||
|
);
|
||||||
|
updates[id] = Math.round(scrolled * 100);
|
||||||
|
});
|
||||||
|
|
||||||
|
let changed = false;
|
||||||
|
for (const [id, val] of Object.entries(updates)) {
|
||||||
|
const numId = Number(id);
|
||||||
|
if ((progressRef.current[numId] ?? 0) < val) {
|
||||||
|
progressRef.current[numId] = val;
|
||||||
|
changed = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (changed) {
|
||||||
|
setChapterProgress({ ...progressRef.current });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
window.addEventListener("scroll", handleScroll, { passive: true });
|
||||||
|
return () => window.removeEventListener("scroll", handleScroll);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
// Keyboard shortcuts
|
||||||
|
useEffect(() => {
|
||||||
|
function handleKeyDown(e: KeyboardEvent) {
|
||||||
|
const target = e.target as HTMLElement;
|
||||||
|
if (
|
||||||
|
target &&
|
||||||
|
(target.tagName === "INPUT" ||
|
||||||
|
target.tagName === "TEXTAREA" ||
|
||||||
|
target.isContentEditable)
|
||||||
|
) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
switch (e.key) {
|
||||||
|
case "ArrowLeft": {
|
||||||
|
e.preventDefault();
|
||||||
|
const idx = chapters.findIndex((c) => c.id === activeChapterId);
|
||||||
|
if (idx > 0) goToChapter(chapters[idx - 1]);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
case "ArrowRight": {
|
||||||
|
e.preventDefault();
|
||||||
|
const idx = chapters.findIndex((c) => c.id === activeChapterId);
|
||||||
|
if (idx >= 0 && idx < chapters.length - 1)
|
||||||
|
goToChapter(chapters[idx + 1]);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
case " ": {
|
||||||
|
e.preventDefault();
|
||||||
|
audio.toggle();
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
case "f":
|
||||||
|
case "F": {
|
||||||
|
e.preventDefault();
|
||||||
|
setReadingMode((prev) => !prev);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
window.addEventListener("keydown", handleKeyDown);
|
||||||
|
return () => window.removeEventListener("keydown", handleKeyDown);
|
||||||
|
}, [activeChapterId, audio, chapters]);
|
||||||
|
|
||||||
|
function goToChapter(chapter: Chapter) {
|
||||||
|
const el = chapterRefs.current.get(chapter.id);
|
||||||
|
if (el) {
|
||||||
|
el.scrollIntoView({ behavior: "smooth", block: "start" });
|
||||||
|
}
|
||||||
|
const trackIndex = audio.tracks.findIndex((t) => t.id === chapter.id);
|
||||||
|
if (trackIndex !== -1) {
|
||||||
|
audio.goToTrack(trackIndex);
|
||||||
|
}
|
||||||
|
setActiveChapterId(chapter.id);
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleSelectChapter(chapterId: number) {
|
||||||
const chapter = chapters.find((c) => c.id === chapterId);
|
const chapter = chapters.find((c) => c.id === chapterId);
|
||||||
if (chapter) {
|
if (chapter) {
|
||||||
setActiveChapter(chapter);
|
goToChapter(chapter);
|
||||||
window.scrollTo({ top: 0, behavior: "smooth" });
|
|
||||||
const trackIndex = audio.tracks.findIndex((t) => t.id === chapterId);
|
|
||||||
if (trackIndex !== -1) {
|
|
||||||
audio.goToTrack(trackIndex);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const activeIndex = chapters.findIndex((c) => c.id === activeChapterId);
|
||||||
|
const prevChapter = activeIndex > 0 ? chapters[activeIndex - 1] : null;
|
||||||
|
const nextChapter =
|
||||||
|
activeIndex >= 0 && activeIndex < chapters.length - 1
|
||||||
|
? chapters[activeIndex + 1]
|
||||||
|
: null;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<ReadingProgress />
|
<ReadingProgress
|
||||||
|
chapterName={
|
||||||
|
chapters.find((c) => c.id === activeChapterId)?.title ?? ""
|
||||||
|
}
|
||||||
|
progress={chapterProgress[activeChapterId] ?? 0}
|
||||||
|
/>
|
||||||
|
|
||||||
{/* Documentary header */}
|
{/* Documentary header */}
|
||||||
<header className="border-b border-white/[0.06]">
|
<header className="border-b border-[rgba(255,255,255,0.08)]">
|
||||||
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 pt-10 pb-8">
|
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 pt-10 pb-8">
|
||||||
<h1 className="text-3xl sm:text-4xl font-display font-bold tracking-tight">
|
<div className="flex items-start justify-between gap-4">
|
||||||
{documentary.title}
|
<div className="flex-1">
|
||||||
</h1>
|
<h1 className="text-3xl sm:text-4xl font-display font-bold tracking-tight">
|
||||||
{documentary.description && (
|
{documentary.title}
|
||||||
<p className="mt-3 text-gray-400 font-body text-lg max-w-3xl leading-relaxed">
|
</h1>
|
||||||
{documentary.description}
|
{!readingMode && documentary.description && (
|
||||||
</p>
|
<p className="mt-3 text-[#a0a0a8] font-body text-lg max-w-3xl leading-relaxed">
|
||||||
)}
|
{documentary.description}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
onClick={() => setReadingMode((prev) => !prev)}
|
||||||
|
className="hidden lg:flex items-center gap-2 px-3 py-2 rounded-lg text-sm text-[#6b6b75] hover:text-[#d4a574] hover:bg-[rgba(212,165,116,0.08)] transition-colors border border-[rgba(255,255,255,0.08)]"
|
||||||
|
title="Toggle reading mode (F)"
|
||||||
|
aria-label="Toggle reading mode"
|
||||||
|
>
|
||||||
|
<svg
|
||||||
|
width="16"
|
||||||
|
height="16"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
fill="none"
|
||||||
|
stroke="currentColor"
|
||||||
|
strokeWidth="2"
|
||||||
|
strokeLinecap="round"
|
||||||
|
strokeLinejoin="round"
|
||||||
|
>
|
||||||
|
<path d="M2 3h6a4 4 0 0 1 4 4v14a3 3 0 0 0-3-3H2z" />
|
||||||
|
<path d="M22 3h-6a4 4 0 0 0-4 4v14a3 3 0 0 1 3-3h7z" />
|
||||||
|
</svg>
|
||||||
|
<span>{readingMode ? "Exit Reading" : "Reading Mode"}</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</header>
|
</header>
|
||||||
|
|
||||||
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-12 flex gap-12">
|
<div
|
||||||
<ChapterNav
|
className={`mx-auto px-4 sm:px-6 lg:px-8 py-12 flex gap-12 ${
|
||||||
chapters={chapters}
|
readingMode ? "max-w-3xl" : "max-w-7xl"
|
||||||
activeChapterId={activeChapter.id}
|
}`}
|
||||||
onSelectChapter={handleSelectChapter}
|
>
|
||||||
/>
|
{!readingMode && (
|
||||||
|
<ChapterNav
|
||||||
|
chapters={chapters}
|
||||||
|
activeChapterId={activeChapterId}
|
||||||
|
onSelectChapter={handleSelectChapter}
|
||||||
|
chapterProgress={chapterProgress}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
<div className="flex-1 min-w-0 pb-24">
|
<div className="flex-1 min-w-0 pb-24">
|
||||||
<ChapterContent chapter={activeChapter} />
|
{chapters.map((chapter) => (
|
||||||
|
<div
|
||||||
|
key={chapter.id}
|
||||||
|
ref={(el) => {
|
||||||
|
if (el) {
|
||||||
|
chapterRefs.current.set(chapter.id, el);
|
||||||
|
} else {
|
||||||
|
chapterRefs.current.delete(chapter.id);
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
data-chapter-id={chapter.id}
|
||||||
|
className="scroll-mt-24"
|
||||||
|
>
|
||||||
|
<ChapterContent
|
||||||
|
chapter={chapter}
|
||||||
|
readingMode={readingMode}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
|
||||||
|
{/* Previous/Next navigation */}
|
||||||
|
<div className="mt-16 flex items-center justify-between border-t border-[rgba(255,255,255,0.08)] pt-8">
|
||||||
|
<button
|
||||||
|
onClick={() => prevChapter && goToChapter(prevChapter)}
|
||||||
|
disabled={!prevChapter}
|
||||||
|
className={`flex items-center gap-2 px-4 py-2 rounded-lg text-sm transition-colors ${
|
||||||
|
prevChapter
|
||||||
|
? "text-[#a0a0a8] hover:text-[#f5f5f7] hover:bg-[rgba(255,255,255,0.05)]"
|
||||||
|
: "text-[#3a3a44] cursor-not-allowed"
|
||||||
|
}`}
|
||||||
|
aria-label="Previous chapter"
|
||||||
|
>
|
||||||
|
<svg
|
||||||
|
width="16"
|
||||||
|
height="16"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
fill="none"
|
||||||
|
stroke="currentColor"
|
||||||
|
strokeWidth="2"
|
||||||
|
strokeLinecap="round"
|
||||||
|
strokeLinejoin="round"
|
||||||
|
>
|
||||||
|
<polyline points="15 18 9 12 15 6" />
|
||||||
|
</svg>
|
||||||
|
<span className="hidden sm:inline">Previous</span>
|
||||||
|
</button>
|
||||||
|
<span className="text-xs text-[#6b6b75]">
|
||||||
|
{activeIndex + 1} / {chapters.length}
|
||||||
|
</span>
|
||||||
|
<button
|
||||||
|
onClick={() => nextChapter && goToChapter(nextChapter)}
|
||||||
|
disabled={!nextChapter}
|
||||||
|
className={`flex items-center gap-2 px-4 py-2 rounded-lg text-sm transition-colors ${
|
||||||
|
nextChapter
|
||||||
|
? "text-[#a0a0a8] hover:text-[#f5f5f7] hover:bg-[rgba(255,255,255,0.05)]"
|
||||||
|
: "text-[#3a3a44] cursor-not-allowed"
|
||||||
|
}`}
|
||||||
|
aria-label="Next chapter"
|
||||||
|
>
|
||||||
|
<span className="hidden sm:inline">Next</span>
|
||||||
|
<svg
|
||||||
|
width="16"
|
||||||
|
height="16"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
fill="none"
|
||||||
|
stroke="currentColor"
|
||||||
|
strokeWidth="2"
|
||||||
|
strokeLinecap="round"
|
||||||
|
strokeLinejoin="round"
|
||||||
|
>
|
||||||
|
<polyline points="9 18 15 12 9 6" />
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<AudioPlayer
|
|
||||||
trackTitle={audio.currentTrack?.title ?? null}
|
{!readingMode && (
|
||||||
isPlaying={audio.isPlaying}
|
<AudioPlayer
|
||||||
progress={audio.progress}
|
trackTitle={audio.currentTrack?.title ?? null}
|
||||||
duration={audio.duration}
|
isPlaying={audio.isPlaying}
|
||||||
playbackRate={audio.playbackRate}
|
progress={audio.progress}
|
||||||
continuousMode={audio.continuousMode}
|
duration={audio.duration}
|
||||||
onToggle={audio.toggle}
|
playbackRate={audio.playbackRate}
|
||||||
onSeek={audio.seek}
|
continuousMode={audio.continuousMode}
|
||||||
onChangeRate={audio.changeRate}
|
onToggle={audio.toggle}
|
||||||
onToggleContinuous={() =>
|
onSeek={audio.seek}
|
||||||
audio.setContinuousMode(!audio.continuousMode)
|
onChangeRate={audio.changeRate}
|
||||||
}
|
onToggleContinuous={() =>
|
||||||
/>
|
audio.setContinuousMode(!audio.continuousMode)
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{!readingMode && (
|
||||||
|
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 pb-24">
|
||||||
|
<GiscusComments term={documentary.title} />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
33
apps/web/src/components/documentary/GiscusComments.tsx
Normal file
33
apps/web/src/components/documentary/GiscusComments.tsx
Normal file
@@ -0,0 +1,33 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useLocale } from "next-intl";
|
||||||
|
import Giscus from "@giscus/react";
|
||||||
|
|
||||||
|
interface GiscusCommentsProps {
|
||||||
|
term: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function GiscusComments({ term }: GiscusCommentsProps) {
|
||||||
|
const locale = useLocale();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="mt-16 pt-8 border-t border-[rgba(255,255,255,0.08)]">
|
||||||
|
<h3 className="text-lg font-semibold mb-6">Comments</h3>
|
||||||
|
<Giscus
|
||||||
|
id="comments"
|
||||||
|
repo="projectafterlife/discussions"
|
||||||
|
repoId=""
|
||||||
|
category="General"
|
||||||
|
categoryId=""
|
||||||
|
mapping="specific"
|
||||||
|
term={term}
|
||||||
|
reactionsEnabled="1"
|
||||||
|
emitMetadata="0"
|
||||||
|
inputPosition="top"
|
||||||
|
theme="transparent_dark"
|
||||||
|
lang={locale}
|
||||||
|
loading="lazy"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -2,25 +2,47 @@
|
|||||||
|
|
||||||
import { useEffect, useState } from "react";
|
import { useEffect, useState } from "react";
|
||||||
|
|
||||||
export function ReadingProgress() {
|
interface ReadingProgressProps {
|
||||||
const [progress, setProgress] = useState(0);
|
chapterName: string;
|
||||||
|
progress: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function ReadingProgress({
|
||||||
|
chapterName,
|
||||||
|
progress,
|
||||||
|
}: ReadingProgressProps) {
|
||||||
|
const [scrollProgress, setScrollProgress] = useState(0);
|
||||||
|
const [hovered, setHovered] = useState(false);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
function handleScroll() {
|
function handleScroll() {
|
||||||
const scrollTop = window.scrollY;
|
const scrollTop = window.scrollY;
|
||||||
const docHeight = document.documentElement.scrollHeight - window.innerHeight;
|
const docHeight =
|
||||||
setProgress(docHeight > 0 ? (scrollTop / docHeight) * 100 : 0);
|
document.documentElement.scrollHeight - window.innerHeight;
|
||||||
|
setScrollProgress(docHeight > 0 ? (scrollTop / docHeight) * 100 : 0);
|
||||||
}
|
}
|
||||||
window.addEventListener("scroll", handleScroll, { passive: true });
|
window.addEventListener("scroll", handleScroll, { passive: true });
|
||||||
|
handleScroll();
|
||||||
return () => window.removeEventListener("scroll", handleScroll);
|
return () => window.removeEventListener("scroll", handleScroll);
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="fixed top-16 left-0 right-0 z-40 h-0.5 bg-gray-800">
|
<div
|
||||||
|
className="fixed top-16 left-0 right-0 z-40 h-[2px] bg-[#12121a] cursor-pointer"
|
||||||
|
onMouseEnter={() => setHovered(true)}
|
||||||
|
onMouseLeave={() => setHovered(false)}
|
||||||
|
>
|
||||||
<div
|
<div
|
||||||
className="h-full bg-blue-500 transition-all duration-150"
|
className="h-full bg-gradient-to-r from-[#d4a574] to-[#e8c4a0] transition-all duration-150 shadow-[0_0_8px_rgba(212,165,116,0.4)]"
|
||||||
style={{ width: `${progress}%` }}
|
style={{ width: `${scrollProgress}%` }}
|
||||||
/>
|
/>
|
||||||
|
{hovered && (
|
||||||
|
<div className="absolute top-3 left-1/2 -translate-x-1/2 px-3 py-1.5 rounded-md bg-[#12121a] border border-[rgba(255,255,255,0.08)] text-xs text-[#a0a0a8] whitespace-nowrap shadow-lg z-50">
|
||||||
|
{chapterName
|
||||||
|
? `${chapterName} · ${progress}%`
|
||||||
|
: `${Math.round(scrollProgress)}%`}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import Image from "next/image";
|
import Image from "next/image";
|
||||||
import type { Game } from "@afterlife/shared";
|
import type { Game } from "@afterlife/shared";
|
||||||
|
import { getImageUrl } from "@/lib/images";
|
||||||
|
|
||||||
interface GameHeaderProps {
|
interface GameHeaderProps {
|
||||||
game: Game;
|
game: Game;
|
||||||
@@ -10,19 +11,20 @@ export function GameHeader({ game }: GameHeaderProps) {
|
|||||||
<div className="relative h-[50vh] overflow-hidden">
|
<div className="relative h-[50vh] overflow-hidden">
|
||||||
{game.coverImage && (
|
{game.coverImage && (
|
||||||
<Image
|
<Image
|
||||||
src={game.coverImage.url}
|
src={getImageUrl(game.coverImage.url)}
|
||||||
alt={game.title}
|
alt={game.title}
|
||||||
fill
|
fill
|
||||||
|
unoptimized
|
||||||
className="object-cover"
|
className="object-cover"
|
||||||
priority
|
priority
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
<div className="absolute inset-0 bg-gradient-to-t from-gray-950 via-gray-950/60 to-transparent" />
|
<div className="absolute inset-0 bg-gradient-to-t from-[#0a0a0f] via-[#0a0a0f]/60 to-transparent" />
|
||||||
<div className="absolute bottom-0 left-0 right-0 p-8 max-w-7xl mx-auto">
|
<div className="absolute bottom-0 left-0 right-0 p-8 max-w-7xl mx-auto">
|
||||||
<h1 className="text-5xl font-bold mb-3 font-display tracking-tight">
|
<h1 className="text-5xl font-bold mb-3 font-display tracking-tight">
|
||||||
{game.title}
|
{game.title}
|
||||||
</h1>
|
</h1>
|
||||||
<p className="text-gray-400 text-lg font-body">
|
<p className="text-[#a0a0a8] text-lg font-body">
|
||||||
{game.developer} · {game.releaseYear}–{game.shutdownYear}
|
{game.developer} · {game.releaseYear}–{game.shutdownYear}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -26,36 +26,36 @@ export function GameInfo({ game, locale }: GameInfoProps) {
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div className="space-y-6">
|
<div className="space-y-6">
|
||||||
<div className="bg-gradient-to-b from-gray-900 to-gray-900/50 rounded-xl p-6 border border-white/[0.07]">
|
<div className="bg-[#12121a] rounded-xl p-6 border border-[rgba(255,255,255,0.08)]">
|
||||||
<dl className="divide-y divide-white/5 text-sm">
|
<dl className="divide-y divide-white/5 text-sm">
|
||||||
<div className="pb-3">
|
<div className="pb-3">
|
||||||
<dt className="text-gray-500 text-xs uppercase tracking-wider mb-1">
|
<dt className="text-[#6b6b75] text-xs uppercase tracking-wider mb-1">
|
||||||
{t("developer")}
|
{t("developer")}
|
||||||
</dt>
|
</dt>
|
||||||
<dd className="text-gray-100 font-medium">{game.developer}</dd>
|
<dd className="text-[#f5f5f7] font-medium">{game.developer}</dd>
|
||||||
</div>
|
</div>
|
||||||
{game.publisher && (
|
{game.publisher && (
|
||||||
<div className="py-3">
|
<div className="py-3">
|
||||||
<dt className="text-gray-500 text-xs uppercase tracking-wider mb-1">
|
<dt className="text-[#6b6b75] text-xs uppercase tracking-wider mb-1">
|
||||||
{t("publisher")}
|
{t("publisher")}
|
||||||
</dt>
|
</dt>
|
||||||
<dd className="text-gray-100 font-medium">{game.publisher}</dd>
|
<dd className="text-[#f5f5f7] font-medium">{game.publisher}</dd>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
<div className="py-3">
|
<div className="py-3">
|
||||||
<dt className="text-gray-500 text-xs uppercase tracking-wider mb-1">
|
<dt className="text-[#6b6b75] text-xs uppercase tracking-wider mb-1">
|
||||||
{t("released")}
|
{t("released")}
|
||||||
</dt>
|
</dt>
|
||||||
<dd className="text-gray-100 font-medium">{game.releaseYear}</dd>
|
<dd className="text-[#f5f5f7] font-medium">{game.releaseYear}</dd>
|
||||||
</div>
|
</div>
|
||||||
<div className="py-3">
|
<div className="py-3">
|
||||||
<dt className="text-gray-500 text-xs uppercase tracking-wider mb-1">
|
<dt className="text-[#6b6b75] text-xs uppercase tracking-wider mb-1">
|
||||||
{t("shutdown")}
|
{t("shutdown")}
|
||||||
</dt>
|
</dt>
|
||||||
<dd className="text-gray-100 font-medium">{game.shutdownYear}</dd>
|
<dd className="text-[#f5f5f7] font-medium">{game.shutdownYear}</dd>
|
||||||
</div>
|
</div>
|
||||||
<div className="pt-3">
|
<div className="pt-3">
|
||||||
<dt className="text-gray-500 text-xs uppercase tracking-wider mb-1">
|
<dt className="text-[#6b6b75] text-xs uppercase tracking-wider mb-1">
|
||||||
{t("server_status")}
|
{t("server_status")}
|
||||||
</dt>
|
</dt>
|
||||||
<dd className={`font-medium ${statusColors[game.serverStatus]}`}>
|
<dd className={`font-medium ${statusColors[game.serverStatus]}`}>
|
||||||
@@ -69,7 +69,7 @@ export function GameInfo({ game, locale }: GameInfoProps) {
|
|||||||
href={game.serverLink}
|
href={game.serverLink}
|
||||||
target="_blank"
|
target="_blank"
|
||||||
rel="noopener noreferrer"
|
rel="noopener noreferrer"
|
||||||
className="block w-full text-center px-4 py-2.5 bg-green-600 hover:bg-green-500 text-white rounded-lg transition-colors font-medium text-sm"
|
className="block w-full text-center px-4 py-2.5 bg-emerald-600 hover:bg-emerald-500 text-white rounded-lg transition-colors font-medium text-sm"
|
||||||
>
|
>
|
||||||
{t("play_now")}
|
{t("play_now")}
|
||||||
</a>
|
</a>
|
||||||
@@ -77,7 +77,7 @@ export function GameInfo({ game, locale }: GameInfoProps) {
|
|||||||
{game.documentary && (
|
{game.documentary && (
|
||||||
<Link
|
<Link
|
||||||
href={`/${locale}/games/${game.slug}/documentary`}
|
href={`/${locale}/games/${game.slug}/documentary`}
|
||||||
className="block w-full text-center px-4 py-2.5 bg-amber-600 hover:bg-amber-500 text-white rounded-lg transition-colors font-medium text-sm"
|
className="block w-full text-center px-4 py-2.5 bg-[#d4a574] hover:bg-[#e8c4a0] text-[#0a0a0f] rounded-lg transition-colors font-medium text-sm"
|
||||||
>
|
>
|
||||||
{t("view_documentary")}
|
{t("view_documentary")}
|
||||||
</Link>
|
</Link>
|
||||||
|
|||||||
@@ -30,7 +30,7 @@ export function ScreenshotGallery({ screenshots }: ScreenshotGalleryProps) {
|
|||||||
key={ss.id}
|
key={ss.id}
|
||||||
onClick={() => setSelected(i)}
|
onClick={() => setSelected(i)}
|
||||||
className={`relative w-24 h-16 rounded overflow-hidden flex-shrink-0 border-2 transition-colors ${
|
className={`relative w-24 h-16 rounded overflow-hidden flex-shrink-0 border-2 transition-colors ${
|
||||||
i === selected ? "border-blue-500" : "border-transparent"
|
i === selected ? "border-[#d4a574]" : "border-transparent"
|
||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
<Image
|
<Image
|
||||||
|
|||||||
202
apps/web/src/components/home/DocumentaryExperienceSection.tsx
Normal file
202
apps/web/src/components/home/DocumentaryExperienceSection.tsx
Normal file
@@ -0,0 +1,202 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { motion } from "framer-motion";
|
||||||
|
import { useTranslations } from "next-intl";
|
||||||
|
|
||||||
|
const features = [
|
||||||
|
{
|
||||||
|
key: "feature1",
|
||||||
|
icon: (
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" strokeWidth={2} stroke="currentColor" className="w-6 h-6">
|
||||||
|
<path strokeLinecap="round" strokeLinejoin="round" d="M19.114 5.636a9 9 0 010 12.728M16.463 8.288a5.25 5.25 0 010 7.424M6.75 8.25l4.72-4.72a.75.75 0 011.28.53v15.88a.75.75 0 01-1.28.53l-4.72-4.72H4.51c-.88 0-1.704-.507-1.938-1.354A9.01 9.01 0 012.25 12c0-.83.112-1.633.322-2.396C2.806 8.756 3.63 8.25 4.51 8.25H6.75z" />
|
||||||
|
</svg>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: "feature2",
|
||||||
|
icon: (
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" strokeWidth={2} stroke="currentColor" className="w-6 h-6">
|
||||||
|
<path strokeLinecap="round" strokeLinejoin="round" d="M2.25 15.75l5.159-5.159a2.25 2.25 0 013.182 0l5.159 5.159m-1.5-1.5l1.409-1.409a2.25 2.25 0 013.182 0l2.909 2.909M3.75 21h16.5a2.25 2.25 0 002.25-2.25V5.25a2.25 2.25 0 00-2.25-2.25H3.75A2.25 2.25 0 001.5 5.25v13.5A2.25 2.25 0 003.75 21z" />
|
||||||
|
</svg>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: "feature3",
|
||||||
|
icon: (
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" strokeWidth={2} stroke="currentColor" className="w-6 h-6">
|
||||||
|
<path strokeLinecap="round" strokeLinejoin="round" d="M10.5 21l5.25-11.25L21 21m-9-3h7.5M3 5.621a48.474 48.474 0 016-.371m0 0c1.12 0 2.233.038 3.334.114M9 5.25V3m3.334 2.364C11.176 10.658 7.69 15.08 3 17.502m9.334-12.138c.896.061 1.785.147 2.666.257m-4.589 8.495a18.023 18.023 0 01-3.827-5.802" />
|
||||||
|
</svg>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
const chapters = [
|
||||||
|
{ num: 1, title: "El Nacimiento", dur: "18:42" },
|
||||||
|
{ num: 2, title: "La Era Dorada", dur: "28:15" },
|
||||||
|
{ num: 3, title: "Así Se Jugaba", dur: "22:30", active: true },
|
||||||
|
{ num: 4, title: "El Declive", dur: "15:08" },
|
||||||
|
{ num: 5, title: "Luces Apagadas", dur: "12:55" },
|
||||||
|
{ num: 6, title: "La Restauración", dur: "20:10" },
|
||||||
|
];
|
||||||
|
|
||||||
|
export function DocumentaryExperienceSection() {
|
||||||
|
const t = useTranslations("home");
|
||||||
|
|
||||||
|
return (
|
||||||
|
<section className="py-24 px-4" style={{ background: "var(--bg-primary)" }}>
|
||||||
|
<div className="max-w-[1200px] mx-auto">
|
||||||
|
<div className="grid grid-cols-1 lg:grid-cols-2 gap-16 items-center">
|
||||||
|
{/* Audio player mockup */}
|
||||||
|
<motion.div
|
||||||
|
initial={{ opacity: 0, x: -40 }}
|
||||||
|
whileInView={{ opacity: 1, x: 0 }}
|
||||||
|
viewport={{ once: true, margin: "-50px" }}
|
||||||
|
transition={{ duration: 0.8, ease: [0.16, 1, 0.3, 1] }}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
className="rounded-[24px] p-6 overflow-hidden"
|
||||||
|
style={{
|
||||||
|
background: "var(--bg-card)",
|
||||||
|
border: "1px solid var(--border-color)",
|
||||||
|
boxShadow: "0 20px 60px rgba(0, 0, 0, 0.4)",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{/* Player header */}
|
||||||
|
<div className="flex items-center gap-4 mb-6">
|
||||||
|
<div
|
||||||
|
className="w-14 h-14 flex items-center justify-center rounded-xl"
|
||||||
|
style={{
|
||||||
|
background: "linear-gradient(135deg, rgba(212, 165, 116, 0.2), rgba(212, 165, 116, 0.05))",
|
||||||
|
color: "var(--accent-primary)",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" strokeWidth={1.5} stroke="currentColor" className="w-7 h-7">
|
||||||
|
<path strokeLinecap="round" strokeLinejoin="round" d="M9 9l10.5-3m0 6.553v3.75a2.25 2.25 0 01-1.632 2.163l-1.32.377a1.803 1.803 0 11-.99-3.467l2.31-.66a2.25 2.25 0 001.632-2.163zm0 0V2.25L9 5.25v10.303m0 0v3.75a2.25 2.25 0 01-1.632 2.163l-1.32.377a1.803 1.803 0 01-.99-3.467l2.31-.66A2.25 2.25 0 009 15.553z" />
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<h4 className="font-semibold text-base" style={{ color: "var(--text-primary)" }}>
|
||||||
|
Cap. 4 - La Era Dorada
|
||||||
|
</h4>
|
||||||
|
<p className="text-sm" style={{ color: "var(--text-muted)" }}>
|
||||||
|
FusionFall - Documental Interactivo
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Progress bar */}
|
||||||
|
<div className="h-1 rounded-full mb-3 overflow-hidden" style={{ background: "var(--bg-elevated)" }}>
|
||||||
|
<div className="h-full rounded-full w-[45%]" style={{ background: "linear-gradient(90deg, var(--accent-primary), var(--accent-secondary))" }} />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Time */}
|
||||||
|
<div className="flex justify-between text-xs mb-5" style={{ color: "var(--text-muted)" }}>
|
||||||
|
<span>12:34</span>
|
||||||
|
<span>28:15</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Controls */}
|
||||||
|
<div className="flex items-center justify-center gap-4 mb-6">
|
||||||
|
<button className="p-2 rounded-full transition-colors" style={{ color: "var(--text-secondary)" }}>
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" strokeWidth={2} stroke="currentColor" className="w-6 h-6">
|
||||||
|
<path strokeLinecap="round" strokeLinejoin="round" d="M21 16.811c0 .864-.933 1.405-1.683.977l-7.108-4.062a1.125 1.125 0 010-1.953l7.108-4.062A1.125 1.125 0 0121 8.688v8.123zM11.25 16.811c0 .864-.933 1.405-1.683.977l-7.108-4.062a1.125 1.125 0 010-1.953l7.108-4.062a1.125 1.125 0 011.683.977v8.123z" />
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
className="p-3 rounded-full transition-all"
|
||||||
|
style={{
|
||||||
|
background: "linear-gradient(135deg, var(--accent-primary), var(--accent-secondary))",
|
||||||
|
color: "var(--bg-primary)",
|
||||||
|
boxShadow: "0 4px 20px var(--accent-glow)",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="currentColor" className="w-6 h-6">
|
||||||
|
<path fillRule="evenodd" d="M4.5 5.653c0-1.426 1.529-2.33 2.779-1.643l11.54 6.348c1.295.712 1.295 2.573 0 3.285L7.28 19.991c-1.25.687-2.779-.217-2.779-1.643V5.653z" clipRule="evenodd" />
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
<button className="p-2 rounded-full transition-colors" style={{ color: "var(--text-secondary)" }}>
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" strokeWidth={2} stroke="currentColor" className="w-6 h-6">
|
||||||
|
<path strokeLinecap="round" strokeLinejoin="round" d="M3 8.688c0-.864.933-1.405 1.683-.977l7.108 4.062a1.125 1.125 0 010 1.953l-7.108 4.062A1.125 1.125 0 013 16.811V8.688zM12.75 8.688c0-.864.933-1.405 1.683-.977l7.108 4.062a1.125 1.125 0 010 1.953l-7.108 4.062a1.125 1.125 0 01-1.683-.977V8.688z" />
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Chapters */}
|
||||||
|
<div className="space-y-2">
|
||||||
|
{chapters.map((ch) => (
|
||||||
|
<div
|
||||||
|
key={ch.num}
|
||||||
|
className="flex items-center gap-3 px-3 py-2.5 rounded-xl text-sm transition-colors"
|
||||||
|
style={{
|
||||||
|
background: ch.active ? "rgba(212, 165, 116, 0.1)" : "transparent",
|
||||||
|
border: ch.active ? "1px solid rgba(212, 165, 116, 0.2)" : "1px solid transparent",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<span
|
||||||
|
className="w-6 h-6 flex items-center justify-center rounded-full text-xs font-semibold"
|
||||||
|
style={{
|
||||||
|
background: ch.active ? "var(--accent-primary)" : "var(--bg-elevated)",
|
||||||
|
color: ch.active ? "var(--bg-primary)" : "var(--text-muted)",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{ch.num}
|
||||||
|
</span>
|
||||||
|
<span className="flex-1 font-medium" style={{ color: ch.active ? "var(--text-primary)" : "var(--text-secondary)" }}>
|
||||||
|
{ch.title}
|
||||||
|
</span>
|
||||||
|
<span className="text-xs" style={{ color: "var(--text-muted)" }}>
|
||||||
|
{ch.dur}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</motion.div>
|
||||||
|
|
||||||
|
{/* Content */}
|
||||||
|
<motion.div
|
||||||
|
initial={{ opacity: 0, x: 40 }}
|
||||||
|
whileInView={{ opacity: 1, x: 0 }}
|
||||||
|
viewport={{ once: true, margin: "-50px" }}
|
||||||
|
transition={{ duration: 0.8, ease: [0.16, 1, 0.3, 1] }}
|
||||||
|
>
|
||||||
|
<h2
|
||||||
|
className="text-[clamp(2rem,4vw,3rem)] font-extrabold mb-6"
|
||||||
|
style={{ fontFamily: "var(--font-display)", letterSpacing: "-0.02em" }}
|
||||||
|
>
|
||||||
|
{t("experience_title")}
|
||||||
|
<span style={{ color: "var(--accent-primary)" }}>{t("experience_title_span")}</span>
|
||||||
|
</h2>
|
||||||
|
<p className="text-[1.1rem] leading-[1.7] mb-10" style={{ color: "var(--text-secondary)" }}>
|
||||||
|
{t("experience_subtitle")}
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<div className="space-y-6">
|
||||||
|
{features.map((feature) => (
|
||||||
|
<div key={feature.key} className="flex gap-4">
|
||||||
|
<div
|
||||||
|
className="w-12 h-12 flex-shrink-0 flex items-center justify-center rounded-xl"
|
||||||
|
style={{
|
||||||
|
background: "linear-gradient(135deg, rgba(212, 165, 116, 0.15), rgba(212, 165, 116, 0.05))",
|
||||||
|
color: "var(--accent-primary)",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{feature.icon}
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<h4 className="font-semibold text-base mb-1" style={{ color: "var(--text-primary)" }}>
|
||||||
|
{t(`${feature.key}_title`)}
|
||||||
|
</h4>
|
||||||
|
<p className="text-sm leading-[1.6]" style={{ color: "var(--text-secondary)" }}>
|
||||||
|
{t(`${feature.key}_desc`)}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</motion.div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,3 +1,5 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
import { useTranslations } from "next-intl";
|
import { useTranslations } from "next-intl";
|
||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
import { useLocale } from "next-intl";
|
import { useLocale } from "next-intl";
|
||||||
@@ -7,16 +9,37 @@ export function DonationCTA() {
|
|||||||
const locale = useLocale();
|
const locale = useLocale();
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<section className="bg-gradient-to-r from-blue-950/50 to-purple-950/50 py-20">
|
<section className="relative overflow-hidden py-24">
|
||||||
<div className="max-w-3xl mx-auto px-4 text-center">
|
{/* Animated gradient background */}
|
||||||
<h2 className="text-3xl font-bold mb-4">{t("title")}</h2>
|
<div className="absolute inset-0 animated-gradient-bg" />
|
||||||
<p className="text-gray-400 mb-8">{t("description")}</p>
|
<div className="absolute inset-0 bg-gradient-to-b from-gray-950/40 via-transparent to-gray-950/40" />
|
||||||
|
|
||||||
|
<div className="relative z-10 max-w-3xl mx-auto px-4 text-center">
|
||||||
|
<h2 className="text-3xl md:text-4xl font-bold mb-4 tracking-tight">
|
||||||
|
{t("title")}
|
||||||
|
</h2>
|
||||||
|
<p className="text-[#a0a0a8] mb-10 text-lg max-w-xl mx-auto leading-relaxed">
|
||||||
|
{t("description")}
|
||||||
|
</p>
|
||||||
|
|
||||||
<Link
|
<Link
|
||||||
href={`/${locale}/donate`}
|
href={`/${locale}/donate`}
|
||||||
className="inline-block px-8 py-3 bg-white text-black font-semibold rounded-lg hover:bg-gray-200 transition-colors"
|
className="inline-block px-10 py-3.5 bg-white text-black font-semibold rounded-xl shadow-lg shadow-white/10 hover:shadow-white/20 hover:scale-[1.03] active:scale-[0.98] transition-all duration-300"
|
||||||
>
|
>
|
||||||
{t("patreon")}
|
{t("patreon")}
|
||||||
</Link>
|
</Link>
|
||||||
|
|
||||||
|
{/* Stat badges */}
|
||||||
|
<div className="mt-10 flex flex-wrap items-center justify-center gap-4">
|
||||||
|
<div className="flex items-center gap-2 px-4 py-2 rounded-full bg-white/[0.04] border border-white/[0.08] backdrop-blur-sm">
|
||||||
|
<span className="text-lg font-bold text-[#f5f5f7]">12</span>
|
||||||
|
<span className="text-sm text-[#a0a0a8]">games preserved</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-2 px-4 py-2 rounded-full bg-white/[0.04] border border-white/[0.08] backdrop-blur-sm">
|
||||||
|
<span className="text-lg font-bold text-[#f5f5f7]">2.4k</span>
|
||||||
|
<span className="text-sm text-[#a0a0a8]">players active</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
);
|
);
|
||||||
|
|||||||
201
apps/web/src/components/home/DonationSection.tsx
Normal file
201
apps/web/src/components/home/DonationSection.tsx
Normal file
@@ -0,0 +1,201 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { motion } from "framer-motion";
|
||||||
|
import { useTranslations } from "next-intl";
|
||||||
|
|
||||||
|
export function DonationSection() {
|
||||||
|
const t = useTranslations("home");
|
||||||
|
|
||||||
|
return (
|
||||||
|
<section className="py-24 px-4" style={{ background: "var(--bg-secondary)" }}>
|
||||||
|
<div className="max-w-[1200px] mx-auto">
|
||||||
|
{/* Header */}
|
||||||
|
<motion.div
|
||||||
|
className="text-center max-w-[700px] mx-auto mb-16"
|
||||||
|
initial={{ opacity: 0, y: 30 }}
|
||||||
|
whileInView={{ opacity: 1, y: 0 }}
|
||||||
|
viewport={{ once: true, margin: "-50px" }}
|
||||||
|
transition={{ duration: 0.6, ease: [0.16, 1, 0.3, 1] }}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
className="text-sm font-semibold uppercase tracking-[0.15em] mb-4"
|
||||||
|
style={{ fontFamily: "var(--font-display)", color: "var(--accent-primary)" }}
|
||||||
|
>
|
||||||
|
{t("donate_label")}
|
||||||
|
</div>
|
||||||
|
<h2
|
||||||
|
className="text-[clamp(2rem,4vw,3rem)] font-extrabold mb-4"
|
||||||
|
style={{ fontFamily: "var(--font-display)", letterSpacing: "-0.02em" }}
|
||||||
|
>
|
||||||
|
{t("donate_title")}
|
||||||
|
<span style={{ color: "var(--accent-primary)" }}>{t("donate_title_span")}</span>
|
||||||
|
</h2>
|
||||||
|
<p className="text-[1.1rem] leading-[1.7]" style={{ color: "var(--text-secondary)" }}>
|
||||||
|
{t("donate_subtitle")}
|
||||||
|
</p>
|
||||||
|
</motion.div>
|
||||||
|
|
||||||
|
{/* Cards */}
|
||||||
|
<div className="grid grid-cols-1 md:grid-cols-2 gap-6 mb-10">
|
||||||
|
{/* Patreon */}
|
||||||
|
<motion.div
|
||||||
|
className="rounded-[20px] p-8 card-hover-glow relative overflow-hidden"
|
||||||
|
style={{
|
||||||
|
background: "var(--bg-card)",
|
||||||
|
border: "1px solid var(--border-color)",
|
||||||
|
}}
|
||||||
|
initial={{ opacity: 0, y: 30 }}
|
||||||
|
whileInView={{ opacity: 1, y: 0 }}
|
||||||
|
viewport={{ once: true, margin: "-50px" }}
|
||||||
|
transition={{ duration: 0.6, ease: [0.16, 1, 0.3, 1] }}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
className="absolute top-0 left-0 right-0 h-[3px]"
|
||||||
|
style={{ background: "linear-gradient(90deg, var(--accent-primary), var(--accent-secondary))" }}
|
||||||
|
/>
|
||||||
|
<div
|
||||||
|
className="w-14 h-14 flex items-center justify-center rounded-2xl mb-6"
|
||||||
|
style={{
|
||||||
|
background: "linear-gradient(135deg, rgba(212, 165, 116, 0.15), rgba(212, 165, 116, 0.05))",
|
||||||
|
color: "var(--accent-primary)",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" strokeWidth={1.5} stroke="currentColor" className="w-7 h-7">
|
||||||
|
<path strokeLinecap="round" strokeLinejoin="round" d="M21 8.25c0-2.485-2.099-4.5-4.688-4.5-1.935 0-3.597 1.126-4.312 2.733-.715-1.607-2.377-2.733-4.313-2.733C5.1 3.75 3 5.765 3 8.25c0 7.22 9 12 9 12s9-4.78 9-12z" />
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
<h3 className="text-xl font-bold mb-1" style={{ fontFamily: "var(--font-display)" }}>
|
||||||
|
{t("donate_patreon_title")}
|
||||||
|
</h3>
|
||||||
|
<p className="text-sm mb-4" style={{ color: "var(--accent-primary)" }}>
|
||||||
|
{t("donate_patreon_type")}
|
||||||
|
</p>
|
||||||
|
<p className="text-sm leading-[1.7] mb-6" style={{ color: "var(--text-secondary)" }}>
|
||||||
|
{t("donate_patreon_desc")}
|
||||||
|
</p>
|
||||||
|
<a
|
||||||
|
href="https://patreon.com"
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener noreferrer"
|
||||||
|
className="inline-flex items-center gap-2 px-6 py-3 rounded-[10px] font-semibold text-sm transition-all"
|
||||||
|
style={{
|
||||||
|
background: "linear-gradient(135deg, var(--accent-primary), var(--accent-secondary))",
|
||||||
|
color: "var(--bg-primary)",
|
||||||
|
boxShadow: "0 4px 25px var(--accent-glow)",
|
||||||
|
}}
|
||||||
|
onMouseEnter={(e) => {
|
||||||
|
e.currentTarget.style.transform = "translateY(-2px)";
|
||||||
|
e.currentTarget.style.boxShadow = "0 8px 40px var(--accent-glow)";
|
||||||
|
}}
|
||||||
|
onMouseLeave={(e) => {
|
||||||
|
e.currentTarget.style.transform = "translateY(0)";
|
||||||
|
e.currentTarget.style.boxShadow = "0 4px 25px var(--accent-glow)";
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{t("donate_patreon_cta")}
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" fill="none" viewBox="0 0 24 24" strokeWidth={2} stroke="currentColor">
|
||||||
|
<path strokeLinecap="round" strokeLinejoin="round" d="M13.5 4.5L21 12m0 0l-7.5 7.5M21 12H3" />
|
||||||
|
</svg>
|
||||||
|
</a>
|
||||||
|
</motion.div>
|
||||||
|
|
||||||
|
{/* Ko-fi */}
|
||||||
|
<motion.div
|
||||||
|
className="rounded-[20px] p-8 card-hover-glow relative overflow-hidden"
|
||||||
|
style={{
|
||||||
|
background: "var(--bg-card)",
|
||||||
|
border: "1px solid var(--border-color)",
|
||||||
|
}}
|
||||||
|
initial={{ opacity: 0, y: 30 }}
|
||||||
|
whileInView={{ opacity: 1, y: 0 }}
|
||||||
|
viewport={{ once: true, margin: "-50px" }}
|
||||||
|
transition={{ duration: 0.6, ease: [0.16, 1, 0.3, 1], delay: 0.1 }}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
className="absolute top-0 left-0 right-0 h-[3px]"
|
||||||
|
style={{ background: "linear-gradient(90deg, var(--accent-primary), var(--accent-secondary))" }}
|
||||||
|
/>
|
||||||
|
<div
|
||||||
|
className="w-14 h-14 flex items-center justify-center rounded-2xl mb-6"
|
||||||
|
style={{
|
||||||
|
background: "linear-gradient(135deg, rgba(212, 165, 116, 0.15), rgba(212, 165, 116, 0.05))",
|
||||||
|
color: "var(--accent-primary)",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" strokeWidth={1.5} stroke="currentColor" className="w-7 h-7">
|
||||||
|
<path strokeLinecap="round" strokeLinejoin="round" d="M15.75 10.5V6a3.75 3.75 0 10-7.5 0v4.5m11.356-1.993l1.263 12c.07.665-.45 1.243-1.119 1.243H4.25a1.125 1.125 0 01-1.12-1.243l1.264-12A1.125 1.125 0 015.513 7.5h12.974c.576 0 1.059.435 1.119 1.007zM8.625 10.5a.375.375 0 11-.75 0 .375.375 0 01.75 0zm7.5 0a.375.375 0 11-.75 0 .375.375 0 01.75 0z" />
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
<h3 className="text-xl font-bold mb-1" style={{ fontFamily: "var(--font-display)" }}>
|
||||||
|
{t("donate_kofi_title")}
|
||||||
|
</h3>
|
||||||
|
<p className="text-sm mb-4" style={{ color: "var(--accent-primary)" }}>
|
||||||
|
{t("donate_kofi_type")}
|
||||||
|
</p>
|
||||||
|
<p className="text-sm leading-[1.7] mb-6" style={{ color: "var(--text-secondary)" }}>
|
||||||
|
{t("donate_kofi_desc")}
|
||||||
|
</p>
|
||||||
|
<a
|
||||||
|
href="https://ko-fi.com"
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener noreferrer"
|
||||||
|
className="inline-flex items-center gap-2 px-6 py-3 rounded-[10px] font-semibold text-sm transition-all"
|
||||||
|
style={{
|
||||||
|
background: "transparent",
|
||||||
|
color: "var(--text-primary)",
|
||||||
|
border: "1px solid var(--border-color)",
|
||||||
|
}}
|
||||||
|
onMouseEnter={(e) => {
|
||||||
|
e.currentTarget.style.borderColor = "var(--accent-primary)";
|
||||||
|
e.currentTarget.style.background = "rgba(212, 165, 116, 0.05)";
|
||||||
|
}}
|
||||||
|
onMouseLeave={(e) => {
|
||||||
|
e.currentTarget.style.borderColor = "var(--border-color)";
|
||||||
|
e.currentTarget.style.background = "transparent";
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{t("donate_kofi_cta")}
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" fill="none" viewBox="0 0 24 24" strokeWidth={2} stroke="currentColor">
|
||||||
|
<path strokeLinecap="round" strokeLinejoin="round" d="M13.5 4.5L21 12m0 0l-7.5 7.5M21 12H3" />
|
||||||
|
</svg>
|
||||||
|
</a>
|
||||||
|
</motion.div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Transparency */}
|
||||||
|
<motion.div
|
||||||
|
className="rounded-[20px] p-8 flex items-start gap-5"
|
||||||
|
style={{
|
||||||
|
background: "var(--bg-card)",
|
||||||
|
border: "1px solid var(--border-color)",
|
||||||
|
}}
|
||||||
|
initial={{ opacity: 0, y: 30 }}
|
||||||
|
whileInView={{ opacity: 1, y: 0 }}
|
||||||
|
viewport={{ once: true, margin: "-50px" }}
|
||||||
|
transition={{ duration: 0.6, ease: [0.16, 1, 0.3, 1], delay: 0.2 }}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
className="w-12 h-12 flex-shrink-0 flex items-center justify-center rounded-xl"
|
||||||
|
style={{
|
||||||
|
background: "linear-gradient(135deg, rgba(212, 165, 116, 0.15), rgba(212, 165, 116, 0.05))",
|
||||||
|
color: "var(--accent-primary)",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" strokeWidth={1.5} stroke="currentColor" className="w-6 h-6">
|
||||||
|
<path strokeLinecap="round" strokeLinejoin="round" d="M2.036 12.322a1.012 1.012 0 010-.639C3.423 7.51 7.36 4.5 12 4.5c4.638 0 8.573 3.007 9.963 7.178.07.207.07.431 0 .639C20.577 16.49 16.64 19.5 12 19.5c-4.638 0-8.573-3.007-9.963-7.178z" />
|
||||||
|
<path strokeLinecap="round" strokeLinejoin="round" d="M15 12a3 3 0 11-6 0 3 3 0 016 0z" />
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<h4 className="text-base font-semibold mb-1" style={{ color: "var(--text-primary)" }}>
|
||||||
|
{t("donate_transparency_title")}
|
||||||
|
</h4>
|
||||||
|
<p className="text-sm leading-[1.7]" style={{ color: "var(--text-secondary)" }}>
|
||||||
|
{t("donate_transparency_desc")}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</motion.div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
);
|
||||||
|
}
|
||||||
260
apps/web/src/components/home/GamesShowcaseSection.tsx
Normal file
260
apps/web/src/components/home/GamesShowcaseSection.tsx
Normal file
@@ -0,0 +1,260 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useState } from "react";
|
||||||
|
import { motion, AnimatePresence } from "framer-motion";
|
||||||
|
import { useTranslations, useLocale } from "next-intl";
|
||||||
|
import Link from "next/link";
|
||||||
|
import { getGradientFromTitle } from "@/components/shared/GameCard";
|
||||||
|
|
||||||
|
interface Game {
|
||||||
|
id: number;
|
||||||
|
title: string;
|
||||||
|
slug: string;
|
||||||
|
description: string;
|
||||||
|
genre: string;
|
||||||
|
releaseYear: number;
|
||||||
|
shutdownYear?: number;
|
||||||
|
developer: string;
|
||||||
|
publisher: string;
|
||||||
|
serverStatus: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
function getStatusConfig(status: string) {
|
||||||
|
switch (status) {
|
||||||
|
case "online":
|
||||||
|
return { label: "Servidor Online", color: "#22c55e", dot: "#22c55e" };
|
||||||
|
case "maintenance":
|
||||||
|
return { label: "Mantenimiento", color: "#f59e0b", dot: "#f59e0b" };
|
||||||
|
default:
|
||||||
|
return { label: "Próximamente", color: "#6b6b75", dot: "#6b6b75" };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function GamesShowcaseSection({ games }: { games: Game[] }) {
|
||||||
|
const t = useTranslations("home");
|
||||||
|
const locale = useLocale();
|
||||||
|
const [current, setCurrent] = useState(0);
|
||||||
|
|
||||||
|
if (!games || games.length === 0) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const game = games[current];
|
||||||
|
const status = getStatusConfig(game.serverStatus);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<section className="py-24 px-4" style={{ background: "var(--bg-primary)" }}>
|
||||||
|
<div className="max-w-[1200px] mx-auto">
|
||||||
|
{/* Header */}
|
||||||
|
<motion.div
|
||||||
|
className="text-center max-w-[700px] mx-auto mb-16"
|
||||||
|
initial={{ opacity: 0, y: 30 }}
|
||||||
|
whileInView={{ opacity: 1, y: 0 }}
|
||||||
|
viewport={{ once: true, margin: "-50px" }}
|
||||||
|
transition={{ duration: 0.6, ease: [0.16, 1, 0.3, 1] }}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
className="text-sm font-semibold uppercase tracking-[0.15em] mb-4"
|
||||||
|
style={{ fontFamily: "var(--font-display)", color: "var(--accent-primary)" }}
|
||||||
|
>
|
||||||
|
{t("games_label")}
|
||||||
|
</div>
|
||||||
|
<h2
|
||||||
|
className="text-[clamp(2rem,4vw,3rem)] font-extrabold mb-4"
|
||||||
|
style={{ fontFamily: "var(--font-display)", letterSpacing: "-0.02em" }}
|
||||||
|
>
|
||||||
|
{t("games_title")}
|
||||||
|
<span style={{ color: "var(--accent-primary)" }}>{t("games_title_span")}</span>
|
||||||
|
</h2>
|
||||||
|
<p className="text-[1.1rem] leading-[1.7]" style={{ color: "var(--text-secondary)" }}>
|
||||||
|
{t("games_subtitle")}
|
||||||
|
</p>
|
||||||
|
</motion.div>
|
||||||
|
|
||||||
|
{/* Carousel */}
|
||||||
|
<div className="relative">
|
||||||
|
<AnimatePresence mode="wait">
|
||||||
|
<motion.div
|
||||||
|
key={game.id}
|
||||||
|
initial={{ opacity: 0, x: 40 }}
|
||||||
|
animate={{ opacity: 1, x: 0 }}
|
||||||
|
exit={{ opacity: 0, x: -40 }}
|
||||||
|
transition={{ duration: 0.5, ease: [0.16, 1, 0.3, 1] }}
|
||||||
|
className="grid grid-cols-1 lg:grid-cols-2 gap-10 items-center"
|
||||||
|
>
|
||||||
|
{/* Content */}
|
||||||
|
<div>
|
||||||
|
<div
|
||||||
|
className="inline-flex items-center gap-2 px-3 py-1.5 rounded-full text-xs font-medium mb-6"
|
||||||
|
style={{
|
||||||
|
background: `${status.color}15`,
|
||||||
|
border: `1px solid ${status.color}30`,
|
||||||
|
color: status.color,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<span className="w-2 h-2 rounded-full" style={{ background: status.dot }} />
|
||||||
|
{status.label}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<h3
|
||||||
|
className="text-[clamp(1.75rem,3vw,2.5rem)] font-extrabold mb-4"
|
||||||
|
style={{ fontFamily: "var(--font-display)", letterSpacing: "-0.02em" }}
|
||||||
|
>
|
||||||
|
{game.title}
|
||||||
|
</h3>
|
||||||
|
|
||||||
|
<p className="text-base leading-[1.7] mb-4" style={{ color: "var(--text-secondary)" }}>
|
||||||
|
{game.description}
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<div className="flex flex-wrap gap-2 mb-8">
|
||||||
|
<span className="px-3 py-1 rounded-lg text-xs font-medium" style={{ background: "var(--bg-card)", border: "1px solid var(--border-color)", color: "var(--text-secondary)" }}>
|
||||||
|
{game.genre}
|
||||||
|
</span>
|
||||||
|
<span className="px-3 py-1 rounded-lg text-xs font-medium" style={{ background: "var(--bg-card)", border: "1px solid var(--border-color)", color: "var(--text-secondary)" }}>
|
||||||
|
{game.releaseYear} - {game.shutdownYear || "?"}
|
||||||
|
</span>
|
||||||
|
<span className="px-3 py-1 rounded-lg text-xs font-medium" style={{ background: "var(--bg-card)", border: "1px solid var(--border-color)", color: "var(--text-secondary)" }}>
|
||||||
|
{game.publisher}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Link
|
||||||
|
href={`/${locale}/games/${game.slug}`}
|
||||||
|
className="inline-flex items-center gap-2 px-6 py-3 rounded-[10px] font-semibold text-sm transition-all"
|
||||||
|
style={{
|
||||||
|
background: "linear-gradient(135deg, var(--accent-primary), var(--accent-secondary))",
|
||||||
|
color: "var(--bg-primary)",
|
||||||
|
boxShadow: "0 4px 25px var(--accent-glow)",
|
||||||
|
}}
|
||||||
|
onMouseEnter={(e) => {
|
||||||
|
e.currentTarget.style.transform = "translateY(-2px)";
|
||||||
|
e.currentTarget.style.boxShadow = "0 8px 40px var(--accent-glow)";
|
||||||
|
}}
|
||||||
|
onMouseLeave={(e) => {
|
||||||
|
e.currentTarget.style.transform = "translateY(0)";
|
||||||
|
e.currentTarget.style.boxShadow = "0 4px 25px var(--accent-glow)";
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Ver Documental
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" fill="none" viewBox="0 0 24 24" strokeWidth={2} stroke="currentColor">
|
||||||
|
<path strokeLinecap="round" strokeLinejoin="round" d="M13.5 4.5L21 12m0 0l-7.5 7.5M21 12H3" />
|
||||||
|
</svg>
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Card */}
|
||||||
|
<div
|
||||||
|
className="rounded-[24px] overflow-hidden"
|
||||||
|
style={{
|
||||||
|
background: "var(--bg-card)",
|
||||||
|
border: "1px solid var(--border-color)",
|
||||||
|
boxShadow: "0 20px 60px rgba(0, 0, 0, 0.4)",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
className="h-48 flex items-center justify-center relative"
|
||||||
|
style={{ background: getGradientFromTitle(game.title) }}
|
||||||
|
>
|
||||||
|
<span
|
||||||
|
className="text-3xl font-extrabold opacity-30"
|
||||||
|
style={{ fontFamily: "var(--font-display)" }}
|
||||||
|
>
|
||||||
|
{game.title}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div className="p-6">
|
||||||
|
<h4 className="text-lg font-bold mb-1" style={{ fontFamily: "var(--font-display)" }}>
|
||||||
|
{game.title}
|
||||||
|
</h4>
|
||||||
|
<p className="text-sm mb-4" style={{ color: "var(--text-muted)" }}>
|
||||||
|
{game.developer} / {game.publisher}
|
||||||
|
</p>
|
||||||
|
<div className="flex items-center gap-4 text-xs" style={{ color: "var(--text-muted)" }}>
|
||||||
|
<div className="flex items-center gap-1.5">
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" width="14" height="14" fill="none" viewBox="0 0 24 24" strokeWidth={2} stroke="currentColor">
|
||||||
|
<path strokeLinecap="round" strokeLinejoin="round" d="M9.568 3H5.25A2.25 2.25 0 003 5.25v4.318c0 .597.237 1.17.659 1.591l9.581 9.581c.699.699 1.78.872 2.607.33a18.095 18.095 0 005.223-5.223c.542-.827.369-1.908-.33-2.607L11.16 3.66A2.25 2.25 0 009.568 3z" />
|
||||||
|
</svg>
|
||||||
|
{game.genre}
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-1.5">
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" width="14" height="14" fill="none" viewBox="0 0 24 24" strokeWidth={2} stroke="currentColor">
|
||||||
|
<path strokeLinecap="round" strokeLinejoin="round" d="M6.75 3v2.25M17.25 3v2.25M3 18.75V7.5a2.25 2.25 0 012.25-2.25h13.5A2.25 2.25 0 0121 7.5v11.25m-18 0A2.25 2.25 0 005.25 21h13.5A2.25 2.25 0 0021 18.75m-18 0v-7.5A2.25 2.25 0 015.25 9h13.5A2.25 2.25 0 0121 11.25v7.5" />
|
||||||
|
</svg>
|
||||||
|
{game.releaseYear} - {game.shutdownYear || "?"}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</motion.div>
|
||||||
|
</AnimatePresence>
|
||||||
|
|
||||||
|
{/* Navigation */}
|
||||||
|
<div className="flex items-center justify-center gap-4 mt-10">
|
||||||
|
<button
|
||||||
|
onClick={() => setCurrent((prev) => (prev === 0 ? games.length - 1 : prev - 1))}
|
||||||
|
className="p-3 rounded-xl transition-colors"
|
||||||
|
style={{
|
||||||
|
background: "var(--bg-card)",
|
||||||
|
border: "1px solid var(--border-color)",
|
||||||
|
color: "var(--text-secondary)",
|
||||||
|
}}
|
||||||
|
onMouseEnter={(e) => {
|
||||||
|
e.currentTarget.style.borderColor = "var(--accent-primary)";
|
||||||
|
e.currentTarget.style.color = "var(--text-primary)";
|
||||||
|
}}
|
||||||
|
onMouseLeave={(e) => {
|
||||||
|
e.currentTarget.style.borderColor = "var(--border-color)";
|
||||||
|
e.currentTarget.style.color = "var(--text-secondary)";
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" fill="none" viewBox="0 0 24 24" strokeWidth={2} stroke="currentColor">
|
||||||
|
<path strokeLinecap="round" strokeLinejoin="round" d="M15.75 19.5L8.25 12l7.5-7.5" />
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<div className="flex gap-2">
|
||||||
|
{games.map((_, index) => (
|
||||||
|
<button
|
||||||
|
key={index}
|
||||||
|
onClick={() => setCurrent(index)}
|
||||||
|
className="w-2.5 h-2.5 rounded-full transition-all"
|
||||||
|
style={{
|
||||||
|
background: index === current ? "var(--accent-primary)" : "var(--bg-elevated)",
|
||||||
|
transform: index === current ? "scale(1.3)" : "scale(1)",
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<span className="text-sm font-medium min-w-[3rem] text-center" style={{ color: "var(--text-muted)" }}>
|
||||||
|
{current + 1} / {games.length}
|
||||||
|
</span>
|
||||||
|
|
||||||
|
<button
|
||||||
|
onClick={() => setCurrent((prev) => (prev === games.length - 1 ? 0 : prev + 1))}
|
||||||
|
className="p-3 rounded-xl transition-colors"
|
||||||
|
style={{
|
||||||
|
background: "var(--bg-card)",
|
||||||
|
border: "1px solid var(--border-color)",
|
||||||
|
color: "var(--text-secondary)",
|
||||||
|
}}
|
||||||
|
onMouseEnter={(e) => {
|
||||||
|
e.currentTarget.style.borderColor = "var(--accent-primary)";
|
||||||
|
e.currentTarget.style.color = "var(--text-primary)";
|
||||||
|
}}
|
||||||
|
onMouseLeave={(e) => {
|
||||||
|
e.currentTarget.style.borderColor = "var(--border-color)";
|
||||||
|
e.currentTarget.style.color = "var(--text-secondary)";
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" fill="none" viewBox="0 0 24 24" strokeWidth={2} stroke="currentColor">
|
||||||
|
<path strokeLinecap="round" strokeLinejoin="round" d="M8.25 4.5l7.5 7.5-7.5 7.5" />
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,52 +1,196 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { useTranslations } from "next-intl";
|
|
||||||
import { motion } from "framer-motion";
|
import { motion } from "framer-motion";
|
||||||
|
import { useTranslations, useLocale } from "next-intl";
|
||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
import { useLocale } from "next-intl";
|
import { useEffect, useRef } from "react";
|
||||||
|
|
||||||
|
function Particles() {
|
||||||
|
const containerRef = useRef<HTMLDivElement>(null);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const container = containerRef.current;
|
||||||
|
if (!container) return;
|
||||||
|
|
||||||
|
// Create particles
|
||||||
|
for (let i = 0; i < 15; i++) {
|
||||||
|
const particle = document.createElement("div");
|
||||||
|
particle.style.position = "absolute";
|
||||||
|
particle.style.width = "4px";
|
||||||
|
particle.style.height = "4px";
|
||||||
|
particle.style.background = "var(--accent-primary)";
|
||||||
|
particle.style.borderRadius = "50%";
|
||||||
|
particle.style.opacity = "0";
|
||||||
|
particle.style.left = `${Math.random() * 100}%`;
|
||||||
|
particle.style.animation = `particleFloat ${10 + Math.random() * 10}s infinite`;
|
||||||
|
particle.style.animationDelay = `${Math.random() * 15}s`;
|
||||||
|
container.appendChild(particle);
|
||||||
|
}
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
while (container.firstChild) {
|
||||||
|
container.removeChild(container.firstChild);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
return <div ref={containerRef} className="absolute inset-0 overflow-hidden pointer-events-none" />;
|
||||||
|
}
|
||||||
|
|
||||||
export function HeroSection() {
|
export function HeroSection() {
|
||||||
const t = useTranslations("home");
|
const t = useTranslations("home");
|
||||||
const locale = useLocale();
|
const locale = useLocale();
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<section className="relative min-h-[80vh] flex items-center justify-center overflow-hidden">
|
<section className="relative min-h-[80vh] flex items-center overflow-hidden" style={{ padding: "10rem 2rem 6rem" }}>
|
||||||
<div className="absolute inset-0 bg-gradient-to-b from-blue-950/20 via-gray-950 to-gray-950" />
|
{/* Background layers */}
|
||||||
<div className="relative z-10 text-center px-4 max-w-4xl mx-auto">
|
<div className="absolute inset-0 -z-10">
|
||||||
<motion.h1
|
{/* Dot grid */}
|
||||||
initial={{ opacity: 0, y: 20 }}
|
<div
|
||||||
animate={{ opacity: 1, y: 0 }}
|
className="absolute inset-0 dot-grid-fade"
|
||||||
transition={{ duration: 0.8 }}
|
style={{
|
||||||
className="text-5xl md:text-7xl font-bold tracking-tight mb-6"
|
backgroundImage: `
|
||||||
>
|
linear-gradient(rgba(212, 165, 116, 0.03) 1px, transparent 1px),
|
||||||
{t("hero_title")}
|
linear-gradient(90deg, rgba(212, 165, 116, 0.03) 1px, transparent 1px)
|
||||||
</motion.h1>
|
`,
|
||||||
<motion.p
|
backgroundSize: "60px 60px",
|
||||||
initial={{ opacity: 0, y: 20 }}
|
maskImage: "radial-gradient(ellipse at center, black 20%, transparent 70%)",
|
||||||
animate={{ opacity: 1, y: 0 }}
|
WebkitMaskImage: "radial-gradient(ellipse at center, black 20%, transparent 70%)",
|
||||||
transition={{ duration: 0.8, delay: 0.2 }}
|
}}
|
||||||
className="text-xl md:text-2xl text-gray-400 mb-10"
|
/>
|
||||||
>
|
{/* Primary gradient */}
|
||||||
{t("hero_subtitle")}
|
<div
|
||||||
</motion.p>
|
className="absolute gradient-pulse"
|
||||||
|
style={{
|
||||||
|
top: "-50%",
|
||||||
|
right: "-20%",
|
||||||
|
width: "80%",
|
||||||
|
height: "150%",
|
||||||
|
background: "radial-gradient(circle, rgba(212, 165, 116, 0.08) 0%, transparent 60%)",
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
{/* Secondary gradient */}
|
||||||
|
<div
|
||||||
|
className="absolute gradient-pulse"
|
||||||
|
style={{
|
||||||
|
top: "50%",
|
||||||
|
left: "-30%",
|
||||||
|
right: "auto",
|
||||||
|
background: "radial-gradient(circle, rgba(212, 165, 116, 0.05) 0%, transparent 50%)",
|
||||||
|
animationDelay: "-4s",
|
||||||
|
width: "80%",
|
||||||
|
height: "150%",
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
{/* Particles */}
|
||||||
|
<Particles />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="max-w-[1200px] mx-auto w-full text-center">
|
||||||
<motion.div
|
<motion.div
|
||||||
initial={{ opacity: 0, y: 20 }}
|
initial={{ opacity: 0, y: 40 }}
|
||||||
animate={{ opacity: 1, y: 0 }}
|
animate={{ opacity: 1, y: 0 }}
|
||||||
transition={{ duration: 0.8, delay: 0.4 }}
|
transition={{ duration: 1, ease: [0.16, 1, 0.3, 1], delay: 0.2 }}
|
||||||
className="flex gap-4 justify-center"
|
|
||||||
>
|
>
|
||||||
<Link
|
{/* Icon */}
|
||||||
href={`/${locale}/catalog`}
|
<div
|
||||||
className="px-8 py-3 bg-white text-black font-semibold rounded-lg hover:bg-gray-200 transition-colors"
|
className="w-[100px] h-[100px] mx-auto mb-8 float-animation flex items-center justify-center rounded-[30px]"
|
||||||
|
style={{
|
||||||
|
background: "linear-gradient(135deg, var(--bg-card), var(--bg-secondary))",
|
||||||
|
border: "1px solid var(--border-color)",
|
||||||
|
boxShadow: "0 20px 60px rgba(0, 0, 0, 0.5)",
|
||||||
|
}}
|
||||||
>
|
>
|
||||||
{t("view_all")}
|
<svg width="50" height="50" viewBox="0 0 24 24" fill="none" stroke="var(--accent-primary)" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round">
|
||||||
</Link>
|
<path d="M12 21a9.004 9.004 0 008.716-6.747M12 21a9.004 9.004 0 01-8.716-6.747M12 21c2.485 0 4.5-4.03 4.5-9S14.485 3 12 3m0 18c-2.485 0-4.5-4.03-4.5-9S9.515 3 12 3" />
|
||||||
<Link
|
</svg>
|
||||||
href={`/${locale}/donate`}
|
</div>
|
||||||
className="px-8 py-3 border border-white/20 text-white font-semibold rounded-lg hover:bg-white/10 transition-colors"
|
|
||||||
|
{/* Badge */}
|
||||||
|
<div
|
||||||
|
className="inline-flex items-center gap-2 px-4 py-2 rounded-full text-sm mb-6"
|
||||||
|
style={{
|
||||||
|
background: "rgba(212, 165, 116, 0.1)",
|
||||||
|
border: "1px solid rgba(212, 165, 116, 0.2)",
|
||||||
|
color: "var(--accent-secondary)",
|
||||||
|
}}
|
||||||
>
|
>
|
||||||
{t("donate_cta")}
|
<span className="w-2 h-2 rounded-full animate-blink" style={{ background: "var(--accent-primary)" }} />
|
||||||
</Link>
|
{t("hero_badge")}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Title */}
|
||||||
|
<h1
|
||||||
|
className="text-[clamp(2.5rem,5vw,4rem)] font-extrabold leading-[1.1] mb-4"
|
||||||
|
style={{ fontFamily: "var(--font-display)", letterSpacing: "-0.03em" }}
|
||||||
|
>
|
||||||
|
<span className="accent-gradient-text">{t("hero_title")}</span>
|
||||||
|
</h1>
|
||||||
|
|
||||||
|
{/* Tagline */}
|
||||||
|
<p
|
||||||
|
className="text-[1.35rem] font-semibold mb-6"
|
||||||
|
style={{
|
||||||
|
fontFamily: "var(--font-display)",
|
||||||
|
color: "var(--accent-secondary)",
|
||||||
|
letterSpacing: "-0.01em",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{t("hero_tagline")}
|
||||||
|
</p>
|
||||||
|
|
||||||
|
{/* Subtitle */}
|
||||||
|
<p
|
||||||
|
className="text-[1.15rem] max-w-[750px] mx-auto mb-10 leading-[1.7]"
|
||||||
|
style={{ color: "var(--text-secondary)" }}
|
||||||
|
>
|
||||||
|
{t("hero_subtitle")}
|
||||||
|
</p>
|
||||||
|
|
||||||
|
{/* Buttons */}
|
||||||
|
<div className="flex gap-4 justify-center flex-wrap">
|
||||||
|
<Link
|
||||||
|
href={`/${locale}/catalog`}
|
||||||
|
className="inline-flex items-center gap-2 px-8 py-4 rounded-[10px] font-semibold text-[0.95rem] transition-all"
|
||||||
|
style={{
|
||||||
|
background: "linear-gradient(135deg, var(--accent-primary), var(--accent-secondary))",
|
||||||
|
color: "var(--bg-primary)",
|
||||||
|
boxShadow: "0 4px 25px var(--accent-glow)",
|
||||||
|
}}
|
||||||
|
onMouseEnter={(e) => {
|
||||||
|
e.currentTarget.style.transform = "translateY(-3px)";
|
||||||
|
e.currentTarget.style.boxShadow = "0 8px 40px var(--accent-glow)";
|
||||||
|
}}
|
||||||
|
onMouseLeave={(e) => {
|
||||||
|
e.currentTarget.style.transform = "translateY(0)";
|
||||||
|
e.currentTarget.style.boxShadow = "0 4px 25px var(--accent-glow)";
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{t("hero_cta_primary")}
|
||||||
|
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round">
|
||||||
|
<path d="M13.5 4.5L21 12m0 0l-7.5 7.5M21 12H3" />
|
||||||
|
</svg>
|
||||||
|
</Link>
|
||||||
|
<Link
|
||||||
|
href={`/${locale}/donate`}
|
||||||
|
className="inline-flex items-center gap-2 px-8 py-4 rounded-[10px] font-semibold text-[0.95rem] transition-all"
|
||||||
|
style={{
|
||||||
|
background: "transparent",
|
||||||
|
color: "var(--text-primary)",
|
||||||
|
border: "1px solid var(--border-color)",
|
||||||
|
}}
|
||||||
|
onMouseEnter={(e) => {
|
||||||
|
e.currentTarget.style.borderColor = "var(--accent-primary)";
|
||||||
|
e.currentTarget.style.background = "rgba(212, 165, 116, 0.05)";
|
||||||
|
}}
|
||||||
|
onMouseLeave={(e) => {
|
||||||
|
e.currentTarget.style.borderColor = "var(--border-color)";
|
||||||
|
e.currentTarget.style.background = "transparent";
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{t("hero_cta_secondary")}
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
</motion.div>
|
</motion.div>
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|||||||
@@ -1,5 +1,8 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
import { useTranslations } from "next-intl";
|
import { useTranslations } from "next-intl";
|
||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
|
import { motion } from "framer-motion";
|
||||||
import type { Game } from "@afterlife/shared";
|
import type { Game } from "@afterlife/shared";
|
||||||
import { GameCard } from "../shared/GameCard";
|
import { GameCard } from "../shared/GameCard";
|
||||||
|
|
||||||
@@ -13,20 +16,42 @@ export function LatestGames({ games, locale }: LatestGamesProps) {
|
|||||||
|
|
||||||
if (games.length === 0) return null;
|
if (games.length === 0) return null;
|
||||||
|
|
||||||
|
const cardVariants = {
|
||||||
|
hidden: { opacity: 0, y: 30 },
|
||||||
|
visible: (i: number) => ({
|
||||||
|
opacity: 1,
|
||||||
|
y: 0,
|
||||||
|
transition: {
|
||||||
|
delay: i * 0.1,
|
||||||
|
duration: 0.5,
|
||||||
|
ease: "easeOut" as const,
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<section className="max-w-7xl mx-auto px-4 py-20">
|
<section className="max-w-7xl mx-auto px-4 py-20">
|
||||||
<div className="flex items-center justify-between mb-10">
|
<div className="flex items-center justify-between mb-10">
|
||||||
<h2 className="text-3xl font-bold">{t("latest_games")}</h2>
|
<h2 className="text-3xl font-bold">{t("latest_games")}</h2>
|
||||||
<Link
|
<Link
|
||||||
href={`/${locale}/catalog`}
|
href={`/${locale}/catalog`}
|
||||||
className="text-sm text-gray-400 hover:text-white transition-colors"
|
className="text-sm text-[#a0a0a8] hover:text-[#d4a574] transition-colors"
|
||||||
>
|
>
|
||||||
{t("view_all")} →
|
{t("view_all")} →
|
||||||
</Link>
|
</Link>
|
||||||
</div>
|
</div>
|
||||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
|
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
|
||||||
{games.slice(0, 6).map((game) => (
|
{games.slice(0, 6).map((game, i) => (
|
||||||
<GameCard key={game.id} game={game} locale={locale} />
|
<motion.div
|
||||||
|
key={game.id}
|
||||||
|
custom={i}
|
||||||
|
initial="hidden"
|
||||||
|
whileInView="visible"
|
||||||
|
viewport={{ once: true, margin: "-50px" }}
|
||||||
|
variants={cardVariants}
|
||||||
|
>
|
||||||
|
<GameCard game={game} locale={locale} />
|
||||||
|
</motion.div>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|||||||
120
apps/web/src/components/home/PillarsSection.tsx
Normal file
120
apps/web/src/components/home/PillarsSection.tsx
Normal file
@@ -0,0 +1,120 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { motion } from "framer-motion";
|
||||||
|
import { useTranslations } from "next-intl";
|
||||||
|
|
||||||
|
const pillars = [
|
||||||
|
{
|
||||||
|
key: "servers",
|
||||||
|
icon: (
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" strokeWidth={1.5} stroke="currentColor" className="w-[30px] h-[30px]">
|
||||||
|
<path strokeLinecap="round" strokeLinejoin="round" d="M5.25 14.25h13.5m-13.5 0a3 3 0 01-3-3m3 3a3 3 0 100 6h13.5a3 3 0 100-6m-16.5-3a3 3 0 013-3h13.5a3 3 0 013 3m-19.5 0a4.5 4.5 0 01.9-2.7L5.737 5.1a3.375 3.375 0 012.7-1.35h7.126c1.062 0 2.062.5 2.7 1.35l2.587 3.45a4.5 4.5 0 01.9 2.7m0 0a3 3 0 01-3 3m0 3h.008v.008h-.008v-.008zm0-6h.008v.008h-.008v-.008zm-3 6h.008v.008h-.008v-.008zm0-6h.008v.008h-.008v-.008z" />
|
||||||
|
</svg>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: "documentaries",
|
||||||
|
icon: (
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" strokeWidth={1.5} stroke="currentColor" className="w-[30px] h-[30px]">
|
||||||
|
<path strokeLinecap="round" strokeLinejoin="round" d="M3.375 19.5h17.25m-17.25 0a1.125 1.125 0 01-1.125-1.125M3.375 19.5h1.5C5.496 19.5 6 18.996 6 18.375m-3.75 0V5.625m0 12.75v-1.5c0-.621.504-1.125 1.125-1.125m18.375 2.625V5.625m0 12.75c0 .621-.504 1.125-1.125 1.125m1.125-1.125v-1.5c0-.621-.504-1.125-1.125-1.125m0 3.75h-1.5A1.125 1.125 0 0118 18.375M20.625 4.5H3.375m17.25 0c.621 0 1.125.504 1.125 1.125M20.625 4.5h-1.5C18.504 4.5 18 5.004 18 5.625m3.75 0v1.5c0 .621-.504 1.125-1.125 1.125M3.375 4.5c-.621 0-1.125.504-1.125 1.125M3.375 4.5h1.5C5.496 4.5 6 5.004 6 5.625m-3.75 0v1.5c0 .621.504 1.125 1.125 1.125m0 0h1.5m-1.5 0c-.621 0-1.125.504-1.125 1.125v1.5c0 .621.504 1.125 1.125 1.125m1.5-3.75C5.496 8.25 6 7.746 6 7.125v-1.5M4.875 8.25C5.496 8.25 6 8.754 6 9.375v1.5m0-5.25v5.25m0-5.25C6 5.004 6.504 4.5 7.125 4.5h9.75c.621 0 1.125.504 1.125 1.125m1.125 2.625h1.5m-1.5 0A1.125 1.125 0 0118 7.125v-1.5m1.125 2.625c-.621 0-1.125.504-1.125 1.125v1.5m2.625-2.625c.621 0 1.125.504 1.125 1.125v1.5c0 .621-.504 1.125-1.125 1.125M18 5.625v5.25M7.125 12h9.75m-9.75 0A1.125 1.125 0 016 10.875M7.125 12C6.504 12 6 12.504 6 13.125m0-2.25C6 11.496 5.496 12 4.875 12M18 10.875c0 .621.504 1.125-1.125 1.125M18 10.875c0 .621.504 1.125 1.125 1.125m-2.25 0c.621 0 1.125.504 1.125 1.125m-12 5.25v-5.25m0 5.25c0 .621.504 1.125 1.125 1.125h9.75c.621 0 1.125-.504 1.125-1.125m-12 0v-1.5c0-.621-.504-1.125-1.125-1.125M18 18.375v-5.25m0 5.25v-1.5c0-.621.504-1.125 1.125-1.125M18 13.125v1.5c0 .621.504 1.125 1.125 1.125M18 13.125c0-.621.504-1.125 1.125-1.125M6 13.125v1.5c0 .621.504 1.125-1.125 1.125M6 13.125C6 12.504 5.496 12 4.875 12m-1.5 0h1.5m-1.5 0c-.621 0-1.125.504-1.125 1.125v1.5c0 .621.504 1.125 1.125 1.125M19.125 12h1.5m0 0c.621 0 1.125.504 1.125 1.125v1.5c0 .621-.504 1.125-1.125 1.125m-17.25 0h1.5m14.25 0h1.5" />
|
||||||
|
</svg>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: "preservation",
|
||||||
|
icon: (
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" strokeWidth={1.5} stroke="currentColor" className="w-[30px] h-[30px]">
|
||||||
|
<path strokeLinecap="round" strokeLinejoin="round" d="M20.25 7.5l-.625 10.632a2.25 2.25 0 01-2.247 2.118H6.622a2.25 2.25 0 01-2.247-2.118L3.75 7.5m8.25 3v6.75m0 0l-3-3m3 3l3-3M3.375 7.5h17.25c.621 0 1.125-.504 1.125-1.125v-1.5c0-.621-.504-1.125-1.125-1.125H3.375c-.621 0-1.125.504-1.125 1.125v1.5c0 .621.504 1.125 1.125 1.125z" />
|
||||||
|
</svg>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
export function PillarsSection() {
|
||||||
|
const t = useTranslations("home");
|
||||||
|
|
||||||
|
return (
|
||||||
|
<section className="py-24 px-4" style={{ background: "var(--bg-secondary)" }}>
|
||||||
|
<div className="max-w-[1200px] mx-auto">
|
||||||
|
{/* Header */}
|
||||||
|
<motion.div
|
||||||
|
className="text-center max-w-[700px] mx-auto mb-16"
|
||||||
|
initial={{ opacity: 0, y: 30 }}
|
||||||
|
whileInView={{ opacity: 1, y: 0 }}
|
||||||
|
viewport={{ once: true, margin: "-50px" }}
|
||||||
|
transition={{ duration: 0.6, ease: [0.16, 1, 0.3, 1] }}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
className="text-sm font-semibold uppercase tracking-[0.15em] mb-4"
|
||||||
|
style={{ fontFamily: "var(--font-display)", color: "var(--accent-primary)" }}
|
||||||
|
>
|
||||||
|
{t("pillars_label")}
|
||||||
|
</div>
|
||||||
|
<h2
|
||||||
|
className="text-[clamp(2rem,4vw,3rem)] font-extrabold mb-4"
|
||||||
|
style={{ fontFamily: "var(--font-display)", letterSpacing: "-0.02em" }}
|
||||||
|
>
|
||||||
|
{t("pillars_title")}
|
||||||
|
<span style={{ color: "var(--accent-primary)" }}>{t("pillars_title_span")}</span>?
|
||||||
|
</h2>
|
||||||
|
<p className="text-[1.1rem] leading-[1.7]" style={{ color: "var(--text-secondary)" }}>
|
||||||
|
{t("pillars_subtitle")}
|
||||||
|
</p>
|
||||||
|
</motion.div>
|
||||||
|
|
||||||
|
{/* Grid */}
|
||||||
|
<div className="grid grid-cols-1 md:grid-cols-3 gap-6">
|
||||||
|
{pillars.map((pillar, index) => (
|
||||||
|
<motion.div
|
||||||
|
key={pillar.key}
|
||||||
|
className="relative overflow-hidden rounded-[20px] p-10 card-hover-glow"
|
||||||
|
style={{
|
||||||
|
background: "var(--bg-card)",
|
||||||
|
border: "1px solid var(--border-color)",
|
||||||
|
}}
|
||||||
|
initial={{ opacity: 0, y: 30 }}
|
||||||
|
whileInView={{ opacity: 1, y: 0 }}
|
||||||
|
viewport={{ once: true, margin: "-50px" }}
|
||||||
|
transition={{ duration: 0.6, ease: [0.16, 1, 0.3, 1], delay: index * 0.1 }}
|
||||||
|
>
|
||||||
|
{/* Top accent line */}
|
||||||
|
<div
|
||||||
|
className="absolute top-0 left-0 right-0 h-[3px] origin-left transition-transform duration-[400ms]"
|
||||||
|
style={{
|
||||||
|
background: "linear-gradient(90deg, var(--accent-primary), var(--accent-secondary))",
|
||||||
|
transform: "scaleX(0)",
|
||||||
|
}}
|
||||||
|
onMouseEnter={(e) => (e.currentTarget.style.transform = "scaleX(1)")}
|
||||||
|
onMouseLeave={(e) => (e.currentTarget.style.transform = "scaleX(0)")}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* Icon */}
|
||||||
|
<div
|
||||||
|
className="w-16 h-16 flex items-center justify-center rounded-2xl mb-6"
|
||||||
|
style={{
|
||||||
|
background: "linear-gradient(135deg, rgba(212, 165, 116, 0.15), rgba(212, 165, 116, 0.05))",
|
||||||
|
color: "var(--accent-primary)",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{pillar.icon}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Title */}
|
||||||
|
<h3
|
||||||
|
className="text-[1.3rem] font-bold mb-3"
|
||||||
|
style={{ fontFamily: "var(--font-display)" }}
|
||||||
|
>
|
||||||
|
{t(`pillar_${pillar.key}_title`)}
|
||||||
|
</h3>
|
||||||
|
|
||||||
|
{/* Description */}
|
||||||
|
<p className="text-[0.95rem] leading-[1.7]" style={{ color: "var(--text-secondary)" }}>
|
||||||
|
{t(`pillar_${pillar.key}_desc`)}
|
||||||
|
</p>
|
||||||
|
</motion.div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
);
|
||||||
|
}
|
||||||
140
apps/web/src/components/home/TechStackSection.tsx
Normal file
140
apps/web/src/components/home/TechStackSection.tsx
Normal file
@@ -0,0 +1,140 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { motion } from "framer-motion";
|
||||||
|
import { useTranslations } from "next-intl";
|
||||||
|
|
||||||
|
const columns = [
|
||||||
|
{
|
||||||
|
key: "frontend",
|
||||||
|
icon: (
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" strokeWidth={1.5} stroke="currentColor" className="w-6 h-6">
|
||||||
|
<path strokeLinecap="round" strokeLinejoin="round" d="M6.75 7.5l3 2.25-3 2.25m4.5 0h3m-9 8.25h13.5A2.25 2.25 0 0021 18V6a2.25 2.25 0 00-2.25-2.25H5.25A2.25 2.25 0 003 6v12a2.25 2.25 0 002.25 2.25z" />
|
||||||
|
</svg>
|
||||||
|
),
|
||||||
|
items: [
|
||||||
|
{ name: "Next.js 15", desc: "App Router, SSR/ISR" },
|
||||||
|
{ name: "TypeScript", desc: "Tipado seguro" },
|
||||||
|
{ name: "Tailwind CSS", desc: "Estilos utilitarios" },
|
||||||
|
{ name: "Framer Motion", desc: "Animaciones" },
|
||||||
|
{ name: "next-intl", desc: "i18n" },
|
||||||
|
{ name: "Howler.js", desc: "Audio player" },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: "backend",
|
||||||
|
icon: (
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" strokeWidth={1.5} stroke="currentColor" className="w-6 h-6">
|
||||||
|
<path strokeLinecap="round" strokeLinejoin="round" d="M5.25 14.25h13.5m-13.5 0a3 3 0 01-3-3m3 3a3 3 0 100 6h13.5a3 3 0 100-6m-16.5-3a3 3 0 013-3h13.5a3 3 0 013 3m-19.5 0a4.5 4.5 0 01.9-2.7L5.737 5.1a3.375 3.375 0 012.7-1.35h7.126c1.062 0 2.062.5 2.7 1.35l2.587 3.45a4.5 4.5 0 01.9 2.7m0 0a3 3 0 01-3 3m0 3h.008v.008h-.008v-.008zm0-6h.008v.008h-.008v-.008zm-3 6h.008v.008h-.008v-.008zm0-6h.008v.008h-.008v-.008z" />
|
||||||
|
</svg>
|
||||||
|
),
|
||||||
|
items: [
|
||||||
|
{ name: "Strapi 5", desc: "CMS Headless" },
|
||||||
|
{ name: "PostgreSQL", desc: "Base de datos" },
|
||||||
|
{ name: "MinIO", desc: "Almacenamiento de medios" },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: "infra",
|
||||||
|
icon: (
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" strokeWidth={1.5} stroke="currentColor" className="w-6 h-6">
|
||||||
|
<path strokeLinecap="round" strokeLinejoin="round" d="M2.25 15a4.5 4.5 0 004.5 4.5H18a3.75 3.75 0 001.332-7.257 3 3 0 00-3.758-3.848 5.25 5.25 0 00-10.233 2.33A4.502 4.502 0 002.25 15z" />
|
||||||
|
</svg>
|
||||||
|
),
|
||||||
|
items: [
|
||||||
|
{ name: "Docker", desc: "Docker Compose" },
|
||||||
|
{ name: "Nginx", desc: "Reverse proxy" },
|
||||||
|
{ name: "Self-Hosted", desc: "100% propio" },
|
||||||
|
{ name: "CI/CD", desc: "GitHub Actions" },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
export function TechStackSection() {
|
||||||
|
const t = useTranslations("home");
|
||||||
|
|
||||||
|
return (
|
||||||
|
<section className="py-24 px-4" style={{ background: "var(--bg-secondary)" }}>
|
||||||
|
<div className="max-w-[1200px] mx-auto">
|
||||||
|
{/* Header */}
|
||||||
|
<motion.div
|
||||||
|
className="text-center max-w-[700px] mx-auto mb-16"
|
||||||
|
initial={{ opacity: 0, y: 30 }}
|
||||||
|
whileInView={{ opacity: 1, y: 0 }}
|
||||||
|
viewport={{ once: true, margin: "-50px" }}
|
||||||
|
transition={{ duration: 0.6, ease: [0.16, 1, 0.3, 1] }}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
className="text-sm font-semibold uppercase tracking-[0.15em] mb-4"
|
||||||
|
style={{ fontFamily: "var(--font-display)", color: "var(--accent-primary)" }}
|
||||||
|
>
|
||||||
|
{t("stack_label")}
|
||||||
|
</div>
|
||||||
|
<h2
|
||||||
|
className="text-[clamp(2rem,4vw,3rem)] font-extrabold mb-4"
|
||||||
|
style={{ fontFamily: "var(--font-display)", letterSpacing: "-0.02em" }}
|
||||||
|
>
|
||||||
|
{t("stack_title")}
|
||||||
|
<span style={{ color: "var(--accent-primary)" }}>{t("stack_title_span")}</span>
|
||||||
|
</h2>
|
||||||
|
<p className="text-[1.1rem] leading-[1.7]" style={{ color: "var(--text-secondary)" }}>
|
||||||
|
{t("stack_subtitle")}
|
||||||
|
</p>
|
||||||
|
</motion.div>
|
||||||
|
|
||||||
|
{/* Grid */}
|
||||||
|
<div className="grid grid-cols-1 md:grid-cols-3 gap-6">
|
||||||
|
{columns.map((col, colIndex) => (
|
||||||
|
<motion.div
|
||||||
|
key={col.key}
|
||||||
|
className="rounded-[20px] p-8"
|
||||||
|
style={{
|
||||||
|
background: "var(--bg-card)",
|
||||||
|
border: "1px solid var(--border-color)",
|
||||||
|
}}
|
||||||
|
initial={{ opacity: 0, y: 30 }}
|
||||||
|
whileInView={{ opacity: 1, y: 0 }}
|
||||||
|
viewport={{ once: true, margin: "-50px" }}
|
||||||
|
transition={{ duration: 0.6, ease: [0.16, 1, 0.3, 1], delay: colIndex * 0.1 }}
|
||||||
|
>
|
||||||
|
{/* Header */}
|
||||||
|
<div className="flex items-center gap-3 mb-6">
|
||||||
|
<div
|
||||||
|
className="w-10 h-10 flex items-center justify-center rounded-lg"
|
||||||
|
style={{
|
||||||
|
background: "linear-gradient(135deg, rgba(212, 165, 116, 0.15), rgba(212, 165, 116, 0.05))",
|
||||||
|
color: "var(--accent-primary)",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{col.icon}
|
||||||
|
</div>
|
||||||
|
<h3 className="text-lg font-bold" style={{ fontFamily: "var(--font-display)" }}>
|
||||||
|
{t(`stack_${col.key}`)}
|
||||||
|
</h3>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Items */}
|
||||||
|
<div className="space-y-3">
|
||||||
|
{col.items.map((item, index) => (
|
||||||
|
<div key={index} className="flex items-center gap-3">
|
||||||
|
<div
|
||||||
|
className="w-2 h-2 rounded-full flex-shrink-0"
|
||||||
|
style={{ background: "var(--accent-primary)" }}
|
||||||
|
/>
|
||||||
|
<div className="flex-1 min-w-0">
|
||||||
|
<span className="text-sm font-medium" style={{ color: "var(--text-primary)" }}>
|
||||||
|
{item.name}
|
||||||
|
</span>
|
||||||
|
<span className="text-xs ml-2" style={{ color: "var(--text-muted)" }}>
|
||||||
|
{item.desc}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</motion.div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,12 +1,21 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
import { useTranslations } from "next-intl";
|
import { useTranslations } from "next-intl";
|
||||||
|
|
||||||
export function Footer() {
|
export function Footer() {
|
||||||
const t = useTranslations("footer");
|
const t = useTranslations("footer");
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<footer className="bg-black border-t border-white/10 py-8">
|
<footer style={{ borderTop: '1px solid var(--border-color)' }}>
|
||||||
<div className="max-w-7xl mx-auto px-4 text-center">
|
<div className="max-w-7xl mx-auto px-4 py-8">
|
||||||
<p className="text-sm text-gray-500">{t("rights")}</p>
|
<div className="flex flex-col sm:flex-row items-center justify-between gap-4">
|
||||||
|
<p className="text-sm" style={{ color: 'var(--text-muted)' }}>
|
||||||
|
{t("rights")}
|
||||||
|
</p>
|
||||||
|
<p className="text-sm" style={{ color: 'var(--text-muted)' }}>
|
||||||
|
2026 Consultoria AS. Todos los derechos reservados.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</footer>
|
</footer>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -17,17 +17,17 @@ export function LanguageSwitcher() {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<span className="text-sm text-gray-400">{t("language")}:</span>
|
<span className="text-sm text-[#a0a0a8]">{t("language")}:</span>
|
||||||
<button
|
<button
|
||||||
onClick={() => switchLocale("es")}
|
onClick={() => switchLocale("es")}
|
||||||
className={`text-sm ${locale === "es" ? "text-white font-bold" : "text-gray-400 hover:text-white"}`}
|
className={`text-sm ${locale === "es" ? "text-[#d4a574] font-bold" : "text-[#a0a0a8] hover:text-[#d4a574]"}`}
|
||||||
>
|
>
|
||||||
ES
|
ES
|
||||||
</button>
|
</button>
|
||||||
<span className="text-gray-600">|</span>
|
<span className="text-[#3a3a44]">|</span>
|
||||||
<button
|
<button
|
||||||
onClick={() => switchLocale("en")}
|
onClick={() => switchLocale("en")}
|
||||||
className={`text-sm ${locale === "en" ? "text-white font-bold" : "text-gray-400 hover:text-white"}`}
|
className={`text-sm ${locale === "en" ? "text-[#d4a574] font-bold" : "text-[#a0a0a8] hover:text-[#d4a574]"}`}
|
||||||
>
|
>
|
||||||
EN
|
EN
|
||||||
</button>
|
</button>
|
||||||
|
|||||||
@@ -1,40 +1,222 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
|
import { useState } from "react";
|
||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
import { useLocale, useTranslations } from "next-intl";
|
import { useLocale, useTranslations } from "next-intl";
|
||||||
|
import { useSession, signOut } from "next-auth/react";
|
||||||
import { LanguageSwitcher } from "./LanguageSwitcher";
|
import { LanguageSwitcher } from "./LanguageSwitcher";
|
||||||
|
|
||||||
|
function MenuIcon({ className }: { className?: string }) {
|
||||||
|
return (
|
||||||
|
<svg className={className} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round">
|
||||||
|
<line x1="3" y1="6" x2="21" y2="6" />
|
||||||
|
<line x1="3" y1="12" x2="21" y2="12" />
|
||||||
|
<line x1="3" y1="18" x2="21" y2="18" />
|
||||||
|
</svg>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function CloseIcon({ className }: { className?: string }) {
|
||||||
|
return (
|
||||||
|
<svg className={className} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round">
|
||||||
|
<line x1="18" y1="6" x2="6" y2="18" />
|
||||||
|
<line x1="6" y1="6" x2="18" y2="18" />
|
||||||
|
</svg>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
export function Navbar() {
|
export function Navbar() {
|
||||||
const t = useTranslations("nav");
|
const t = useTranslations("nav");
|
||||||
const locale = useLocale();
|
const locale = useLocale();
|
||||||
|
const { data: session, status } = useSession();
|
||||||
|
const [mobileOpen, setMobileOpen] = useState(false);
|
||||||
|
|
||||||
const links = [
|
const links = [
|
||||||
{ href: `/${locale}`, label: t("home") },
|
{ href: `/${locale}`, label: t("home") },
|
||||||
{ href: `/${locale}/catalog`, label: t("catalog") },
|
{ href: `/${locale}/catalog`, label: t("catalog") },
|
||||||
{ href: `/${locale}/afc`, label: t("afc") },
|
{ href: `/${locale}/server-status`, label: "Status" },
|
||||||
|
{ href: `/${locale}/guides`, label: "Guides" },
|
||||||
|
{ href: `/${locale}/community`, label: t("community") || "Community" },
|
||||||
{ href: `/${locale}/about`, label: t("about") },
|
{ href: `/${locale}/about`, label: t("about") },
|
||||||
{ href: `/${locale}/donate`, label: t("donate") },
|
{ href: `/${locale}/donate`, label: t("donate") },
|
||||||
|
{ href: `/${locale}/contact`, label: "Contact" },
|
||||||
];
|
];
|
||||||
|
|
||||||
|
const isAuthenticated = status === "authenticated";
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<nav className="fixed top-0 left-0 right-0 z-50 bg-black/90 backdrop-blur-sm border-b border-white/10">
|
<nav className="fixed top-0 left-0 right-0 z-50" style={{ background: 'rgba(10, 10, 15, 0.8)', backdropFilter: 'blur(20px)', borderBottom: '1px solid var(--border-color)' }}>
|
||||||
<div className="max-w-7xl mx-auto px-4 h-16 flex items-center justify-between">
|
<div className="max-w-7xl mx-auto px-4 h-16 flex items-center justify-between">
|
||||||
<Link href={`/${locale}`} className="text-xl font-bold text-white tracking-tight">
|
<Link
|
||||||
Project Afterlife
|
href={`/${locale}`}
|
||||||
|
className="text-xl font-bold tracking-tight"
|
||||||
|
style={{ fontFamily: 'var(--font-display)', color: 'var(--text-primary)' }}
|
||||||
|
>
|
||||||
|
Project<span style={{ color: 'var(--accent-primary)' }}>Afterlife</span>
|
||||||
</Link>
|
</Link>
|
||||||
<div className="flex items-center gap-6">
|
|
||||||
|
{/* Desktop nav */}
|
||||||
|
<div className="hidden lg:flex items-center gap-8">
|
||||||
{links.map((link) => (
|
{links.map((link) => (
|
||||||
<Link
|
<Link
|
||||||
key={link.href}
|
key={link.href}
|
||||||
href={link.href}
|
href={link.href}
|
||||||
className="text-sm text-gray-300 hover:text-white transition-colors"
|
className="text-sm font-medium transition-colors link-underline"
|
||||||
|
style={{ color: 'var(--text-secondary)' }}
|
||||||
|
onMouseEnter={(e) => (e.currentTarget.style.color = 'var(--text-primary)')}
|
||||||
|
onMouseLeave={(e) => (e.currentTarget.style.color = 'var(--text-secondary)')}
|
||||||
>
|
>
|
||||||
{link.label}
|
{link.label}
|
||||||
</Link>
|
</Link>
|
||||||
))}
|
))}
|
||||||
|
|
||||||
|
{isAuthenticated && session?.user ? (
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<Link
|
||||||
|
href={`/${locale}/profile`}
|
||||||
|
className="text-sm font-medium transition-colors link-underline"
|
||||||
|
style={{ color: 'var(--text-secondary)' }}
|
||||||
|
onMouseEnter={(e) => (e.currentTarget.style.color = 'var(--text-primary)')}
|
||||||
|
onMouseLeave={(e) => (e.currentTarget.style.color = 'var(--text-secondary)')}
|
||||||
|
>
|
||||||
|
{t("profile")}
|
||||||
|
</Link>
|
||||||
|
<button
|
||||||
|
onClick={() => signOut({ callbackUrl: `/${locale}` })}
|
||||||
|
className="text-sm font-medium transition-colors"
|
||||||
|
style={{ color: 'var(--text-secondary)' }}
|
||||||
|
onMouseEnter={(e) => (e.currentTarget.style.color = 'var(--text-primary)')}
|
||||||
|
onMouseLeave={(e) => (e.currentTarget.style.color = 'var(--text-secondary)')}
|
||||||
|
>
|
||||||
|
{t("logout")}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<Link
|
||||||
|
href={`/${locale}/login`}
|
||||||
|
className="text-sm font-semibold px-4 py-2 rounded-lg transition-all"
|
||||||
|
style={{
|
||||||
|
background: 'linear-gradient(135deg, var(--accent-primary), var(--accent-secondary))',
|
||||||
|
color: 'var(--bg-primary)',
|
||||||
|
boxShadow: '0 4px 20px var(--accent-glow)',
|
||||||
|
}}
|
||||||
|
onMouseEnter={(e) => {
|
||||||
|
e.currentTarget.style.transform = 'translateY(-2px)';
|
||||||
|
e.currentTarget.style.boxShadow = '0 8px 30px var(--accent-glow)';
|
||||||
|
}}
|
||||||
|
onMouseLeave={(e) => {
|
||||||
|
e.currentTarget.style.transform = 'translateY(0)';
|
||||||
|
e.currentTarget.style.boxShadow = '0 4px 20px var(--accent-glow)';
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{t("login")}
|
||||||
|
</Link>
|
||||||
|
)}
|
||||||
|
|
||||||
<LanguageSwitcher />
|
<LanguageSwitcher />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* Mobile hamburger */}
|
||||||
|
<button
|
||||||
|
onClick={() => setMobileOpen(!mobileOpen)}
|
||||||
|
className="lg:hidden p-2 transition-colors"
|
||||||
|
style={{ color: 'var(--text-primary)' }}
|
||||||
|
aria-label="Toggle menu"
|
||||||
|
>
|
||||||
|
{mobileOpen ? <CloseIcon className="w-6 h-6" /> : <MenuIcon className="w-6 h-6" />}
|
||||||
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* Mobile menu overlay */}
|
||||||
|
{mobileOpen && (
|
||||||
|
<div
|
||||||
|
className="lg:hidden absolute top-16 left-0 right-0"
|
||||||
|
style={{
|
||||||
|
background: 'rgba(10, 10, 15, 0.95)',
|
||||||
|
backdropFilter: 'blur(20px)',
|
||||||
|
borderBottom: '1px solid var(--border-color)',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div className="px-4 py-6 space-y-1">
|
||||||
|
{links.map((link) => (
|
||||||
|
<Link
|
||||||
|
key={link.href}
|
||||||
|
href={link.href}
|
||||||
|
onClick={() => setMobileOpen(false)}
|
||||||
|
className="block px-3 py-3 text-base font-medium rounded-lg transition-colors"
|
||||||
|
style={{ color: 'var(--text-secondary)' }}
|
||||||
|
onMouseEnter={(e) => {
|
||||||
|
e.currentTarget.style.color = 'var(--text-primary)';
|
||||||
|
e.currentTarget.style.background = 'rgba(255, 255, 255, 0.05)';
|
||||||
|
}}
|
||||||
|
onMouseLeave={(e) => {
|
||||||
|
e.currentTarget.style.color = 'var(--text-secondary)';
|
||||||
|
e.currentTarget.style.background = 'transparent';
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{link.label}
|
||||||
|
</Link>
|
||||||
|
))}
|
||||||
|
|
||||||
|
<div className="pt-4 mt-4" style={{ borderTop: '1px solid var(--border-color)' }}>
|
||||||
|
{isAuthenticated && session?.user ? (
|
||||||
|
<>
|
||||||
|
<Link
|
||||||
|
href={`/${locale}/profile`}
|
||||||
|
onClick={() => setMobileOpen(false)}
|
||||||
|
className="block px-3 py-3 text-base font-medium rounded-lg transition-colors"
|
||||||
|
style={{ color: 'var(--text-secondary)' }}
|
||||||
|
onMouseEnter={(e) => {
|
||||||
|
e.currentTarget.style.color = 'var(--text-primary)';
|
||||||
|
e.currentTarget.style.background = 'rgba(255, 255, 255, 0.05)';
|
||||||
|
}}
|
||||||
|
onMouseLeave={(e) => {
|
||||||
|
e.currentTarget.style.color = 'var(--text-secondary)';
|
||||||
|
e.currentTarget.style.background = 'transparent';
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{t("profile")}
|
||||||
|
</Link>
|
||||||
|
<button
|
||||||
|
onClick={() => {
|
||||||
|
setMobileOpen(false);
|
||||||
|
signOut({ callbackUrl: `/${locale}` });
|
||||||
|
}}
|
||||||
|
className="block w-full text-left px-3 py-3 text-base font-medium rounded-lg transition-colors"
|
||||||
|
style={{ color: 'var(--text-secondary)' }}
|
||||||
|
onMouseEnter={(e) => {
|
||||||
|
e.currentTarget.style.color = 'var(--text-primary)';
|
||||||
|
e.currentTarget.style.background = 'rgba(255, 255, 255, 0.05)';
|
||||||
|
}}
|
||||||
|
onMouseLeave={(e) => {
|
||||||
|
e.currentTarget.style.color = 'var(--text-secondary)';
|
||||||
|
e.currentTarget.style.background = 'transparent';
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{t("logout")}
|
||||||
|
</button>
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<Link
|
||||||
|
href={`/${locale}/login`}
|
||||||
|
onClick={() => setMobileOpen(false)}
|
||||||
|
className="block px-3 py-3 text-base font-semibold rounded-lg text-center transition-all"
|
||||||
|
style={{
|
||||||
|
background: 'linear-gradient(135deg, var(--accent-primary), var(--accent-secondary))',
|
||||||
|
color: 'var(--bg-primary)',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{t("login")}
|
||||||
|
</Link>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="pt-4 flex items-center justify-center gap-4">
|
||||||
|
<LanguageSwitcher />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</nav>
|
</nav>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
27
apps/web/src/components/live/LiveIndicator.tsx
Normal file
27
apps/web/src/components/live/LiveIndicator.tsx
Normal file
@@ -0,0 +1,27 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useEffect, useState } from "react";
|
||||||
|
|
||||||
|
export function LiveIndicator() {
|
||||||
|
const [connected, setConnected] = useState(false);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const es = new EventSource("/api/sse");
|
||||||
|
|
||||||
|
es.onopen = () => setConnected(true);
|
||||||
|
es.onerror = () => setConnected(false);
|
||||||
|
|
||||||
|
return () => es.close();
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<span className="inline-flex items-center gap-1.5 text-xs">
|
||||||
|
<span
|
||||||
|
className={`w-2 h-2 rounded-full ${connected ? "bg-emerald-400 animate-pulse" : "bg-[#3a3a44]"}`}
|
||||||
|
/>
|
||||||
|
<span className={`${connected ? "text-emerald-400" : "text-[#6b6b75]"}`}>
|
||||||
|
{connected ? "Live" : "Offline"}
|
||||||
|
</span>
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
}
|
||||||
56
apps/web/src/components/navigation/Breadcrumb.tsx
Normal file
56
apps/web/src/components/navigation/Breadcrumb.tsx
Normal file
@@ -0,0 +1,56 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import Link from "next/link";
|
||||||
|
import { useLocale } from "next-intl";
|
||||||
|
import { usePathname } from "next/navigation";
|
||||||
|
|
||||||
|
const routeLabels: Record<string, string> = {
|
||||||
|
catalog: "Catalog",
|
||||||
|
about: "About",
|
||||||
|
donate: "Donate",
|
||||||
|
community: "Community",
|
||||||
|
guides: "Guides",
|
||||||
|
contact: "Contact",
|
||||||
|
admin: "Admin",
|
||||||
|
"server-status": "Server Status",
|
||||||
|
games: "Games",
|
||||||
|
documentary: "Documentary",
|
||||||
|
};
|
||||||
|
|
||||||
|
export function Breadcrumb() {
|
||||||
|
const locale = useLocale();
|
||||||
|
const pathname = usePathname();
|
||||||
|
|
||||||
|
const segments = pathname.split("/").filter(Boolean).slice(1); // remove locale
|
||||||
|
if (segments.length === 0) return null;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<nav aria-label="Breadcrumb" className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 pt-4 pb-0">
|
||||||
|
<ol className="flex items-center gap-2 text-sm text-[#6b6b75]">
|
||||||
|
<li>
|
||||||
|
<Link href={`/${locale}`} className="hover:text-[#d4a574] transition-colors">
|
||||||
|
Home
|
||||||
|
</Link>
|
||||||
|
</li>
|
||||||
|
{segments.map((segment, i) => {
|
||||||
|
const isLast = i === segments.length - 1;
|
||||||
|
const href = `/${locale}/${segments.slice(0, i + 1).join("/")}`;
|
||||||
|
const label = routeLabels[segment] || segment;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<li key={segment + i} className="flex items-center gap-2">
|
||||||
|
<span className="text-[rgba(255,255,255,0.1)]">/</span>
|
||||||
|
{isLast ? (
|
||||||
|
<span className="text-[#a0a0a8] font-medium">{label}</span>
|
||||||
|
) : (
|
||||||
|
<Link href={href} className="hover:text-[#d4a574] transition-colors">
|
||||||
|
{label}
|
||||||
|
</Link>
|
||||||
|
)}
|
||||||
|
</li>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</ol>
|
||||||
|
</nav>
|
||||||
|
);
|
||||||
|
}
|
||||||
74
apps/web/src/components/search/SearchInput.tsx
Normal file
74
apps/web/src/components/search/SearchInput.tsx
Normal file
@@ -0,0 +1,74 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useState, useEffect, useCallback } from "react";
|
||||||
|
import { useRouter, usePathname, useSearchParams } from "next/navigation";
|
||||||
|
|
||||||
|
interface SearchInputProps {
|
||||||
|
placeholder?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function SearchInput({ placeholder = "Search..." }: SearchInputProps) {
|
||||||
|
const router = useRouter();
|
||||||
|
const pathname = usePathname();
|
||||||
|
const searchParams = useSearchParams();
|
||||||
|
const [query, setQuery] = useState(searchParams.get("search") || "");
|
||||||
|
const [debouncedQuery, setDebouncedQuery] = useState(query);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const timer = setTimeout(() => {
|
||||||
|
setDebouncedQuery(query);
|
||||||
|
}, 300);
|
||||||
|
return () => clearTimeout(timer);
|
||||||
|
}, [query]);
|
||||||
|
|
||||||
|
const updateSearch = useCallback(
|
||||||
|
(value: string) => {
|
||||||
|
const params = new URLSearchParams(searchParams.toString());
|
||||||
|
if (value) {
|
||||||
|
params.set("search", value);
|
||||||
|
} else {
|
||||||
|
params.delete("search");
|
||||||
|
}
|
||||||
|
router.push(`${pathname}?${params.toString()}`);
|
||||||
|
},
|
||||||
|
[router, pathname, searchParams]
|
||||||
|
);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
updateSearch(debouncedQuery);
|
||||||
|
}, [debouncedQuery, updateSearch]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="relative flex-1 min-w-[200px]">
|
||||||
|
<svg
|
||||||
|
className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-[#6b6b75]"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
fill="none"
|
||||||
|
stroke="currentColor"
|
||||||
|
strokeWidth="2"
|
||||||
|
strokeLinecap="round"
|
||||||
|
>
|
||||||
|
<circle cx="11" cy="11" r="8" />
|
||||||
|
<line x1="21" y1="21" x2="16.65" y2="16.65" />
|
||||||
|
</svg>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={query}
|
||||||
|
onChange={(e) => setQuery(e.target.value)}
|
||||||
|
placeholder={placeholder}
|
||||||
|
className="w-full bg-[#12121a] border border-[rgba(255,255,255,0.08)] rounded-lg pl-10 pr-4 py-2 text-sm text-[#f5f5f7] placeholder-[#6b6b75] focus:outline-none focus:border-[rgba(212,165,116,0.4)] focus:ring-1 focus:ring-[rgba(212,165,116,0.15)] transition-colors"
|
||||||
|
/>
|
||||||
|
{query && (
|
||||||
|
<button
|
||||||
|
onClick={() => setQuery("")}
|
||||||
|
className="absolute right-3 top-1/2 -translate-y-1/2 text-[#6b6b75] hover:text-[#a0a0a8]"
|
||||||
|
>
|
||||||
|
<svg className="w-4 h-4" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round">
|
||||||
|
<line x1="18" y1="6" x2="6" y2="18" />
|
||||||
|
<line x1="6" y1="6" x2="18" y2="18" />
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,42 +1,173 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
import Image from "next/image";
|
import Image from "next/image";
|
||||||
import type { Game } from "@afterlife/shared";
|
import type { Game } from "@afterlife/shared";
|
||||||
|
import { useServerStatus } from "@/hooks/useServerStatus";
|
||||||
|
import { getImageUrl } from "@/lib/images";
|
||||||
|
|
||||||
interface GameCardProps {
|
interface GameCardProps {
|
||||||
game: Game;
|
game: Game;
|
||||||
locale: string;
|
locale: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const statusConfig = {
|
||||||
|
online: {
|
||||||
|
bg: "rgba(34, 197, 94, 0.15)",
|
||||||
|
text: "#22c55e",
|
||||||
|
dot: "#22c55e",
|
||||||
|
label: "Online",
|
||||||
|
},
|
||||||
|
maintenance: {
|
||||||
|
bg: "rgba(245, 158, 11, 0.15)",
|
||||||
|
text: "#f59e0b",
|
||||||
|
dot: "#f59e0b",
|
||||||
|
label: "Maintenance",
|
||||||
|
},
|
||||||
|
coming_soon: {
|
||||||
|
bg: "rgba(107, 107, 117, 0.15)",
|
||||||
|
text: "#6b6b75",
|
||||||
|
dot: "#6b6b75",
|
||||||
|
label: "Coming soon",
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
function hashString(str: string): number {
|
||||||
|
let hash = 0;
|
||||||
|
for (let i = 0; i < str.length; i++) {
|
||||||
|
const char = str.charCodeAt(i);
|
||||||
|
hash = (hash << 5) - hash + char;
|
||||||
|
hash |= 0;
|
||||||
|
}
|
||||||
|
return Math.abs(hash);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getGradientFromTitle(title: string): string {
|
||||||
|
const hash = hashString(title);
|
||||||
|
const hues = [
|
||||||
|
[30, 45],
|
||||||
|
[45, 60],
|
||||||
|
[20, 35],
|
||||||
|
[35, 50],
|
||||||
|
[25, 40],
|
||||||
|
[40, 55],
|
||||||
|
];
|
||||||
|
const pair = hues[hash % hues.length];
|
||||||
|
const h1 = pair[0];
|
||||||
|
const h2 = pair[1];
|
||||||
|
const s1 = 35 + (hash % 20);
|
||||||
|
const s2 = 30 + ((hash >> 4) % 20);
|
||||||
|
const l1 = 15 + (hash % 10);
|
||||||
|
const l2 = 10 + ((hash >> 8) % 8);
|
||||||
|
return `linear-gradient(135deg, hsl(${h1} ${s1}% ${l1}%), hsl(${h2} ${s2}% ${l2}%))`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function getInitials(title: string): string {
|
||||||
|
return title
|
||||||
|
.split(/\s+/)
|
||||||
|
.map((w) => w[0])
|
||||||
|
.join("")
|
||||||
|
.slice(0, 2)
|
||||||
|
.toUpperCase();
|
||||||
|
}
|
||||||
|
|
||||||
export function GameCard({ game, locale }: GameCardProps) {
|
export function GameCard({ game, locale }: GameCardProps) {
|
||||||
const statusColors = {
|
const status = statusConfig[game.serverStatus];
|
||||||
online: "bg-green-500",
|
const serverHealth = useServerStatus(game.title);
|
||||||
maintenance: "bg-yellow-500",
|
const showRealPing = serverHealth && game.serverStatus === "online";
|
||||||
coming_soon: "bg-blue-500",
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Link href={`/${locale}/games/${game.slug}`} className="group block">
|
<Link href={`/${locale}/games/${game.slug}`} className="group block">
|
||||||
<div className="relative overflow-hidden rounded-lg bg-gray-900 border border-white/5 hover:border-white/20 transition-all">
|
<div
|
||||||
{game.coverImage && (
|
className="relative overflow-hidden rounded-xl transition-all duration-300"
|
||||||
<div className="relative aspect-[16/9] overflow-hidden">
|
style={{
|
||||||
<Image
|
background: "var(--bg-card)",
|
||||||
src={game.coverImage.url}
|
border: "1px solid var(--border-color)",
|
||||||
alt={game.coverImage.alternativeText || game.title}
|
boxShadow: "0 10px 40px rgba(0, 0, 0, 0.3)",
|
||||||
fill
|
}}
|
||||||
className="object-cover group-hover:scale-105 transition-transform duration-500"
|
onMouseEnter={(e) => {
|
||||||
/>
|
e.currentTarget.style.borderColor = "var(--border-hover)";
|
||||||
<div className="absolute inset-0 bg-gradient-to-t from-gray-900 via-transparent" />
|
e.currentTarget.style.transform = "translateY(-5px)";
|
||||||
|
e.currentTarget.style.boxShadow = "0 20px 60px rgba(0, 0, 0, 0.4)";
|
||||||
|
}}
|
||||||
|
onMouseLeave={(e) => {
|
||||||
|
e.currentTarget.style.borderColor = "var(--border-color)";
|
||||||
|
e.currentTarget.style.transform = "translateY(0)";
|
||||||
|
e.currentTarget.style.boxShadow = "0 10px 40px rgba(0, 0, 0, 0.3)";
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div className="relative aspect-[16/9] overflow-hidden">
|
||||||
|
{game.coverImage ? (
|
||||||
|
<>
|
||||||
|
<Image
|
||||||
|
src={getImageUrl(game.coverImage.url)}
|
||||||
|
alt={game.coverImage.alternativeText || game.title}
|
||||||
|
fill
|
||||||
|
unoptimized
|
||||||
|
className="object-cover group-hover:scale-105 transition-transform duration-500"
|
||||||
|
/>
|
||||||
|
<div
|
||||||
|
className="absolute inset-0"
|
||||||
|
style={{
|
||||||
|
background: "linear-gradient(to top, var(--bg-card), transparent)",
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<div
|
||||||
|
className="absolute inset-0 flex items-center justify-center"
|
||||||
|
style={{ background: getGradientFromTitle(game.title) }}
|
||||||
|
>
|
||||||
|
<span
|
||||||
|
className="text-5xl font-bold select-none tracking-tight"
|
||||||
|
style={{ color: "rgba(255, 255, 255, 0.15)", fontFamily: "var(--font-display)" }}
|
||||||
|
>
|
||||||
|
{getInitials(game.title)}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Status badge pill */}
|
||||||
|
<div className="absolute top-3 right-3">
|
||||||
|
<span
|
||||||
|
className="inline-flex items-center gap-1.5 px-2.5 py-1 rounded-full text-[11px] font-medium"
|
||||||
|
style={{
|
||||||
|
background: status.bg,
|
||||||
|
color: status.text,
|
||||||
|
border: `1px solid ${status.dot}30`,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<span className="w-1.5 h-1.5 rounded-full" style={{ background: status.dot }} />
|
||||||
|
{showRealPing ? `${serverHealth.latencyMs}ms` : status.label}
|
||||||
|
</span>
|
||||||
</div>
|
</div>
|
||||||
)}
|
</div>
|
||||||
|
|
||||||
<div className="p-4">
|
<div className="p-4">
|
||||||
<div className="flex items-center gap-2 mb-2">
|
<div className="flex items-center gap-2 mb-2.5">
|
||||||
<span className={`w-2 h-2 rounded-full ${statusColors[game.serverStatus]}`} />
|
<span
|
||||||
<span className="text-xs text-gray-400 uppercase tracking-wider">{game.genre}</span>
|
className="px-2 py-0.5 rounded-md text-[11px] uppercase tracking-wider font-medium"
|
||||||
|
style={{
|
||||||
|
background: "rgba(255, 255, 255, 0.05)",
|
||||||
|
border: "1px solid var(--border-color)",
|
||||||
|
color: "var(--text-muted)",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{game.genre}
|
||||||
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<h3 className="text-lg font-semibold text-white group-hover:text-blue-400 transition-colors">
|
<h3
|
||||||
|
className="text-lg font-semibold transition-colors duration-300 leading-snug"
|
||||||
|
style={{ color: "var(--text-primary)", fontFamily: "var(--font-display)" }}
|
||||||
|
onMouseEnter={(e) => (e.currentTarget.style.color = "var(--accent-primary)")}
|
||||||
|
onMouseLeave={(e) => (e.currentTarget.style.color = "var(--text-primary)")}
|
||||||
|
>
|
||||||
{game.title}
|
{game.title}
|
||||||
</h3>
|
</h3>
|
||||||
<p className="text-sm text-gray-500 mt-1">
|
<p className="text-xs mt-1.5 truncate" style={{ color: "var(--text-muted)" }}>
|
||||||
|
{game.developer}
|
||||||
|
</p>
|
||||||
|
<p className="text-sm mt-1" style={{ color: "var(--text-muted)" }}>
|
||||||
{game.releaseYear} – {game.shutdownYear}
|
{game.releaseYear} – {game.shutdownYear}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
12
apps/web/src/components/shared/GameCardSkeleton.tsx
Normal file
12
apps/web/src/components/shared/GameCardSkeleton.tsx
Normal file
@@ -0,0 +1,12 @@
|
|||||||
|
export function GameCardSkeleton() {
|
||||||
|
return (
|
||||||
|
<div className="rounded-xl bg-[#12121a] border border-[rgba(255,255,255,0.08)] overflow-hidden">
|
||||||
|
<div className="aspect-[16/9] bg-[#1a1a24] animate-pulse" />
|
||||||
|
<div className="p-4 space-y-3">
|
||||||
|
<div className="h-4 w-16 bg-[#1a1a24] rounded animate-pulse" />
|
||||||
|
<div className="h-5 w-3/4 bg-[#1a1a24] rounded animate-pulse" />
|
||||||
|
<div className="h-3 w-1/2 bg-[#1a1a24] rounded animate-pulse" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
119
apps/web/src/components/social/SocialShare.tsx
Normal file
119
apps/web/src/components/social/SocialShare.tsx
Normal file
@@ -0,0 +1,119 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useState } from "react";
|
||||||
|
import { useToast } from "@/hooks/useToast";
|
||||||
|
|
||||||
|
function TwitterIcon({ className }: { className?: string }) {
|
||||||
|
return (
|
||||||
|
<svg className={className} viewBox="0 0 24 24" fill="currentColor">
|
||||||
|
<path d="M18.244 2.25h3.308l-7.227 8.26 8.502 11.24H16.17l-5.214-6.817L4.99 21.75H1.68l7.73-8.835L1.254 2.25H8.08l4.713 6.231zm-1.161 17.52h1.833L7.084 4.126H5.117z" />
|
||||||
|
</svg>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function FacebookIcon({ className }: { className?: string }) {
|
||||||
|
return (
|
||||||
|
<svg className={className} viewBox="0 0 24 24" fill="currentColor">
|
||||||
|
<path d="M24 12.073c0-6.627-5.373-12-12-12s-12 5.373-12 12c0 5.99 4.388 10.954 10.125 11.854v-8.385H7.078v-3.47h3.047V9.43c0-3.007 1.792-4.669 4.533-4.669 1.312 0 2.686.235 2.686.235v2.953H15.83c-1.491 0-1.956.925-1.956 1.874v2.25h3.328l-.532 3.47h-2.796v8.385C19.612 23.027 24 18.062 24 12.073z" />
|
||||||
|
</svg>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function WhatsAppIcon({ className }: { className?: string }) {
|
||||||
|
return (
|
||||||
|
<svg className={className} viewBox="0 0 24 24" fill="currentColor">
|
||||||
|
<path d="M17.472 14.382c-.297-.149-1.758-.867-2.03-.967-.273-.099-.471-.148-.67.15-.197.297-.767.966-.94 1.164-.173.199-.347.223-.644.075-.297-.15-1.255-.463-2.39-1.475-.883-.788-1.48-1.761-1.653-2.059-.173-.297-.018-.458.13-.606.134-.133.298-.347.446-.52.149-.174.198-.298.298-.497.099-.198.05-.371-.025-.52-.075-.149-.669-1.612-.916-2.207-.242-.579-.487-.5-.669-.51-.173-.008-.371-.01-.57-.01-.198 0-.52.074-.792.372-.272.297-1.04 1.016-1.04 2.479 0 1.462 1.065 2.875 1.213 3.074.149.198 2.096 3.2 5.077 4.487.709.306 1.262.489 1.694.625.712.227 1.36.195 1.871.118.571-.085 1.758-.719 2.006-1.413.248-.694.248-1.289.173-1.413-.074-.124-.272-.198-.57-.347m-5.421 7.403h-.004a9.87 9.87 0 01-5.031-1.378l-.361-.214-3.741.982.998-3.648-.235-.374a9.86 9.86 0 01-1.51-5.26c.001-5.45 4.436-9.884 9.888-9.884 2.64 0 5.122 1.03 6.988 2.898a9.825 9.825 0 012.893 6.994c-.003 5.45-4.437 9.884-9.885 9.884m8.413-18.297A11.815 11.815 0 0012.05 0C5.495 0 .16 5.335.157 11.892c0 2.096.547 4.142 1.588 5.945L.057 24l6.305-1.654a11.882 11.882 0 005.683 1.448h.005c6.554 0 11.89-5.335 11.893-11.893a11.821 11.821 0 00-3.48-8.413z" />
|
||||||
|
</svg>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function LinkIcon({ className }: { className?: string }) {
|
||||||
|
return (
|
||||||
|
<svg className={className} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round">
|
||||||
|
<path d="M10 13a5 5 0 0 0 7.54.54l3-3a5 5 0 0 0-7.07-7.07l-1.72 1.71" />
|
||||||
|
<path d="M14 11a5 5 0 0 0-7.54-.54l-3 3a5 5 0 0 0 7.07 7.07l1.71-1.71" />
|
||||||
|
</svg>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function CheckIcon({ className }: { className?: string }) {
|
||||||
|
return (
|
||||||
|
<svg className={className} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round">
|
||||||
|
<polyline points="20 6 9 17 4 12" />
|
||||||
|
</svg>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
interface SocialShareProps {
|
||||||
|
title: string;
|
||||||
|
description?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function SocialShare({ title, description }: SocialShareProps) {
|
||||||
|
const toast = useToast();
|
||||||
|
const [copied, setCopied] = useState(false);
|
||||||
|
|
||||||
|
const url = typeof window !== "undefined" ? window.location.href : "";
|
||||||
|
const text = description || title;
|
||||||
|
|
||||||
|
const shareLinks = [
|
||||||
|
{
|
||||||
|
name: "Twitter / X",
|
||||||
|
href: `https://twitter.com/intent/tweet?text=${encodeURIComponent(text)}&url=${encodeURIComponent(url)}`,
|
||||||
|
icon: TwitterIcon,
|
||||||
|
color: "hover:text-white hover:bg-black",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "Facebook",
|
||||||
|
href: `https://www.facebook.com/sharer/sharer.php?u=${encodeURIComponent(url)}`,
|
||||||
|
icon: FacebookIcon,
|
||||||
|
color: "hover:text-white hover:bg-blue-600",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "WhatsApp",
|
||||||
|
href: `https://wa.me/?text=${encodeURIComponent(text + " " + url)}`,
|
||||||
|
icon: WhatsAppIcon,
|
||||||
|
color: "hover:text-white hover:bg-green-600",
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
async function copyLink() {
|
||||||
|
try {
|
||||||
|
await navigator.clipboard.writeText(url);
|
||||||
|
setCopied(true);
|
||||||
|
toast.success("Link copied to clipboard");
|
||||||
|
setTimeout(() => setCopied(false), 2000);
|
||||||
|
} catch {
|
||||||
|
toast.error("Failed to copy link");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<span className="text-xs text-[#6b6b75] mr-1">Share:</span>
|
||||||
|
{shareLinks.map((link) => (
|
||||||
|
<a
|
||||||
|
key={link.name}
|
||||||
|
href={link.href}
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener noreferrer"
|
||||||
|
className={`p-2 rounded-lg text-[#6b6b75] transition-all duration-200 ${link.color}`}
|
||||||
|
title={link.name}
|
||||||
|
aria-label={link.name}
|
||||||
|
>
|
||||||
|
<link.icon className="w-4 h-4" />
|
||||||
|
</a>
|
||||||
|
))}
|
||||||
|
<button
|
||||||
|
onClick={copyLink}
|
||||||
|
className={`p-2 rounded-lg transition-all duration-200 ${
|
||||||
|
copied ? "text-emerald-400 bg-emerald-400/10" : "text-[#6b6b75] hover:text-[#d4a574] hover:bg-[rgba(212,165,116,0.1)]"
|
||||||
|
}`}
|
||||||
|
title="Copy link"
|
||||||
|
aria-label="Copy link"
|
||||||
|
>
|
||||||
|
{copied ? <CheckIcon className="w-4 h-4" /> : <LinkIcon className="w-4 h-4" />}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
21
apps/web/src/components/theme/ThemeProvider.tsx
Normal file
21
apps/web/src/components/theme/ThemeProvider.tsx
Normal file
@@ -0,0 +1,21 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { createContext, useContext, type ReactNode } from "react";
|
||||||
|
|
||||||
|
interface ThemeContextValue {
|
||||||
|
theme: "dark";
|
||||||
|
}
|
||||||
|
|
||||||
|
const ThemeContext = createContext<ThemeContextValue>({ theme: "dark" });
|
||||||
|
|
||||||
|
export function useTheme() {
|
||||||
|
return useContext(ThemeContext);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function ThemeProvider({ children }: { children: ReactNode }) {
|
||||||
|
return (
|
||||||
|
<ThemeContext.Provider value={{ theme: "dark" }}>
|
||||||
|
{children}
|
||||||
|
</ThemeContext.Provider>
|
||||||
|
);
|
||||||
|
}
|
||||||
13
apps/web/src/components/theme/ThemeToggle.tsx
Normal file
13
apps/web/src/components/theme/ThemeToggle.tsx
Normal file
@@ -0,0 +1,13 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useTheme } from "./ThemeProvider";
|
||||||
|
|
||||||
|
export function ThemeToggle() {
|
||||||
|
const { theme } = useTheme();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<span className="text-xs" style={{ color: 'var(--text-muted)' }}>
|
||||||
|
{theme === "dark" ? "🌙" : "☀️"}
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
}
|
||||||
54
apps/web/src/components/ui/CookieConsent.tsx
Normal file
54
apps/web/src/components/ui/CookieConsent.tsx
Normal file
@@ -0,0 +1,54 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useEffect, useState } from "react";
|
||||||
|
import { motion, AnimatePresence } from "framer-motion";
|
||||||
|
|
||||||
|
const CONSENT_KEY = "cookie-consent";
|
||||||
|
|
||||||
|
export function CookieConsent() {
|
||||||
|
const [visible, setVisible] = useState(false);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
try {
|
||||||
|
const stored = localStorage.getItem(CONSENT_KEY);
|
||||||
|
if (!stored) setVisible(true);
|
||||||
|
} catch {
|
||||||
|
// ignore
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
function accept() {
|
||||||
|
try {
|
||||||
|
localStorage.setItem(CONSENT_KEY, "accepted");
|
||||||
|
} catch {
|
||||||
|
// ignore
|
||||||
|
}
|
||||||
|
setVisible(false);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<AnimatePresence>
|
||||||
|
{visible && (
|
||||||
|
<motion.div
|
||||||
|
initial={{ y: 100, opacity: 0 }}
|
||||||
|
animate={{ y: 0, opacity: 1 }}
|
||||||
|
exit={{ y: 100, opacity: 0 }}
|
||||||
|
transition={{ duration: 0.4, ease: "easeOut" }}
|
||||||
|
className="fixed bottom-0 left-0 right-0 z-[90] bg-[#0a0a0f]/95 backdrop-blur-lg border-t border-[rgba(255,255,255,0.08)]"
|
||||||
|
>
|
||||||
|
<div className="max-w-7xl mx-auto px-4 py-4 flex flex-col sm:flex-row items-center justify-between gap-4">
|
||||||
|
<p className="text-sm text-[#a0a0a8] text-center sm:text-left">
|
||||||
|
We use cookies to enhance your experience. By continuing to visit this site you agree to our use of cookies.
|
||||||
|
</p>
|
||||||
|
<button
|
||||||
|
onClick={accept}
|
||||||
|
className="px-6 py-2 bg-[#d4a574] text-[#0a0a0f] text-sm font-semibold rounded-lg hover:bg-[#e8c4a0] transition-colors whitespace-nowrap"
|
||||||
|
>
|
||||||
|
Accept
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</motion.div>
|
||||||
|
)}
|
||||||
|
</AnimatePresence>
|
||||||
|
);
|
||||||
|
}
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user