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:
|
||||
push:
|
||||
branches: [main]
|
||||
|
||||
jobs:
|
||||
deploy:
|
||||
deploy-web:
|
||||
name: Deploy VM Web
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- name: Deploy to VPS
|
||||
- name: Deploy to VM Web
|
||||
uses: appleboy/ssh-action@v1
|
||||
with:
|
||||
host: ${{ secrets.VPS_HOST }}
|
||||
username: ${{ secrets.VPS_USER }}
|
||||
key: ${{ secrets.VPS_SSH_KEY }}
|
||||
host: ${{ secrets.VM_WEB_HOST }}
|
||||
username: ${{ secrets.VM_WEB_USER }}
|
||||
key: ${{ secrets.VM_WEB_SSH_KEY }}
|
||||
script: |
|
||||
cd /opt/project-afterlife
|
||||
git pull origin main
|
||||
cd docker
|
||||
docker compose build
|
||||
docker compose up -d
|
||||
docker compose exec web npm run build
|
||||
docker compose restart web
|
||||
docker compose -f docker-compose.web.yml build
|
||||
docker compose -f docker-compose.web.yml up -d
|
||||
docker compose -f docker-compose.web.yml exec web npm run build
|
||||
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
|
||||
|
||||
182
README.md
182
README.md
@@ -10,58 +10,59 @@ Plataforma de preservacion de videojuegos con documentales interactivos. Servido
|
||||
| **Strapi 5** (CMS) | En linea | 1337 | ~179 MB |
|
||||
| **PostgreSQL 16** | En linea | 5432 | ~57 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 - 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 - MySQL** | En linea | 3307 | ~733 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 |
|
||||
| **FusionFall** | En linea | 23000-23001 | ~254 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)
|
||||
- **Emulador**: [OpenFusion](https://github.com/OpenFusionProject/OpenFusion) (C++)
|
||||
- **Conexion**: `play.consultoria-as.com:23000` (o `192.168.10.234:23000` en LAN)
|
||||
- **Cliente**: [FusionFall Retro Client](https://github.com/OpenFusionProject)
|
||||
- **Documental**: "FusionFall: El Mundo Que No Queriamos Perder" (7 capitulos)
|
||||
### Dragon Ball Online
|
||||
- **Emulador**: [DBO Global](https://github.com/dboglobal) (C++ / Windows)
|
||||
- **Conexion**: `play.consultoria-as.com:22000`
|
||||
- **Cliente**: DBO Global Client (Windows)
|
||||
- **Documental**: "La Tierra Sin Goku" (en produccion)
|
||||
- **Estado**: En configuracion — requiere VM Windows
|
||||
|
||||
### MapleStory 2
|
||||
- **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
|
||||
- **Documental**: "MapleStory 2: El Mundo Que Construimos Juntos" (7 capitulos)
|
||||
- **Documental**: "El Mundo Que Construimos Juntos" (7 capitulos)
|
||||
- **Estado**: Online
|
||||
|
||||
### Minecraft: FTB Evolution
|
||||
- **Servidor**: [itzg/minecraft-server](https://github.com/itzg/docker-minecraft-server) (Java 21)
|
||||
- **Conexion**: `play.consultoria-as.com:25565` (o `192.168.10.234:25565` en LAN)
|
||||
- **Cliente**: FTB App o launcher compatible con FTB Evolution v1.29.1
|
||||
- **Modpack**: 200+ mods, Minecraft 1.21.1 + NeoForge 21.1.218
|
||||
### FusionFall
|
||||
- **Emulador**: [OpenFusion](https://github.com/OpenFusionProject/OpenFusion) (C++)
|
||||
- **Conexion**: `play.consultoria-as.com:23000`
|
||||
- **Cliente**: FusionFall Retro Client
|
||||
- **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
|
||||
|
||||
### 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/
|
||||
├── apps/
|
||||
@@ -72,15 +73,35 @@ project-afterlife/
|
||||
├── servers/
|
||||
│ ├── openfusion/ # Servidor FusionFall (C++)
|
||||
│ ├── maple2/ # Servidor MapleStory 2 (C# .NET 8)
|
||||
│ ├── sm64coopdx/ # Super Mario 64 Coop (C, headless)
|
||||
│ └── dolphin-traversal/ # Dolphin Traversal Server (C++)
|
||||
│ ├── nier-reincarnation/ # NieR Reincarnation (MariesWonderland .NET 10)
|
||||
│ └── 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-compose.dev.yml # Stack local (web + CMS + juegos)
|
||||
│ ├── docker-compose.maple2.yml # MapleStory 2 (separado)
|
||||
│ ├── docker-compose.yml # Produccion (con Nginx + SSL)
|
||||
│ ├── docker-compose.main.yml # VM Principal (Web + Auth + CMS)
|
||||
│ ├── docker-compose.nier.yml # VM NieR Reincarnation
|
||||
│ ├── docker-compose.dbo.yml # VM Dragon Ball Online
|
||||
│ ├── docker-compose.fusionfall.yml # VM OpenFusion
|
||||
│ ├── docker-compose.maple2.yml # VM MapleStory 2
|
||||
│ ├── 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
|
||||
├── docs/ # Documentacion del proyecto
|
||||
└── .github/workflows/ # CI/CD deployment
|
||||
├── 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
|
||||
@@ -102,19 +123,55 @@ project-afterlife/
|
||||
|
||||
## 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+
|
||||
- 8 GB RAM minimo (16 GB recomendado con todos los servidores)
|
||||
- 50 GB disco libre
|
||||
|
||||
### 1. Clonar y configurar
|
||||
#### 1. Clonar y configurar
|
||||
|
||||
```bash
|
||||
git clone https://git.consultoria-as.com/consultoria-as/project-afterlife.git
|
||||
cd project-afterlife
|
||||
```
|
||||
|
||||
### 2. Crear archivo de entorno
|
||||
#### 2. Crear archivo de entorno
|
||||
|
||||
```bash
|
||||
cp docker/.env.example docker/.env
|
||||
@@ -144,24 +201,27 @@ STRAPI_API_TOKEN=
|
||||
|
||||
# Strapi URL publica
|
||||
PUBLIC_STRAPI_URL=http://localhost:1337
|
||||
|
||||
# OpenFusion
|
||||
OPENFUSION_SHARD_IP=192.168.10.234
|
||||
```
|
||||
|
||||
### 3. Levantar servicios base
|
||||
#### 3. Levantar servicios base
|
||||
|
||||
```bash
|
||||
cd docker
|
||||
|
||||
# Stack principal (CMS + Web + OpenFusion + Minecraft FTB)
|
||||
# Stack principal (CMS + Web)
|
||||
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)
|
||||
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
|
||||
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`
|
||||
5. Reiniciar el servicio web: `docker compose -f docker-compose.dev.yml restart web`
|
||||
|
||||
### 5. Verificar
|
||||
#### 5. Verificar
|
||||
|
||||
- **Frontend**: http://localhost:3000
|
||||
- **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/games/[slug]` | Pagina individual de juego |
|
||||
| `/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
|
||||
|
||||
### Juegos
|
||||
### Juegos (Soft Launch)
|
||||
| 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 |
|
||||
| `minecraft-ftb-evolution` | Minecraft: FTB Evolution | Online | Pendiente |
|
||||
| `fusionfall` | FusionFall | Online | 7 capitulos |
|
||||
|
||||
### Documentales
|
||||
| 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 |
|
||||
| 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
|
||||
|
||||
|
||||
@@ -6,4 +6,24 @@ export default () => ({
|
||||
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": {
|
||||
"type": "media",
|
||||
"multiple": false,
|
||||
"required": true,
|
||||
"allowedTypes": ["images"]
|
||||
},
|
||||
"serverStatus": {
|
||||
|
||||
@@ -5,7 +5,8 @@ COPY package.json package-lock.json* turbo.json ./
|
||||
COPY apps/web/package.json ./apps/web/
|
||||
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 apps/web/ ./apps/web/
|
||||
|
||||
@@ -2,10 +2,57 @@ import createNextIntlPlugin from "next-intl/plugin";
|
||||
|
||||
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 = {
|
||||
eslint: {
|
||||
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);
|
||||
|
||||
@@ -10,12 +10,20 @@
|
||||
},
|
||||
"dependencies": {
|
||||
"@afterlife/shared": "*",
|
||||
"@giscus/react": "^3.1.0",
|
||||
"@vercel/analytics": "^2.0.1",
|
||||
"chart.js": "^4.5.1",
|
||||
"framer-motion": "^12.34.3",
|
||||
"howler": "^2.2.4",
|
||||
"ioredis": "^5.10.1",
|
||||
"mercadopago": "^2.12.0",
|
||||
"next": "^15",
|
||||
"next-auth": "^5.0.0-beta.25",
|
||||
"next-intl": "^4.8.3",
|
||||
"nodemailer": "^6.10.1",
|
||||
"pg": "^8.20.0",
|
||||
"react": "^19",
|
||||
"react-chartjs-2": "^5.3.1",
|
||||
"react-dom": "^19",
|
||||
"uuid": "^13.0.0"
|
||||
},
|
||||
@@ -23,6 +31,8 @@
|
||||
"@tailwindcss/postcss": "^4",
|
||||
"@types/howler": "^2.2.12",
|
||||
"@types/node": "^20",
|
||||
"@types/nodemailer": "^8.0.0",
|
||||
"@types/pg": "^8.20.0",
|
||||
"@types/react": "^19",
|
||||
"@types/react-dom": "^19",
|
||||
"eslint": "^9",
|
||||
|
||||
@@ -1,11 +1,23 @@
|
||||
import type { Metadata } from "next";
|
||||
import { useTranslations } from "next-intl";
|
||||
|
||||
export function generateMetadata(): Metadata {
|
||||
return {
|
||||
title: "About Us",
|
||||
};
|
||||
}
|
||||
|
||||
export default function AboutPage() {
|
||||
const t = useTranslations("about");
|
||||
|
||||
return (
|
||||
<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">
|
||||
<h2 className="text-2xl font-semibold mb-4">{t("mission")}</h2>
|
||||
@@ -22,8 +34,8 @@ export default function AboutPage() {
|
||||
<section className="mb-12">
|
||||
<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="bg-gray-900 rounded-lg p-6 border border-white/5">
|
||||
<p className="text-gray-500 text-sm">Team members coming soon.</p>
|
||||
<div className="bg-[#12121a] rounded-lg p-6 border border-[rgba(255,255,255,0.08)]">
|
||||
<p className="text-[#6b6b75] text-sm">Team members coming soon.</p>
|
||||
</div>
|
||||
</div>
|
||||
</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 { getGames } from "@/lib/api";
|
||||
import { CatalogFilters } from "@/components/catalog/CatalogFilters";
|
||||
import { CatalogGrid } from "@/components/catalog/CatalogGrid";
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: "Game Catalog | Project Afterlife",
|
||||
};
|
||||
|
||||
export default async function CatalogPage({
|
||||
params,
|
||||
}: {
|
||||
@@ -20,8 +25,14 @@ export default async function CatalogPage({
|
||||
|
||||
return (
|
||||
<div className="max-w-7xl mx-auto px-4 py-12">
|
||||
<h1 className="text-4xl font-bold mb-8">
|
||||
{locale === "es" ? "Catálogo de Juegos" : "Game Catalog"}
|
||||
<h1
|
||||
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>
|
||||
<Suspense>
|
||||
<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";
|
||||
|
||||
export function generateMetadata(): Metadata {
|
||||
return {
|
||||
title: "Donations",
|
||||
};
|
||||
}
|
||||
|
||||
export default function DonatePage() {
|
||||
const t = useTranslations("donate");
|
||||
|
||||
return (
|
||||
<div className="max-w-4xl mx-auto px-4 py-12">
|
||||
<h1 className="text-4xl font-bold mb-6">{t("title")}</h1>
|
||||
<p className="text-lg text-gray-400 mb-12">{t("description")}</p>
|
||||
<h1
|
||||
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">
|
||||
<a
|
||||
href="https://patreon.com/projectafterlife"
|
||||
target="_blank"
|
||||
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>
|
||||
<p className="text-gray-400 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">
|
||||
<h3 className="text-2xl font-bold mb-2 text-[#d4a574]">Patreon</h3>
|
||||
<p className="text-[#a0a0a8] text-sm mb-4">Donaciones recurrentes mensuales</p>
|
||||
<span className="inline-block px-6 py-2 bg-[#d4a574] hover:bg-[#e8c4a0] text-[#0a0a0f] rounded-lg font-medium transition-colors">
|
||||
{t("patreon")}
|
||||
</span>
|
||||
</a>
|
||||
@@ -26,11 +38,11 @@ export default function DonatePage() {
|
||||
href="https://ko-fi.com/projectafterlife"
|
||||
target="_blank"
|
||||
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>
|
||||
<p className="text-gray-400 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">
|
||||
<h3 className="text-2xl font-bold mb-2 text-[#e8c4a0]">Ko-fi</h3>
|
||||
<p className="text-[#a0a0a8] text-sm mb-4">Donaciones puntuales</p>
|
||||
<span className="inline-block px-6 py-2 bg-[#e8c4a0] hover:bg-[#d4a574] text-[#0a0a0f] rounded-lg font-medium transition-colors">
|
||||
{t("kofi")}
|
||||
</span>
|
||||
</a>
|
||||
|
||||
@@ -1,7 +1,27 @@
|
||||
import type { Metadata } from "next";
|
||||
import { notFound } from "next/navigation";
|
||||
import { getDocumentaryByGameSlug } from "@/lib/api";
|
||||
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({
|
||||
params,
|
||||
}: {
|
||||
|
||||
@@ -1,8 +1,47 @@
|
||||
import type { Metadata } from "next";
|
||||
import { notFound } from "next/navigation";
|
||||
import { getGameBySlug } from "@/lib/api";
|
||||
import { GameHeader } from "@/components/game/GameHeader";
|
||||
import { GameInfo } from "@/components/game/GameInfo";
|
||||
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({
|
||||
params,
|
||||
@@ -25,6 +64,9 @@ export default async function GamePage({
|
||||
<>
|
||||
<GameHeader game={game} />
|
||||
<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} />
|
||||
{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 { 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 { getMessages } from "next-intl/server";
|
||||
import { notFound } from "next/navigation";
|
||||
import { routing } from "@/i18n/routing";
|
||||
import { Navbar } from "@/components/layout/Navbar";
|
||||
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"],
|
||||
variable: "--font-playfair",
|
||||
display: "swap",
|
||||
});
|
||||
|
||||
const sourceSerif = Source_Serif_4({
|
||||
subsets: ["latin", "latin-ext"],
|
||||
variable: "--font-source-serif",
|
||||
variable: "--font-syne",
|
||||
display: "swap",
|
||||
});
|
||||
|
||||
@@ -50,14 +51,24 @@ export default async function LocaleLayout({
|
||||
return (
|
||||
<html
|
||||
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">
|
||||
<AuthProvider>
|
||||
<NextIntlClientProvider messages={messages}>
|
||||
<ThemeProvider>
|
||||
<ToastProvider>
|
||||
<Navbar />
|
||||
<main className="flex-1 pt-16">{children}</main>
|
||||
<Breadcrumb />
|
||||
<main className="flex-1 pt-12">{children}</main>
|
||||
<Footer />
|
||||
<ScrollToTop />
|
||||
<CookieConsent />
|
||||
<Analytics />
|
||||
</ToastProvider>
|
||||
</ThemeProvider>
|
||||
</NextIntlClientProvider>
|
||||
</AuthProvider>
|
||||
</body>
|
||||
</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 { HeroSection } from "@/components/home/HeroSection";
|
||||
import { LatestGames } from "@/components/home/LatestGames";
|
||||
import { DonationCTA } from "@/components/home/DonationCTA";
|
||||
import { PillarsSection } from "@/components/home/PillarsSection";
|
||||
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({
|
||||
params,
|
||||
@@ -21,8 +24,11 @@ export default async function HomePage({
|
||||
return (
|
||||
<>
|
||||
<HeroSection />
|
||||
<LatestGames games={games} locale={locale} />
|
||||
<DonationCTA />
|
||||
<PillarsSection />
|
||||
<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 {
|
||||
--font-sans: var(--font-dm-sans), system-ui, sans-serif;
|
||||
--font-display: var(--font-playfair), Georgia, serif;
|
||||
--font-body: var(--font-source-serif), Georgia, serif;
|
||||
--font-display: var(--font-syne), system-ui, sans-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 {
|
||||
font-family: var(--font-body);
|
||||
font-size: 1.125rem;
|
||||
line-height: 1.85;
|
||||
color: #d1d5db;
|
||||
color: var(--text-secondary);
|
||||
margin-bottom: 1.5em;
|
||||
}
|
||||
|
||||
@@ -26,7 +87,7 @@
|
||||
font-family: var(--font-body);
|
||||
font-size: 1.1875rem;
|
||||
line-height: 1.9;
|
||||
color: #e5e7eb;
|
||||
color: var(--text-secondary);
|
||||
margin-bottom: 1.75em;
|
||||
letter-spacing: 0.005em;
|
||||
}
|
||||
@@ -39,16 +100,180 @@
|
||||
padding-right: 0.5rem;
|
||||
margin-top: 0.1rem;
|
||||
font-weight: 700;
|
||||
color: #f59e0b;
|
||||
color: var(--accent-primary);
|
||||
}
|
||||
|
||||
.chapter-prose p:last-child {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
/* ── Em-dash and quotation styling inside prose ─────────── */
|
||||
|
||||
.chapter-prose p em {
|
||||
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() {
|
||||
return (
|
||||
<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>
|
||||
);
|
||||
redirect("/es");
|
||||
}
|
||||
|
||||
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 { useRouter, useSearchParams, usePathname } from "next/navigation";
|
||||
import type { Genre, ServerStatus } from "@afterlife/shared";
|
||||
import { SearchInput } from "@/components/search/SearchInput";
|
||||
|
||||
const GENRES: Genre[] = ["MMORPG", "FPS", "Casual", "Strategy", "Sports", "Other"];
|
||||
const STATUSES: ServerStatus[] = ["online", "maintenance", "coming_soon"];
|
||||
@@ -28,21 +29,24 @@ export function CatalogFilters() {
|
||||
}
|
||||
|
||||
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
|
||||
value={currentGenre}
|
||||
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>
|
||||
{GENRES.map((g) => (
|
||||
<option key={g} value={g}>{g}</option>
|
||||
))}
|
||||
</select>
|
||||
|
||||
<select
|
||||
value={currentStatus}
|
||||
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>
|
||||
{STATUSES.map((s) => (
|
||||
|
||||
@@ -1,4 +1,7 @@
|
||||
"use client";
|
||||
|
||||
import { useTranslations } from "next-intl";
|
||||
import { useSearchParams } from "next/navigation";
|
||||
import type { Game } from "@afterlife/shared";
|
||||
import { GameCard } from "../shared/GameCard";
|
||||
|
||||
@@ -9,18 +12,51 @@ interface CatalogGridProps {
|
||||
|
||||
export function CatalogGrid({ games, locale }: CatalogGridProps) {
|
||||
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) {
|
||||
return (
|
||||
<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>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<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} />
|
||||
))}
|
||||
</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;
|
||||
|
||||
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">
|
||||
{/* Progress bar */}
|
||||
<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) => {
|
||||
const rect = e.currentTarget.getBoundingClientRect();
|
||||
const ratio = (e.clientX - rect.left) / rect.width;
|
||||
@@ -54,7 +54,7 @@ export function AudioPlayer({
|
||||
}}
|
||||
>
|
||||
<div
|
||||
className="h-full bg-blue-500 rounded-full transition-all"
|
||||
className="h-full bg-[#d4a574] rounded-full transition-all"
|
||||
style={{ width: `${progressPercent}%` }}
|
||||
/>
|
||||
</div>
|
||||
@@ -63,7 +63,7 @@ export function AudioPlayer({
|
||||
{/* Play/Pause */}
|
||||
<button
|
||||
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")}
|
||||
>
|
||||
{isPlaying ? "\u23F8" : "\u25B6"}
|
||||
@@ -71,19 +71,19 @@ export function AudioPlayer({
|
||||
|
||||
{/* Track info */}
|
||||
<div className="flex-1 min-w-0">
|
||||
<p className="text-sm text-white truncate">{trackTitle}</p>
|
||||
<p className="text-xs text-gray-500">
|
||||
<p className="text-sm text-[#f5f5f7] truncate">{trackTitle}</p>
|
||||
<p className="text-xs text-[#6b6b75]">
|
||||
{formatTime(progress)} / {formatTime(duration)}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Speed selector */}
|
||||
<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
|
||||
value={playbackRate}
|
||||
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) => (
|
||||
<option key={r} value={r}>
|
||||
@@ -98,8 +98,8 @@ export function AudioPlayer({
|
||||
onClick={onToggleContinuous}
|
||||
className={`text-xs px-3 py-1 rounded border transition-colors ${
|
||||
continuousMode
|
||||
? "border-blue-500 text-blue-400"
|
||||
: "border-white/10 text-gray-500 hover:text-white"
|
||||
? "border-[#d4a574] text-[#d4a574]"
|
||||
: "border-[rgba(255,255,255,0.08)] text-[#6b6b75] hover:text-[#a0a0a8]"
|
||||
}`}
|
||||
>
|
||||
{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 type { Chapter } from "@afterlife/shared";
|
||||
import { formatTextToHtml } from "@/lib/format";
|
||||
import { useToast } from "@/hooks/useToast";
|
||||
import { getImageUrl } from "@/lib/images";
|
||||
import { BookmarkButton } from "@/components/bookmark/BookmarkButton";
|
||||
|
||||
interface ChapterContentProps {
|
||||
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 (
|
||||
<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 */}
|
||||
<div className="mb-10">
|
||||
<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")}
|
||||
</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>
|
||||
<h2 className="text-3xl sm:text-4xl font-display font-bold leading-tight tracking-tight">
|
||||
</div>
|
||||
<h2
|
||||
className={`font-display font-bold leading-tight tracking-tight ${
|
||||
readingMode ? "text-4xl sm:text-5xl" : "text-3xl sm:text-4xl"
|
||||
}`}
|
||||
>
|
||||
{chapter.title}
|
||||
</h2>
|
||||
</div>
|
||||
@@ -25,18 +108,25 @@ export function ChapterContent({ chapter }: ChapterContentProps) {
|
||||
{chapter.coverImage && (
|
||||
<div className="relative aspect-video rounded-lg overflow-hidden mb-10">
|
||||
<Image
|
||||
src={chapter.coverImage.url}
|
||||
src={getImageUrl(chapter.coverImage.url)}
|
||||
alt={chapter.coverImage.alternativeText || chapter.title}
|
||||
fill
|
||||
unoptimized
|
||||
className="object-cover"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div
|
||||
className="chapter-prose"
|
||||
dangerouslySetInnerHTML={{ __html: formatTextToHtml(chapter.content) }}
|
||||
className={`chapter-prose transition-all duration-300 ${
|
||||
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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -6,46 +6,84 @@ import { useTranslations } from "next-intl";
|
||||
interface ChapterNavProps {
|
||||
chapters: Chapter[];
|
||||
activeChapterId: number;
|
||||
onSelectChapter: (id: number, index: number) => void;
|
||||
onSelectChapter: (id: number) => void;
|
||||
chapterProgress: Record<number, number>;
|
||||
}
|
||||
|
||||
export function ChapterNav({
|
||||
chapters,
|
||||
activeChapterId,
|
||||
onSelectChapter,
|
||||
chapterProgress,
|
||||
}: ChapterNavProps) {
|
||||
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 (
|
||||
<nav className="w-72 flex-shrink-0 hidden lg:block">
|
||||
<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")}
|
||||
</h3>
|
||||
<ol className="space-y-0.5">
|
||||
{chapters.map((chapter, index) => (
|
||||
{chapters.map((chapter, index) => {
|
||||
const progress = chapterProgress[chapter.id] ?? 0;
|
||||
const isCompleted = progress >= 90;
|
||||
const isActive = chapter.id === activeChapterId;
|
||||
|
||||
return (
|
||||
<li key={chapter.id}>
|
||||
<button
|
||||
onClick={() => onSelectChapter(chapter.id, index)}
|
||||
onClick={() => handleClick(chapter.id)}
|
||||
className={`w-full text-left px-3 py-2.5 rounded-lg text-sm transition-all duration-200 ${
|
||||
chapter.id === activeChapterId
|
||||
? "bg-amber-500/10 text-amber-400 font-medium border-l-2 border-amber-500 rounded-l-none"
|
||||
: "text-gray-400 hover:text-gray-200 hover:bg-white/[0.03]"
|
||||
isActive
|
||||
? "bg-[rgba(212,165,116,0.1)] text-[#d4a574] font-medium border-l-2 border-[#d4a574] rounded-l-none"
|
||||
: "text-[#6b6b75] hover:text-[#a0a0a8] hover:bg-[rgba(255,255,255,0.03)]"
|
||||
}`}
|
||||
>
|
||||
<div className="flex items-center gap-2">
|
||||
<span
|
||||
className={`text-xs mr-2 tabular-nums ${
|
||||
chapter.id === activeChapterId
|
||||
? "text-amber-500/70"
|
||||
: "text-gray-600"
|
||||
className={`text-xs tabular-nums ${
|
||||
isActive ? "text-[#d4a574]/70" : "text-[#3a3a44]"
|
||||
}`}
|
||||
>
|
||||
{String(index + 1).padStart(2, "0")}
|
||||
</span>
|
||||
{chapter.title}
|
||||
<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>
|
||||
</div>
|
||||
</nav>
|
||||
|
||||
@@ -1,23 +1,67 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useState } from "react";
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import type { Documentary, Chapter } from "@afterlife/shared";
|
||||
import { ChapterNav } from "./ChapterNav";
|
||||
import { ChapterContent } from "./ChapterContent";
|
||||
import { AudioPlayer } from "./AudioPlayer";
|
||||
import { ReadingProgress } from "./ReadingProgress";
|
||||
import { GiscusComments } from "./GiscusComments";
|
||||
import { useAudioPlayer } from "@/hooks/useAudioPlayer";
|
||||
|
||||
interface DocumentaryLayoutProps {
|
||||
documentary: Documentary;
|
||||
}
|
||||
|
||||
function getProgressKey(docId: number) {
|
||||
return `doc-progress-${docId}`;
|
||||
}
|
||||
|
||||
export function DocumentaryLayout({ documentary }: DocumentaryLayoutProps) {
|
||||
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();
|
||||
|
||||
// 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(() => {
|
||||
const audioTracks = chapters
|
||||
.filter((ch) => ch.audioFile)
|
||||
@@ -33,46 +77,278 @@ export function DocumentaryLayout({ documentary }: DocumentaryLayoutProps) {
|
||||
}
|
||||
}, []); // eslint-disable-line react-hooks/exhaustive-deps
|
||||
|
||||
function handleSelectChapter(chapterId: number, index: number) {
|
||||
const chapter = chapters.find((c) => c.id === chapterId);
|
||||
if (chapter) {
|
||||
setActiveChapter(chapter);
|
||||
window.scrollTo({ top: 0, behavior: "smooth" });
|
||||
const trackIndex = audio.tracks.findIndex((t) => t.id === chapterId);
|
||||
// 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);
|
||||
if (chapter) {
|
||||
goToChapter(chapter);
|
||||
}
|
||||
}
|
||||
|
||||
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 (
|
||||
<>
|
||||
<ReadingProgress />
|
||||
<ReadingProgress
|
||||
chapterName={
|
||||
chapters.find((c) => c.id === activeChapterId)?.title ?? ""
|
||||
}
|
||||
progress={chapterProgress[activeChapterId] ?? 0}
|
||||
/>
|
||||
|
||||
{/* 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="flex items-start justify-between gap-4">
|
||||
<div className="flex-1">
|
||||
<h1 className="text-3xl sm:text-4xl font-display font-bold tracking-tight">
|
||||
{documentary.title}
|
||||
</h1>
|
||||
{documentary.description && (
|
||||
<p className="mt-3 text-gray-400 font-body text-lg max-w-3xl leading-relaxed">
|
||||
{!readingMode && documentary.description && (
|
||||
<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>
|
||||
</header>
|
||||
|
||||
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-12 flex gap-12">
|
||||
<div
|
||||
className={`mx-auto px-4 sm:px-6 lg:px-8 py-12 flex gap-12 ${
|
||||
readingMode ? "max-w-3xl" : "max-w-7xl"
|
||||
}`}
|
||||
>
|
||||
{!readingMode && (
|
||||
<ChapterNav
|
||||
chapters={chapters}
|
||||
activeChapterId={activeChapter.id}
|
||||
activeChapterId={activeChapterId}
|
||||
onSelectChapter={handleSelectChapter}
|
||||
chapterProgress={chapterProgress}
|
||||
/>
|
||||
)}
|
||||
<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>
|
||||
|
||||
{!readingMode && (
|
||||
<AudioPlayer
|
||||
trackTitle={audio.currentTrack?.title ?? null}
|
||||
isPlaying={audio.isPlaying}
|
||||
@@ -87,6 +363,13 @@ export function DocumentaryLayout({ documentary }: DocumentaryLayoutProps) {
|
||||
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";
|
||||
|
||||
export function ReadingProgress() {
|
||||
const [progress, setProgress] = useState(0);
|
||||
interface ReadingProgressProps {
|
||||
chapterName: string;
|
||||
progress: number;
|
||||
}
|
||||
|
||||
export function ReadingProgress({
|
||||
chapterName,
|
||||
progress,
|
||||
}: ReadingProgressProps) {
|
||||
const [scrollProgress, setScrollProgress] = useState(0);
|
||||
const [hovered, setHovered] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
function handleScroll() {
|
||||
const scrollTop = window.scrollY;
|
||||
const docHeight = document.documentElement.scrollHeight - window.innerHeight;
|
||||
setProgress(docHeight > 0 ? (scrollTop / docHeight) * 100 : 0);
|
||||
const docHeight =
|
||||
document.documentElement.scrollHeight - window.innerHeight;
|
||||
setScrollProgress(docHeight > 0 ? (scrollTop / docHeight) * 100 : 0);
|
||||
}
|
||||
window.addEventListener("scroll", handleScroll, { passive: true });
|
||||
handleScroll();
|
||||
return () => window.removeEventListener("scroll", handleScroll);
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<div className="fixed top-16 left-0 right-0 z-40 h-0.5 bg-gray-800">
|
||||
<div
|
||||
className="h-full bg-blue-500 transition-all duration-150"
|
||||
style={{ width: `${progress}%` }}
|
||||
className="fixed top-16 left-0 right-0 z-40 h-[2px] bg-[#12121a] cursor-pointer"
|
||||
onMouseEnter={() => setHovered(true)}
|
||||
onMouseLeave={() => setHovered(false)}
|
||||
>
|
||||
<div
|
||||
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: `${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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import Image from "next/image";
|
||||
import type { Game } from "@afterlife/shared";
|
||||
import { getImageUrl } from "@/lib/images";
|
||||
|
||||
interface GameHeaderProps {
|
||||
game: Game;
|
||||
@@ -10,19 +11,20 @@ export function GameHeader({ game }: GameHeaderProps) {
|
||||
<div className="relative h-[50vh] overflow-hidden">
|
||||
{game.coverImage && (
|
||||
<Image
|
||||
src={game.coverImage.url}
|
||||
src={getImageUrl(game.coverImage.url)}
|
||||
alt={game.title}
|
||||
fill
|
||||
unoptimized
|
||||
className="object-cover"
|
||||
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">
|
||||
<h1 className="text-5xl font-bold mb-3 font-display tracking-tight">
|
||||
{game.title}
|
||||
</h1>
|
||||
<p className="text-gray-400 text-lg font-body">
|
||||
<p className="text-[#a0a0a8] text-lg font-body">
|
||||
{game.developer} · {game.releaseYear}–{game.shutdownYear}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
@@ -26,36 +26,36 @@ export function GameInfo({ game, locale }: GameInfoProps) {
|
||||
/>
|
||||
</div>
|
||||
<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">
|
||||
<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")}
|
||||
</dt>
|
||||
<dd className="text-gray-100 font-medium">{game.developer}</dd>
|
||||
<dd className="text-[#f5f5f7] font-medium">{game.developer}</dd>
|
||||
</div>
|
||||
{game.publisher && (
|
||||
<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")}
|
||||
</dt>
|
||||
<dd className="text-gray-100 font-medium">{game.publisher}</dd>
|
||||
<dd className="text-[#f5f5f7] font-medium">{game.publisher}</dd>
|
||||
</div>
|
||||
)}
|
||||
<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")}
|
||||
</dt>
|
||||
<dd className="text-gray-100 font-medium">{game.releaseYear}</dd>
|
||||
<dd className="text-[#f5f5f7] font-medium">{game.releaseYear}</dd>
|
||||
</div>
|
||||
<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")}
|
||||
</dt>
|
||||
<dd className="text-gray-100 font-medium">{game.shutdownYear}</dd>
|
||||
<dd className="text-[#f5f5f7] font-medium">{game.shutdownYear}</dd>
|
||||
</div>
|
||||
<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")}
|
||||
</dt>
|
||||
<dd className={`font-medium ${statusColors[game.serverStatus]}`}>
|
||||
@@ -69,7 +69,7 @@ export function GameInfo({ game, locale }: GameInfoProps) {
|
||||
href={game.serverLink}
|
||||
target="_blank"
|
||||
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")}
|
||||
</a>
|
||||
@@ -77,7 +77,7 @@ export function GameInfo({ game, locale }: GameInfoProps) {
|
||||
{game.documentary && (
|
||||
<Link
|
||||
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")}
|
||||
</Link>
|
||||
|
||||
@@ -30,7 +30,7 @@ export function ScreenshotGallery({ screenshots }: ScreenshotGalleryProps) {
|
||||
key={ss.id}
|
||||
onClick={() => setSelected(i)}
|
||||
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
|
||||
|
||||
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 Link from "next/link";
|
||||
import { useLocale } from "next-intl";
|
||||
@@ -7,16 +9,37 @@ export function DonationCTA() {
|
||||
const locale = useLocale();
|
||||
|
||||
return (
|
||||
<section className="bg-gradient-to-r from-blue-950/50 to-purple-950/50 py-20">
|
||||
<div className="max-w-3xl mx-auto px-4 text-center">
|
||||
<h2 className="text-3xl font-bold mb-4">{t("title")}</h2>
|
||||
<p className="text-gray-400 mb-8">{t("description")}</p>
|
||||
<section className="relative overflow-hidden py-24">
|
||||
{/* Animated gradient background */}
|
||||
<div className="absolute inset-0 animated-gradient-bg" />
|
||||
<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
|
||||
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")}
|
||||
</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>
|
||||
</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";
|
||||
|
||||
import { useTranslations } from "next-intl";
|
||||
import { motion } from "framer-motion";
|
||||
import { useTranslations, useLocale } from "next-intl";
|
||||
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() {
|
||||
const t = useTranslations("home");
|
||||
const locale = useLocale();
|
||||
|
||||
return (
|
||||
<section className="relative min-h-[80vh] flex items-center justify-center overflow-hidden">
|
||||
<div className="absolute inset-0 bg-gradient-to-b from-blue-950/20 via-gray-950 to-gray-950" />
|
||||
<div className="relative z-10 text-center px-4 max-w-4xl mx-auto">
|
||||
<motion.h1
|
||||
initial={{ opacity: 0, y: 20 }}
|
||||
<section className="relative min-h-[80vh] flex items-center overflow-hidden" style={{ padding: "10rem 2rem 6rem" }}>
|
||||
{/* Background layers */}
|
||||
<div className="absolute inset-0 -z-10">
|
||||
{/* Dot grid */}
|
||||
<div
|
||||
className="absolute inset-0 dot-grid-fade"
|
||||
style={{
|
||||
backgroundImage: `
|
||||
linear-gradient(rgba(212, 165, 116, 0.03) 1px, transparent 1px),
|
||||
linear-gradient(90deg, rgba(212, 165, 116, 0.03) 1px, transparent 1px)
|
||||
`,
|
||||
backgroundSize: "60px 60px",
|
||||
maskImage: "radial-gradient(ellipse at center, black 20%, transparent 70%)",
|
||||
WebkitMaskImage: "radial-gradient(ellipse at center, black 20%, transparent 70%)",
|
||||
}}
|
||||
/>
|
||||
{/* Primary gradient */}
|
||||
<div
|
||||
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
|
||||
initial={{ opacity: 0, y: 40 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ duration: 0.8 }}
|
||||
className="text-5xl md:text-7xl font-bold tracking-tight mb-6"
|
||||
transition={{ duration: 1, ease: [0.16, 1, 0.3, 1], delay: 0.2 }}
|
||||
>
|
||||
{t("hero_title")}
|
||||
</motion.h1>
|
||||
<motion.p
|
||||
initial={{ opacity: 0, y: 20 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ duration: 0.8, delay: 0.2 }}
|
||||
className="text-xl md:text-2xl text-gray-400 mb-10"
|
||||
{/* Icon */}
|
||||
<div
|
||||
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)",
|
||||
}}
|
||||
>
|
||||
<svg width="50" height="50" viewBox="0 0 24 24" fill="none" stroke="var(--accent-primary)" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round">
|
||||
<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" />
|
||||
</svg>
|
||||
</div>
|
||||
|
||||
{/* 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)",
|
||||
}}
|
||||
>
|
||||
<span className="w-2 h-2 rounded-full animate-blink" style={{ background: "var(--accent-primary)" }} />
|
||||
{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")}
|
||||
</motion.p>
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: 20 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ duration: 0.8, delay: 0.4 }}
|
||||
className="flex gap-4 justify-center"
|
||||
>
|
||||
</p>
|
||||
|
||||
{/* Buttons */}
|
||||
<div className="flex gap-4 justify-center flex-wrap">
|
||||
<Link
|
||||
href={`/${locale}/catalog`}
|
||||
className="px-8 py-3 bg-white text-black font-semibold rounded-lg hover:bg-gray-200 transition-colors"
|
||||
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("view_all")}
|
||||
{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="px-8 py-3 border border-white/20 text-white font-semibold rounded-lg hover:bg-white/10 transition-colors"
|
||||
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("donate_cta")}
|
||||
{t("hero_cta_secondary")}
|
||||
</Link>
|
||||
</div>
|
||||
</motion.div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
@@ -1,5 +1,8 @@
|
||||
"use client";
|
||||
|
||||
import { useTranslations } from "next-intl";
|
||||
import Link from "next/link";
|
||||
import { motion } from "framer-motion";
|
||||
import type { Game } from "@afterlife/shared";
|
||||
import { GameCard } from "../shared/GameCard";
|
||||
|
||||
@@ -13,20 +16,42 @@ export function LatestGames({ games, locale }: LatestGamesProps) {
|
||||
|
||||
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 (
|
||||
<section className="max-w-7xl mx-auto px-4 py-20">
|
||||
<div className="flex items-center justify-between mb-10">
|
||||
<h2 className="text-3xl font-bold">{t("latest_games")}</h2>
|
||||
<Link
|
||||
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")} →
|
||||
</Link>
|
||||
</div>
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
|
||||
{games.slice(0, 6).map((game) => (
|
||||
<GameCard key={game.id} game={game} locale={locale} />
|
||||
{games.slice(0, 6).map((game, i) => (
|
||||
<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>
|
||||
</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";
|
||||
|
||||
export function Footer() {
|
||||
const t = useTranslations("footer");
|
||||
|
||||
return (
|
||||
<footer className="bg-black border-t border-white/10 py-8">
|
||||
<div className="max-w-7xl mx-auto px-4 text-center">
|
||||
<p className="text-sm text-gray-500">{t("rights")}</p>
|
||||
<footer style={{ borderTop: '1px solid var(--border-color)' }}>
|
||||
<div className="max-w-7xl mx-auto px-4 py-8">
|
||||
<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>
|
||||
</footer>
|
||||
);
|
||||
|
||||
@@ -17,17 +17,17 @@ export function LanguageSwitcher() {
|
||||
|
||||
return (
|
||||
<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
|
||||
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
|
||||
</button>
|
||||
<span className="text-gray-600">|</span>
|
||||
<span className="text-[#3a3a44]">|</span>
|
||||
<button
|
||||
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
|
||||
</button>
|
||||
|
||||
@@ -1,40 +1,222 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import Link from "next/link";
|
||||
import { useLocale, useTranslations } from "next-intl";
|
||||
import { useSession, signOut } from "next-auth/react";
|
||||
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() {
|
||||
const t = useTranslations("nav");
|
||||
const locale = useLocale();
|
||||
const { data: session, status } = useSession();
|
||||
const [mobileOpen, setMobileOpen] = useState(false);
|
||||
|
||||
const links = [
|
||||
{ href: `/${locale}`, label: t("home") },
|
||||
{ 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}/donate`, label: t("donate") },
|
||||
{ href: `/${locale}/contact`, label: "Contact" },
|
||||
];
|
||||
|
||||
const isAuthenticated = status === "authenticated";
|
||||
|
||||
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">
|
||||
<Link href={`/${locale}`} className="text-xl font-bold text-white tracking-tight">
|
||||
Project Afterlife
|
||||
<Link
|
||||
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>
|
||||
<div className="flex items-center gap-6">
|
||||
|
||||
{/* Desktop nav */}
|
||||
<div className="hidden lg:flex items-center gap-8">
|
||||
{links.map((link) => (
|
||||
<Link
|
||||
key={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>
|
||||
))}
|
||||
|
||||
{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 />
|
||||
</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>
|
||||
|
||||
{/* 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>
|
||||
);
|
||||
}
|
||||
|
||||
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 Image from "next/image";
|
||||
import type { Game } from "@afterlife/shared";
|
||||
import { useServerStatus } from "@/hooks/useServerStatus";
|
||||
import { getImageUrl } from "@/lib/images";
|
||||
|
||||
interface GameCardProps {
|
||||
game: Game;
|
||||
locale: string;
|
||||
}
|
||||
|
||||
export function GameCard({ game, locale }: GameCardProps) {
|
||||
const statusColors = {
|
||||
online: "bg-green-500",
|
||||
maintenance: "bg-yellow-500",
|
||||
coming_soon: "bg-blue-500",
|
||||
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) {
|
||||
const status = statusConfig[game.serverStatus];
|
||||
const serverHealth = useServerStatus(game.title);
|
||||
const showRealPing = serverHealth && game.serverStatus === "online";
|
||||
|
||||
return (
|
||||
<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">
|
||||
{game.coverImage && (
|
||||
<div
|
||||
className="relative overflow-hidden rounded-xl transition-all duration-300"
|
||||
style={{
|
||||
background: "var(--bg-card)",
|
||||
border: "1px solid var(--border-color)",
|
||||
boxShadow: "0 10px 40px rgba(0, 0, 0, 0.3)",
|
||||
}}
|
||||
onMouseEnter={(e) => {
|
||||
e.currentTarget.style.borderColor = "var(--border-hover)";
|
||||
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={game.coverImage.url}
|
||||
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 bg-gradient-to-t from-gray-900 via-transparent" />
|
||||
<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>
|
||||
)}
|
||||
<div className="p-4">
|
||||
<div className="flex items-center gap-2 mb-2">
|
||||
<span className={`w-2 h-2 rounded-full ${statusColors[game.serverStatus]}`} />
|
||||
<span className="text-xs text-gray-400 uppercase tracking-wider">{game.genre}</span>
|
||||
|
||||
{/* 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>
|
||||
<h3 className="text-lg font-semibold text-white group-hover:text-blue-400 transition-colors">
|
||||
</div>
|
||||
|
||||
<div className="p-4">
|
||||
<div className="flex items-center gap-2 mb-2.5">
|
||||
<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>
|
||||
<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}
|
||||
</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}
|
||||
</p>
|
||||
</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