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

- 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:
consultoria-as
2026-04-28 05:15:38 +00:00
parent ea142501fa
commit 449c02eadc
151 changed files with 10053 additions and 2312 deletions

View File

@@ -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
View File

@@ -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

View File

@@ -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",
},
},
},
},
});

View File

@@ -62,7 +62,6 @@
"coverImage": {
"type": "media",
"multiple": false,
"required": true,
"allowedTypes": ["images"]
},
"serverStatus": {

View File

@@ -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/

View File

@@ -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);

View File

@@ -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",

View File

@@ -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>

View 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]">&lt;{msg.email}&gt;</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>
);
}

View File

@@ -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>
);
}

View File

@@ -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>
);
}

View File

@@ -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>
);
}

View File

@@ -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>
);
}

View File

@@ -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>
);
}

View File

@@ -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>
);
}

View File

@@ -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>
);
}

View File

@@ -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 />

View 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>
);
}

View 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>
);
}

View File

@@ -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>

View File

@@ -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,
}: {

View File

@@ -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} />

View 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>
);
}

View File

@@ -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>
);

View 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>
);
}

View 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>
);
}

View File

@@ -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 />
</>
);
}

View 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>
);
}

View 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>
);
}

View 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>
);
}

View 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 });
}

View 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 });
}
}

View 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 });
}
}

View File

@@ -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 });
}
}

View File

@@ -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 });
}
}

View File

@@ -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();
}

View File

@@ -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 };

View File

@@ -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 });
}
}

View File

@@ -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 });
}
}

View File

@@ -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 });
}
}

View File

@@ -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 });
}
}

View File

@@ -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 });
}
}

View File

@@ -0,0 +1,3 @@
import { GET, POST } from "@/lib/auth";
export { GET, POST };

View 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 });
}
}

View 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(),
},
});
}

View 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 });
}
}

View 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",
},
});
}

View File

@@ -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%;
}

View 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",
},
],
};
}

View File

@@ -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");
}

View 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`,
};
}

View 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;
}

View 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>
);
}

View 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>
);
}

View 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>
);
}

View 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>
);
}

View File

@@ -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>
);
}

View File

@@ -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>
);
}

View File

@@ -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>
);
}

View File

@@ -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>
);
}

View File

@@ -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>
);
}

View File

@@ -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>
);
}

View File

@@ -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>
);
}

View File

@@ -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>
);
}

View 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>;
}

View 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>
);
}

View 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>
);
}

View 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>
);
}

View File

@@ -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) => (

View File

@@ -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>

View 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>
);
}

View File

@@ -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")}

View File

@@ -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>
);
}

View File

@@ -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>

View File

@@ -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>
)}
</>
);
}

View 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>
);
}

View File

@@ -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>
);
}

View File

@@ -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>

View File

@@ -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>

View File

@@ -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

View 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>
);
}

View File

@@ -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>
);

View 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>
);
}

View 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>
);
}

View File

@@ -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>

View File

@@ -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>

View 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>
);
}

View 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>
);
}

View File

@@ -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>
);

View File

@@ -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>

View File

@@ -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>
);
}

View 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>
);
}

View 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>
);
}

View 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>
);
}

View File

@@ -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;
}
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 statusColors = {
online: "bg-green-500",
maintenance: "bg-yellow-500",
coming_soon: "bg-blue-500",
};
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>

View 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>
);
}

View 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>
);
}

View 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>
);
}

View 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>
);
}

View 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