+
+
+
{game.screenshots && (
diff --git a/apps/web/src/app/[locale]/guides/page.tsx b/apps/web/src/app/[locale]/guides/page.tsx
new file mode 100644
index 0000000..833ecdc
--- /dev/null
+++ b/apps/web/src/app/[locale]/guides/page.tsx
@@ -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 (
+
+ 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"
+ >
+ {guide.game}
+
+
+
+
+
+
+ {open && (
+
+
+
+
Steps
+
+ {guide.steps.map((step, i) => (
+
+
+ {i + 1}
+
+ {step}
+
+ ))}
+
+
+
+
+
Requirements
+
+ {guide.requirements.map((req, i) => (
+
+ {req}
+
+ ))}
+
+
+
+
+
Troubleshooting
+
+ {guide.troubleshooting.map((tip, i) => (
+
+ •
+ {tip}
+
+ ))}
+
+
+
+
+ )}
+
+
+ );
+}
+
+export default function GuidesPage() {
+ const locale = useLocale();
+ const isEs = locale === "es";
+ const guides = isEs ? guidesEs : guidesEn;
+
+ return (
+
+
+
+ {isEs ? "Guías de Conexión" : "Connection Guides"}
+
+
+ {isEs
+ ? "Todo lo que necesitas saber para conectarte a nuestros servidores privados."
+ : "Everything you need to know to connect to our private servers."}
+
+
+
+
+ {guides.map((guide, i) => (
+
+ ))}
+
+
+ );
+}
diff --git a/apps/web/src/app/[locale]/layout.tsx b/apps/web/src/app/[locale]/layout.tsx
index da1d110..f5dd261 100644
--- a/apps/web/src/app/[locale]/layout.tsx
+++ b/apps/web/src/app/[locale]/layout.tsx
@@ -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 (
-
-
-
- {children}
-
-
+
+
+
+
+
+
+
+ {children}
+
+
+
+
+
+
+
+
);
diff --git a/apps/web/src/app/[locale]/login/page.tsx b/apps/web/src/app/[locale]/login/page.tsx
new file mode 100644
index 0000000..25f8e44
--- /dev/null
+++ b/apps/web/src/app/[locale]/login/page.tsx
@@ -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 (
+
+
+
+ );
+}
diff --git a/apps/web/src/app/[locale]/not-found.tsx b/apps/web/src/app/[locale]/not-found.tsx
new file mode 100644
index 0000000..29f5f2f
--- /dev/null
+++ b/apps/web/src/app/[locale]/not-found.tsx
@@ -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 (
+
+
+ 404
+
+ {isEs ? "Página no encontrada" : "Page Not Found"}
+
+
+ {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."}
+
+
+ {isEs ? "Volver al inicio" : "Go back home"}
+
+
+
+ );
+}
diff --git a/apps/web/src/app/[locale]/page.tsx b/apps/web/src/app/[locale]/page.tsx
index b7065a2..23f55cc 100644
--- a/apps/web/src/app/[locale]/page.tsx
+++ b/apps/web/src/app/[locale]/page.tsx
@@ -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 (
<>
-
-
+
+
+
+
+
>
);
}
diff --git a/apps/web/src/app/[locale]/profile/page.tsx b/apps/web/src/app/[locale]/profile/page.tsx
new file mode 100644
index 0000000..f3f141a
--- /dev/null
+++ b/apps/web/src/app/[locale]/profile/page.tsx
@@ -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 (
+
+
+
+ );
+}
diff --git a/apps/web/src/app/[locale]/server-status/page.tsx b/apps/web/src/app/[locale]/server-status/page.tsx
new file mode 100644
index 0000000..ae5a368
--- /dev/null
+++ b/apps/web/src/app/[locale]/server-status/page.tsx
@@ -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 (
+
+
+
+
+
+ {locale === "es" ? "Estado de Servidores" : "Server Status"}
+
+
+ {locale === "es"
+ ? "Información de conexión para todos los servidores de juego de Project Afterlife."
+ : "Connection information for all Project Afterlife game servers."}
+
+
+
+
+
+
+
+
+
+ );
+}
diff --git a/apps/web/src/app/[locale]/template.tsx b/apps/web/src/app/[locale]/template.tsx
new file mode 100644
index 0000000..d096042
--- /dev/null
+++ b/apps/web/src/app/[locale]/template.tsx
@@ -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 (
+
+
+ {children}
+
+
+ );
+}
diff --git a/apps/web/src/app/api/activities/route.ts b/apps/web/src/app/api/activities/route.ts
new file mode 100644
index 0000000..e2c5e92
--- /dev/null
+++ b/apps/web/src/app/api/activities/route.ts
@@ -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 });
+}
diff --git a/apps/web/src/app/api/admin/messages/route.ts b/apps/web/src/app/api/admin/messages/route.ts
new file mode 100644
index 0000000..4e8ba12
--- /dev/null
+++ b/apps/web/src/app/api/admin/messages/route.ts
@@ -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 });
+ }
+}
diff --git a/apps/web/src/app/api/admin/subscribers/route.ts b/apps/web/src/app/api/admin/subscribers/route.ts
new file mode 100644
index 0000000..8cbd3be
--- /dev/null
+++ b/apps/web/src/app/api/admin/subscribers/route.ts
@@ -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 });
+ }
+}
diff --git a/apps/web/src/app/api/afc/balance/route.ts b/apps/web/src/app/api/afc/balance/route.ts
deleted file mode 100644
index ba5121b..0000000
--- a/apps/web/src/app/api/afc/balance/route.ts
+++ /dev/null
@@ -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 });
- }
-}
diff --git a/apps/web/src/app/api/afc/create-preference/route.ts b/apps/web/src/app/api/afc/create-preference/route.ts
deleted file mode 100644
index 21dcb6d..0000000
--- a/apps/web/src/app/api/afc/create-preference/route.ts
+++ /dev/null
@@ -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 });
- }
-}
diff --git a/apps/web/src/app/api/afc/lib/bridge.ts b/apps/web/src/app/api/afc/lib/bridge.ts
deleted file mode 100644
index 2b775d8..0000000
--- a/apps/web/src/app/api/afc/lib/bridge.ts
+++ /dev/null
@@ -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
) {
- 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
-) {
- 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();
-}
diff --git a/apps/web/src/app/api/afc/lib/mercadopago.ts b/apps/web/src/app/api/afc/lib/mercadopago.ts
deleted file mode 100644
index 743278c..0000000
--- a/apps/web/src/app/api/afc/lib/mercadopago.ts
+++ /dev/null
@@ -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 };
diff --git a/apps/web/src/app/api/afc/payments/route.ts b/apps/web/src/app/api/afc/payments/route.ts
deleted file mode 100644
index 26c869b..0000000
--- a/apps/web/src/app/api/afc/payments/route.ts
+++ /dev/null
@@ -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 });
- }
-}
diff --git a/apps/web/src/app/api/afc/redeem/route.ts b/apps/web/src/app/api/afc/redeem/route.ts
deleted file mode 100644
index ad09d48..0000000
--- a/apps/web/src/app/api/afc/redeem/route.ts
+++ /dev/null
@@ -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 });
- }
-}
diff --git a/apps/web/src/app/api/afc/redemptions/route.ts b/apps/web/src/app/api/afc/redemptions/route.ts
deleted file mode 100644
index f6413fe..0000000
--- a/apps/web/src/app/api/afc/redemptions/route.ts
+++ /dev/null
@@ -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 });
- }
-}
diff --git a/apps/web/src/app/api/afc/verify-disk/route.ts b/apps/web/src/app/api/afc/verify-disk/route.ts
deleted file mode 100644
index 9c822d1..0000000
--- a/apps/web/src/app/api/afc/verify-disk/route.ts
+++ /dev/null
@@ -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 });
- }
-}
diff --git a/apps/web/src/app/api/afc/webhook/route.ts b/apps/web/src/app/api/afc/webhook/route.ts
deleted file mode 100644
index 02beb97..0000000
--- a/apps/web/src/app/api/afc/webhook/route.ts
+++ /dev/null
@@ -1,113 +0,0 @@
-import { NextRequest, NextResponse } from "next/server";
-import { createHmac } from "crypto";
-import { paymentClient } from "../lib/mercadopago";
-import { bridgeGet, bridgePost, bridgePatch } from "../lib/bridge";
-
-const WEBHOOK_SECRET = process.env.MERCADOPAGO_WEBHOOK_SECRET || "";
-
-function verifySignature(req: NextRequest): boolean {
- if (!WEBHOOK_SECRET) return true; // Skip in dev if no secret configured
-
- const xSignature = req.headers.get("x-signature") || "";
- const xRequestId = req.headers.get("x-request-id") || "";
-
- // MercadoPago v2 signature: ts=xxx,v1=xxx
- const parts = Object.fromEntries(
- xSignature.split(",").map((p) => {
- const [k, ...v] = p.trim().split("=");
- return [k, v.join("=")];
- })
- );
-
- const dataId = new URL(req.url).searchParams.get("data.id") || "";
- const manifest = `id:${dataId};request-id:${xRequestId};ts:${parts.ts};`;
- const hmac = createHmac("sha256", WEBHOOK_SECRET)
- .update(manifest)
- .digest("hex");
-
- return hmac === parts.v1;
-}
-
-export async function POST(req: NextRequest) {
- try {
- const body = await req.text();
-
- if (!verifySignature(req)) {
- return NextResponse.json(
- { error: "Invalid signature" },
- { status: 401 }
- );
- }
-
- const data = JSON.parse(body);
-
- // Only process payment notifications
- if (data.type !== "payment") {
- return NextResponse.json({ ok: true });
- }
-
- const mpPaymentId = String(data.data?.id);
- if (!mpPaymentId) {
- return NextResponse.json({ ok: true });
- }
-
- // Fetch payment details from MercadoPago
- const mpPayment = await paymentClient.get({ id: mpPaymentId });
-
- if (mpPayment.status !== "approved") {
- // Update our record status but don't mint
- const externalRef = mpPayment.external_reference;
- if (externalRef) {
- await bridgePatch(`/api/payments/${externalRef}`, {
- status: mpPayment.status,
- mp_payment_id: mpPaymentId,
- });
- }
- return NextResponse.json({ ok: true });
- }
-
- const paymentId = mpPayment.external_reference;
- if (!paymentId) {
- console.error("webhook: no external_reference in MP payment");
- return NextResponse.json({ ok: true });
- }
-
- // Get our payment record
- let payment;
- try {
- payment = (await bridgeGet(`/api/payments/${paymentId}`)).payment;
- } catch {
- console.error("webhook: payment not found:", paymentId);
- return NextResponse.json({ ok: true });
- }
-
- // Idempotency: if already minted, skip
- if (payment.status === "completed" && payment.tx_hash) {
- return NextResponse.json({ ok: true, already_processed: true });
- }
-
- // Mint AFC via bridge deposit endpoint
- const mintResult = await bridgePost("/api/deposit", {
- diskId: payment.disk_id,
- amount: payment.amount_afc,
- });
-
- // Update payment record as completed
- await bridgePatch(`/api/payments/${paymentId}`, {
- status: "completed",
- mp_payment_id: mpPaymentId,
- tx_hash: mintResult.txHash,
- });
-
- console.log(
- `webhook: minted ${payment.amount_afc} AFC for disk ${payment.disk_id}, tx: ${mintResult.txHash}`
- );
-
- return NextResponse.json({ ok: true, minted: true });
- } catch (e: unknown) {
- const message = e instanceof Error ? e.message : "Unknown error";
- console.error("webhook error:", e);
- // Always return 200 to MP so it doesn't retry endlessly
- return NextResponse.json({ ok: true, error: message });
- }
-}
diff --git a/apps/web/src/app/api/auth/[...nextauth]/route.ts b/apps/web/src/app/api/auth/[...nextauth]/route.ts
new file mode 100644
index 0000000..7cb00e9
--- /dev/null
+++ b/apps/web/src/app/api/auth/[...nextauth]/route.ts
@@ -0,0 +1,3 @@
+import { GET, POST } from "@/lib/auth";
+
+export { GET, POST };
diff --git a/apps/web/src/app/api/contact/route.ts b/apps/web/src/app/api/contact/route.ts
new file mode 100644
index 0000000..4fd8125
--- /dev/null
+++ b/apps/web/src/app/api/contact/route.ts
@@ -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 });
+ }
+}
diff --git a/apps/web/src/app/api/health/route.ts b/apps/web/src/app/api/health/route.ts
new file mode 100644
index 0000000..f9d57af
--- /dev/null
+++ b/apps/web/src/app/api/health/route.ts
@@ -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(),
+ },
+ });
+}
diff --git a/apps/web/src/app/api/newsletter/route.ts b/apps/web/src/app/api/newsletter/route.ts
new file mode 100644
index 0000000..350a6d1
--- /dev/null
+++ b/apps/web/src/app/api/newsletter/route.ts
@@ -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 });
+ }
+}
diff --git a/apps/web/src/app/api/sse/route.ts b/apps/web/src/app/api/sse/route.ts
new file mode 100644
index 0000000..93c6b20
--- /dev/null
+++ b/apps/web/src/app/api/sse/route.ts
@@ -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",
+ },
+ });
+}
diff --git a/apps/web/src/app/globals.css b/apps/web/src/app/globals.css
index 99512da..b080a84 100644
--- a/apps/web/src/app/globals.css
+++ b/apps/web/src/app/globals.css
@@ -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%;
}
diff --git a/apps/web/src/app/manifest.ts b/apps/web/src/app/manifest.ts
new file mode 100644
index 0000000..d292ec7
--- /dev/null
+++ b/apps/web/src/app/manifest.ts
@@ -0,0 +1,30 @@
+import type { MetadataRoute } from "next";
+
+function svgIcon(size: number): string {
+ const svg = `PA `;
+ 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",
+ },
+ ],
+ };
+}
diff --git a/apps/web/src/app/not-found.tsx b/apps/web/src/app/not-found.tsx
index d8d0f6e..af83f74 100644
--- a/apps/web/src/app/not-found.tsx
+++ b/apps/web/src/app/not-found.tsx
@@ -1,13 +1,5 @@
+import { redirect } from "next/navigation";
+
export default function RootNotFound() {
- return (
-
-
-
-
-
- );
+ redirect("/es");
}
diff --git a/apps/web/src/app/robots.ts b/apps/web/src/app/robots.ts
new file mode 100644
index 0000000..91f0ea8
--- /dev/null
+++ b/apps/web/src/app/robots.ts
@@ -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`,
+ };
+}
diff --git a/apps/web/src/app/sitemap.ts b/apps/web/src/app/sitemap.ts
new file mode 100644
index 0000000..464f24f
--- /dev/null
+++ b/apps/web/src/app/sitemap.ts
@@ -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 {
+ 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;
+}
diff --git a/apps/web/src/components/activity/ActivityFeed.tsx b/apps/web/src/components/activity/ActivityFeed.tsx
new file mode 100644
index 0000000..0a9c24f
--- /dev/null
+++ b/apps/web/src/components/activity/ActivityFeed.tsx
@@ -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;
+ created_at: string;
+}
+
+const typeConfig: Record = {
+ 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([]);
+ 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 (
+
+
+ {Array.from({ length: 3 }).map((_, i) => (
+
+ ))}
+
+
+ );
+ }
+
+ if (activities.length === 0) return null;
+
+ return (
+
+
+
Recent Activity
+
+
+
+ {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
;
+
+ return (
+
+
+ {config.icon}
+
+
+
+ {config.label}
+ {details.email && — {details.email} }
+ {details.name && — {details.name} }
+ {details.server && — {details.server} }
+
+
+ {formatTimeAgo(activity.created_at)}
+
+ );
+ })}
+
+
+ );
+}
diff --git a/apps/web/src/components/admin/HealthBanner.tsx b/apps/web/src/components/admin/HealthBanner.tsx
new file mode 100644
index 0000000..b21c722
--- /dev/null
+++ b/apps/web/src/components/admin/HealthBanner.tsx
@@ -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(null);
+ const [lastUpdated, setLastUpdated] = useState(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 (
+
+
+
+
+ {locale === "es" ? "Salud del Sistema" : "System Health"}
+
+
+
+
+ CMS {cmsUp ? "UP" : "DOWN"}
+
+
+ {cmsUp ? `${health.checks.cms.latencyMs}ms` : "-"}
+
+
+
+ {lastUpdated && (
+
+ {locale === "es" ? "Actualizado" : "Last updated"}: {formatTime(lastUpdated)}
+
+ )}
+
+
+ {/* Per-server status */}
+ {health.checks.servers.length > 0 && (
+
+
+ {locale === "es" ? "Servidores de Juego" : "Game Servers"}
+
+
+ {health.checks.servers.map((server) => (
+
+
+
+ {server.name}
+
+
+ {server.status === "up" ? `${server.latencyMs}ms` : "OFFLINE"}
+
+
+ ))}
+
+
+ )}
+
+ );
+}
diff --git a/apps/web/src/components/admin/ServerStatusGrid.tsx b/apps/web/src/components/admin/ServerStatusGrid.tsx
new file mode 100644
index 0000000..c8a8583
--- /dev/null
+++ b/apps/web/src/components/admin/ServerStatusGrid.tsx
@@ -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 (
+
+ {servers.map((server) => {
+ const status = statusConfig[server.status];
+ return (
+
+
+
{server.name}
+
+
+ {status.label}
+
+
+
+
+
+ IP / Dominio
+ {server.ip}
+
+
+ Puerto
+ {server.ports}
+
+
+ Género
+ {server.type}
+
+
+ VM
+ {server.vm}
+
+
+
+ );
+ })}
+
+ );
+}
diff --git a/apps/web/src/components/admin/SubscriberChart.tsx b/apps/web/src/components/admin/SubscriberChart.tsx
new file mode 100644
index 0000000..5496063
--- /dev/null
+++ b/apps/web/src/components/admin/SubscriberChart.tsx
@@ -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(null);
+ const chartRef = useRef(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 (
+
+
+
+ );
+}
diff --git a/apps/web/src/components/afc/AfcPackageCard.tsx b/apps/web/src/components/afc/AfcPackageCard.tsx
deleted file mode 100644
index 7b93f69..0000000
--- a/apps/web/src/components/afc/AfcPackageCard.tsx
+++ /dev/null
@@ -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 (
-
- {popular && (
-
- POPULAR
-
- )}
-
-
-
- {amount}
-
-
-
{amount} AFC
-
AfterCoin
-
-
-
-
- );
-}
diff --git a/apps/web/src/components/afc/BalanceDisplay.tsx b/apps/web/src/components/afc/BalanceDisplay.tsx
deleted file mode 100644
index 7f13cee..0000000
--- a/apps/web/src/components/afc/BalanceDisplay.tsx
+++ /dev/null
@@ -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 (
-
-
- A
-
- {balance} AFC
-
- );
- }
-
- return (
-
-
- A
-
-
{t("your_balance")}
-
{balance}
-
AfterCoin
-
- );
-}
diff --git a/apps/web/src/components/afc/DiskIdInput.tsx b/apps/web/src/components/afc/DiskIdInput.tsx
deleted file mode 100644
index 3c0edb4..0000000
--- a/apps/web/src/components/afc/DiskIdInput.tsx
+++ /dev/null
@@ -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 (
-
-
- {playerName.charAt(0).toUpperCase()}
-
-
-
{t("disk_id")}: {diskId}
-
{playerName}
-
- {onClear && (
-
- {t("change")}
-
- )}
-
- );
- }
-
- return (
-
-
- {t("enter_disk_id")}
-
-
- 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"
- />
-
- {loading ? "..." : t("verify")}
-
-
- {error && (
-
{error}
- )}
-
- );
-}
diff --git a/apps/web/src/components/afc/PaymentHistoryTable.tsx b/apps/web/src/components/afc/PaymentHistoryTable.tsx
deleted file mode 100644
index 81390e9..0000000
--- a/apps/web/src/components/afc/PaymentHistoryTable.tsx
+++ /dev/null
@@ -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 (
- {t("no_payments")}
- );
- }
-
- return (
-
-
-
-
- {t("date")}
- AFC
- MXN
- {t("status")}
-
-
-
- {payments.map((p) => (
-
-
- {new Date(p.created_at).toLocaleDateString()}
-
-
- +{p.amount_afc}
-
-
- ${p.amount_mxn}
-
-
-
-
-
- ))}
-
-
-
- );
-}
diff --git a/apps/web/src/components/afc/PrizeCard.tsx b/apps/web/src/components/afc/PrizeCard.tsx
deleted file mode 100644
index 1ed5429..0000000
--- a/apps/web/src/components/afc/PrizeCard.tsx
+++ /dev/null
@@ -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 (
-
-
-
{icon}
-
-
-
{costAfc} AFC
-
${valueMxn} MXN
-
-
-
- );
-}
diff --git a/apps/web/src/components/afc/RedeemForm.tsx b/apps/web/src/components/afc/RedeemForm.tsx
deleted file mode 100644
index 4911367..0000000
--- a/apps/web/src/components/afc/RedeemForm.tsx
+++ /dev/null
@@ -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 (
-
-
-
-
{prizeDetail}
-
{costAfc} AFC
-
-
- {t("cancel")}
-
-
-
-
-
- {label}
-
- 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"
- />
-
-
-
- {t("redeem_warning")}
-
-
-
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")}
-
-
- );
-}
diff --git a/apps/web/src/components/afc/RedemptionHistoryTable.tsx b/apps/web/src/components/afc/RedemptionHistoryTable.tsx
deleted file mode 100644
index 6276402..0000000
--- a/apps/web/src/components/afc/RedemptionHistoryTable.tsx
+++ /dev/null
@@ -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 (
- {t("no_redemptions")}
- );
- }
-
- return (
-
-
-
-
- {t("date")}
- {t("prize")}
- AFC
- {t("status")}
-
-
-
- {redemptions.map((r) => (
-
-
- {new Date(r.created_at).toLocaleDateString()}
-
-
- {r.prize_detail}
-
-
- -{r.amount_afc}
-
-
-
-
-
- ))}
-
-
-
- );
-}
diff --git a/apps/web/src/components/afc/StatusBadge.tsx b/apps/web/src/components/afc/StatusBadge.tsx
deleted file mode 100644
index 4359107..0000000
--- a/apps/web/src/components/afc/StatusBadge.tsx
+++ /dev/null
@@ -1,26 +0,0 @@
-"use client";
-
-interface StatusBadgeProps {
- status: string;
-}
-
-const STATUS_STYLES: Record = {
- 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 (
-
- {status}
-
- );
-}
diff --git a/apps/web/src/components/auth/AuthProvider.tsx b/apps/web/src/components/auth/AuthProvider.tsx
new file mode 100644
index 0000000..a2597fe
--- /dev/null
+++ b/apps/web/src/components/auth/AuthProvider.tsx
@@ -0,0 +1,8 @@
+"use client";
+
+import { SessionProvider } from "next-auth/react";
+import { ReactNode } from "react";
+
+export function AuthProvider({ children }: { children: ReactNode }) {
+ return {children} ;
+}
diff --git a/apps/web/src/components/auth/LoginForm.tsx b/apps/web/src/components/auth/LoginForm.tsx
new file mode 100644
index 0000000..d4733df
--- /dev/null
+++ b/apps/web/src/components/auth/LoginForm.tsx
@@ -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 (
+
+
+
+ Project Afterlife
+
+
+ Inicia sesión para acceder a tu cuenta
+
+
+
+
+ {isLoading ? (
+
+ ) : (
+ <>
+
+
+
+ Iniciar sesión con Authentik
+ >
+ )}
+
+
+
+ Al iniciar sesión, aceptas nuestros términos y condiciones.
+
+
+ );
+}
diff --git a/apps/web/src/components/auth/ProfileCard.tsx b/apps/web/src/components/auth/ProfileCard.tsx
new file mode 100644
index 0000000..d87dccb
--- /dev/null
+++ b/apps/web/src/components/auth/ProfileCard.tsx
@@ -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 (
+
+
+ {user.image ? (
+
+ ) : (
+
+ )}
+
+
+ {user.name || "Usuario"}
+
+
{user.email}
+
+
+
+
+
+ Tu cuenta
+
+
+
+ Método de inicio
+ Authentik SSO
+
+
+ Estado
+
+
+ Activo
+
+
+
+
+
+
+
+ {isSigningOut ? (
+
+ ) : (
+ <>
+
+
+
+ Cerrar sesión
+ >
+ )}
+
+
+
+ );
+}
diff --git a/apps/web/src/components/bookmark/BookmarkButton.tsx b/apps/web/src/components/bookmark/BookmarkButton.tsx
new file mode 100644
index 0000000..b2c95ca
--- /dev/null
+++ b/apps/web/src/components/bookmark/BookmarkButton.tsx
@@ -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 (
+
+
+
+ );
+}
+
+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 (
+
+
+
+ );
+}
+
+export function BookmarksList() {
+ const [bookmarks, setBookmarks] = useState>([]);
+ 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 (
+
+
Bookmarks
+
+ {bookmarks.map((b) => (
+
+ {b.title}
+ removeBookmark(b.docId, b.chapterId)}
+ className="text-[#6b6b75] hover:text-red-400 transition-colors ml-2"
+ >
+
+
+
+
+
+
+ ))}
+
+
+ );
+}
diff --git a/apps/web/src/components/catalog/CatalogFilters.tsx b/apps/web/src/components/catalog/CatalogFilters.tsx
index d42db97..dfbe44b 100644
--- a/apps/web/src/components/catalog/CatalogFilters.tsx
+++ b/apps/web/src/components/catalog/CatalogFilters.tsx
@@ -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 (
-
+
+
+
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"
>
{t("filter_genre")}: {t("all")}
{GENRES.map((g) => (
{g}
))}
+
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"
>
{t("filter_status")}: {t("all")}
{STATUSES.map((s) => (
diff --git a/apps/web/src/components/catalog/CatalogGrid.tsx b/apps/web/src/components/catalog/CatalogGrid.tsx
index 9416536..3c250d6 100644
--- a/apps/web/src/components/catalog/CatalogGrid.tsx
+++ b/apps/web/src/components/catalog/CatalogGrid.tsx
@@ -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 (
-
{t("no_results")}
+
+ {t("no_results")}
+
+
+ );
+ }
+
+ if (filtered.length === 0) {
+ return (
+
+
+ {t("no_results")}
+
+
+ Try adjusting your filters or search query.
+
);
}
return (
- {games.map((game) => (
+ {filtered.map((game) => (
))}
diff --git a/apps/web/src/components/catalog/CatalogSkeleton.tsx b/apps/web/src/components/catalog/CatalogSkeleton.tsx
new file mode 100644
index 0000000..290a1a8
--- /dev/null
+++ b/apps/web/src/components/catalog/CatalogSkeleton.tsx
@@ -0,0 +1,18 @@
+import { GameCardSkeleton } from "../shared/GameCardSkeleton";
+
+export function CatalogSkeleton() {
+ return (
+
+
+
+ {Array.from({ length: 6 }).map((_, i) => (
+
+ ))}
+
+
+ );
+}
diff --git a/apps/web/src/components/documentary/AudioPlayer.tsx b/apps/web/src/components/documentary/AudioPlayer.tsx
index 8df64e2..f8b796a 100644
--- a/apps/web/src/components/documentary/AudioPlayer.tsx
+++ b/apps/web/src/components/documentary/AudioPlayer.tsx
@@ -42,11 +42,11 @@ export function AudioPlayer({
const progressPercent = duration > 0 ? (progress / duration) * 100 : 0;
return (
-
+
{/* Progress bar */}
{
const rect = e.currentTarget.getBoundingClientRect();
const ratio = (e.clientX - rect.left) / rect.width;
@@ -54,7 +54,7 @@ export function AudioPlayer({
}}
>
@@ -63,7 +63,7 @@ export function AudioPlayer({
{/* Play/Pause */}
{isPlaying ? "\u23F8" : "\u25B6"}
@@ -71,19 +71,19 @@ export function AudioPlayer({
{/* Track info */}
-
{trackTitle}
-
+
{trackTitle}
+
{formatTime(progress)} / {formatTime(duration)}
{/* Speed selector */}
-
{t("speed")}:
+
{t("speed")}:
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) => (
@@ -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")}
diff --git a/apps/web/src/components/documentary/ChapterContent.tsx b/apps/web/src/components/documentary/ChapterContent.tsx
index dfd8128..88b6b6c 100644
--- a/apps/web/src/components/documentary/ChapterContent.tsx
+++ b/apps/web/src/components/documentary/ChapterContent.tsx
@@ -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 (
-
+
{/* Chapter indicator */}
-
+
{String(chapter.order).padStart(2, "0")}
-
+
+
+
{readingTime} min read
+
+
+
+
+
+
+ Copy link
+
+
-
+
{chapter.title}
@@ -25,18 +108,25 @@ export function ChapterContent({ chapter }: ChapterContentProps) {
{chapter.coverImage && (
)}
+
+
);
}
diff --git a/apps/web/src/components/documentary/ChapterNav.tsx b/apps/web/src/components/documentary/ChapterNav.tsx
index 15b4b42..742c939 100644
--- a/apps/web/src/components/documentary/ChapterNav.tsx
+++ b/apps/web/src/components/documentary/ChapterNav.tsx
@@ -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;
}
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 (
-
+
{t("chapters")}
- {chapters.map((chapter, index) => (
-
- onSelectChapter(chapter.id, index)}
- 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]"
- }`}
- >
- {
+ const progress = chapterProgress[chapter.id] ?? 0;
+ const isCompleted = progress >= 90;
+ const isActive = chapter.id === activeChapterId;
+
+ return (
+
+ handleClick(chapter.id)}
+ className={`w-full text-left px-3 py-2.5 rounded-lg text-sm transition-all duration-200 ${
+ 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)]"
}`}
>
- {String(index + 1).padStart(2, "0")}
-
- {chapter.title}
-
-
- ))}
+
+
+ {String(index + 1).padStart(2, "0")}
+
+
{chapter.title}
+ {isCompleted && (
+
+
+
+ )}
+
+ {/* Progress indicator */}
+
+
+
+ );
+ })}
diff --git a/apps/web/src/components/documentary/DocumentaryLayout.tsx b/apps/web/src/components/documentary/DocumentaryLayout.tsx
index d46241c..0eb167e 100644
--- a/apps/web/src/components/documentary/DocumentaryLayout.tsx
+++ b/apps/web/src/components/documentary/DocumentaryLayout.tsx
@@ -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(chapters[0]);
-
+ const [activeChapterId, setActiveChapterId] = useState(
+ chapters[0]?.id ?? 0
+ );
+ const [readingMode, setReadingMode] = useState(false);
+ const [chapterProgress, setChapterProgress] = useState<
+ Record
+ >({});
+ const chapterRefs = useRef>(new Map());
+ const progressRef = useRef>({});
+ const rafRef = useRef(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;
+ 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,60 +77,299 @@ export function DocumentaryLayout({ documentary }: DocumentaryLayoutProps) {
}
}, []); // eslint-disable-line react-hooks/exhaustive-deps
- function handleSelectChapter(chapterId: number, index: number) {
+ // IntersectionObserver for scroll spy
+ useEffect(() => {
+ const observer = new IntersectionObserver(
+ (entries) => {
+ let best: IntersectionObserverEntry | null = null;
+ for (const entry of entries) {
+ if (!best || entry.intersectionRatio > best.intersectionRatio) {
+ best = entry;
+ }
+ }
+ if (best && best.intersectionRatio > 0) {
+ const id = Number(best.target.getAttribute("data-chapter-id"));
+ if (!isNaN(id)) {
+ setActiveChapterId(id);
+ }
+ }
+ },
+ { threshold: [0, 0.25, 0.5, 0.75, 1.0], rootMargin: "-10% 0px -40% 0px" }
+ );
+
+ chapterRefs.current.forEach((el) => observer.observe(el));
+ return () => observer.disconnect();
+ }, [chapters.length]);
+
+ // Scroll progress tracking per chapter (throttled with rAF)
+ useEffect(() => {
+ function handleScroll() {
+ if (rafRef.current) return;
+ rafRef.current = requestAnimationFrame(() => {
+ rafRef.current = 0;
+ const updates: Record = {};
+ 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) {
- setActiveChapter(chapter);
- window.scrollTo({ top: 0, behavior: "smooth" });
- const trackIndex = audio.tracks.findIndex((t) => t.id === chapterId);
- if (trackIndex !== -1) {
- audio.goToTrack(trackIndex);
- }
+ 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 (
<>
-
+ c.id === activeChapterId)?.title ?? ""
+ }
+ progress={chapterProgress[activeChapterId] ?? 0}
+ />
{/* Documentary header */}
-
+
-
-
+
+ {!readingMode && (
+
+ )}
-
+ {chapters.map((chapter) => (
+
{
+ if (el) {
+ chapterRefs.current.set(chapter.id, el);
+ } else {
+ chapterRefs.current.delete(chapter.id);
+ }
+ }}
+ data-chapter-id={chapter.id}
+ className="scroll-mt-24"
+ >
+
+
+ ))}
+
+ {/* Previous/Next navigation */}
+
+
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"
+ >
+
+
+
+ Previous
+
+
+ {activeIndex + 1} / {chapters.length}
+
+
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"
+ >
+ Next
+
+
+
+
+
-
- audio.setContinuousMode(!audio.continuousMode)
- }
- />
+
+ {!readingMode && (
+
+ audio.setContinuousMode(!audio.continuousMode)
+ }
+ />
+ )}
+
+ {!readingMode && (
+
+
+
+ )}
>
);
}
diff --git a/apps/web/src/components/documentary/GiscusComments.tsx b/apps/web/src/components/documentary/GiscusComments.tsx
new file mode 100644
index 0000000..f2f9c36
--- /dev/null
+++ b/apps/web/src/components/documentary/GiscusComments.tsx
@@ -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 (
+
+
Comments
+
+
+ );
+}
diff --git a/apps/web/src/components/documentary/ReadingProgress.tsx b/apps/web/src/components/documentary/ReadingProgress.tsx
index 04c1364..42ba634 100644
--- a/apps/web/src/components/documentary/ReadingProgress.tsx
+++ b/apps/web/src/components/documentary/ReadingProgress.tsx
@@ -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 (
-
+
setHovered(true)}
+ onMouseLeave={() => setHovered(false)}
+ >
+ {hovered && (
+
+ {chapterName
+ ? `${chapterName} · ${progress}%`
+ : `${Math.round(scrollProgress)}%`}
+
+ )}
);
}
diff --git a/apps/web/src/components/game/GameHeader.tsx b/apps/web/src/components/game/GameHeader.tsx
index a841b49..237f1a8 100644
--- a/apps/web/src/components/game/GameHeader.tsx
+++ b/apps/web/src/components/game/GameHeader.tsx
@@ -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) {
{game.coverImage && (
)}
-
+
{game.title}
-
+
{game.developer} · {game.releaseYear}–{game.shutdownYear}
diff --git a/apps/web/src/components/game/GameInfo.tsx b/apps/web/src/components/game/GameInfo.tsx
index 4d31957..f54b26c 100644
--- a/apps/web/src/components/game/GameInfo.tsx
+++ b/apps/web/src/components/game/GameInfo.tsx
@@ -26,36 +26,36 @@ export function GameInfo({ game, locale }: GameInfoProps) {
/>
-
+
-
+
{t("developer")}
- {game.developer}
+ {game.developer}
{game.publisher && (
-
+
{t("publisher")}
- {game.publisher}
+ {game.publisher}
)}
-
+
{t("released")}
- {game.releaseYear}
+ {game.releaseYear}
-
+
{t("shutdown")}
- {game.shutdownYear}
+ {game.shutdownYear}
-
+
{t("server_status")}
@@ -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")}
@@ -77,7 +77,7 @@ export function GameInfo({ game, locale }: GameInfoProps) {
{game.documentary && (
{t("view_documentary")}
diff --git a/apps/web/src/components/game/ScreenshotGallery.tsx b/apps/web/src/components/game/ScreenshotGallery.tsx
index d5b4a5a..b406615 100644
--- a/apps/web/src/components/game/ScreenshotGallery.tsx
+++ b/apps/web/src/components/game/ScreenshotGallery.tsx
@@ -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"
}`}
>
+
+
+ ),
+ },
+ {
+ key: "feature2",
+ icon: (
+
+
+
+ ),
+ },
+ {
+ key: "feature3",
+ icon: (
+
+
+
+ ),
+ },
+];
+
+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 (
+
+
+
+ {/* Audio player mockup */}
+
+
+ {/* Player header */}
+
+
+
+
+ Cap. 4 - La Era Dorada
+
+
+ FusionFall - Documental Interactivo
+
+
+
+
+ {/* Progress bar */}
+
+
+ {/* Time */}
+
+ 12:34
+ 28:15
+
+
+ {/* Controls */}
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ {/* Chapters */}
+
+ {chapters.map((ch) => (
+
+
+ {ch.num}
+
+
+ {ch.title}
+
+
+ {ch.dur}
+
+
+ ))}
+
+
+
+
+ {/* Content */}
+
+
+ {t("experience_title")}
+ {t("experience_title_span")}
+
+
+ {t("experience_subtitle")}
+
+
+
+ {features.map((feature) => (
+
+
+ {feature.icon}
+
+
+
+ {t(`${feature.key}_title`)}
+
+
+ {t(`${feature.key}_desc`)}
+
+
+
+ ))}
+
+
+
+
+
+ );
+}
diff --git a/apps/web/src/components/home/DonationCTA.tsx b/apps/web/src/components/home/DonationCTA.tsx
index 3270f46..b0d0252 100644
--- a/apps/web/src/components/home/DonationCTA.tsx
+++ b/apps/web/src/components/home/DonationCTA.tsx
@@ -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 (
-
-
-
{t("title")}
-
{t("description")}
+
+ {/* Animated gradient background */}
+
+
+
+
+
+ {t("title")}
+
+
+ {t("description")}
+
+
{t("patreon")}
+
+ {/* Stat badges */}
+
+
+ 12
+ games preserved
+
+
+ 2.4k
+ players active
+
+
);
diff --git a/apps/web/src/components/home/DonationSection.tsx b/apps/web/src/components/home/DonationSection.tsx
new file mode 100644
index 0000000..d5f022a
--- /dev/null
+++ b/apps/web/src/components/home/DonationSection.tsx
@@ -0,0 +1,201 @@
+"use client";
+
+import { motion } from "framer-motion";
+import { useTranslations } from "next-intl";
+
+export function DonationSection() {
+ const t = useTranslations("home");
+
+ return (
+
+
+ {/* Header */}
+
+
+ {t("donate_label")}
+
+
+ {t("donate_title")}
+ {t("donate_title_span")}
+
+
+ {t("donate_subtitle")}
+
+
+
+ {/* Cards */}
+
+
+ {/* Transparency */}
+
+
+
+
+ {t("donate_transparency_title")}
+
+
+ {t("donate_transparency_desc")}
+
+
+
+
+
+ );
+}
diff --git a/apps/web/src/components/home/GamesShowcaseSection.tsx b/apps/web/src/components/home/GamesShowcaseSection.tsx
new file mode 100644
index 0000000..02c322e
--- /dev/null
+++ b/apps/web/src/components/home/GamesShowcaseSection.tsx
@@ -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 (
+
+
+ {/* Header */}
+
+
+ {t("games_label")}
+
+
+ {t("games_title")}
+ {t("games_title_span")}
+
+
+ {t("games_subtitle")}
+
+
+
+ {/* Carousel */}
+
+
+
+ {/* Content */}
+
+
+
+ {status.label}
+
+
+
+ {game.title}
+
+
+
+ {game.description}
+
+
+
+
+ {game.genre}
+
+
+ {game.releaseYear} - {game.shutdownYear || "?"}
+
+
+ {game.publisher}
+
+
+
+
{
+ 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
+
+
+
+
+
+
+ {/* Card */}
+
+
+
+ {game.title}
+
+
+
+
+ {game.title}
+
+
+ {game.developer} / {game.publisher}
+
+
+
+
+
+
+
+ {game.releaseYear} - {game.shutdownYear || "?"}
+
+
+
+
+
+
+
+ {/* Navigation */}
+
+
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)";
+ }}
+ >
+
+
+
+
+
+
+ {games.map((_, index) => (
+ 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)",
+ }}
+ />
+ ))}
+
+
+
+ {current + 1} / {games.length}
+
+
+
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)";
+ }}
+ >
+
+
+
+
+
+
+
+
+ );
+}
diff --git a/apps/web/src/components/home/HeroSection.tsx b/apps/web/src/components/home/HeroSection.tsx
index b7e5fa2..6e2f56a 100644
--- a/apps/web/src/components/home/HeroSection.tsx
+++ b/apps/web/src/components/home/HeroSection.tsx
@@ -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
(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
;
+}
export function HeroSection() {
const t = useTranslations("home");
const locale = useLocale();
return (
-
-
-
-
- {t("hero_title")}
-
-
- {t("hero_subtitle")}
-
+
+ {/* Background layers */}
+
+ {/* Dot grid */}
+
+ {/* Primary gradient */}
+
+ {/* Secondary gradient */}
+
+ {/* Particles */}
+
+
+
+
-
- {t("view_all")}
-
-
+
+
+
+
+ {/* Badge */}
+
- {t("donate_cta")}
-
+
+ {t("hero_badge")}
+
+
+ {/* Title */}
+
+ {t("hero_title")}
+
+
+ {/* Tagline */}
+
+ {t("hero_tagline")}
+
+
+ {/* Subtitle */}
+
+ {t("hero_subtitle")}
+
+
+ {/* Buttons */}
+
+
{
+ e.currentTarget.style.transform = "translateY(-3px)";
+ e.currentTarget.style.boxShadow = "0 8px 40px var(--accent-glow)";
+ }}
+ onMouseLeave={(e) => {
+ e.currentTarget.style.transform = "translateY(0)";
+ e.currentTarget.style.boxShadow = "0 4px 25px var(--accent-glow)";
+ }}
+ >
+ {t("hero_cta_primary")}
+
+
+
+
+
{
+ e.currentTarget.style.borderColor = "var(--accent-primary)";
+ e.currentTarget.style.background = "rgba(212, 165, 116, 0.05)";
+ }}
+ onMouseLeave={(e) => {
+ e.currentTarget.style.borderColor = "var(--border-color)";
+ e.currentTarget.style.background = "transparent";
+ }}
+ >
+ {t("hero_cta_secondary")}
+
+
diff --git a/apps/web/src/components/home/LatestGames.tsx b/apps/web/src/components/home/LatestGames.tsx
index 499f73f..5de1cde 100644
--- a/apps/web/src/components/home/LatestGames.tsx
+++ b/apps/web/src/components/home/LatestGames.tsx
@@ -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 (
{t("latest_games")}
{t("view_all")} →
- {games.slice(0, 6).map((game) => (
-
+ {games.slice(0, 6).map((game, i) => (
+
+
+
))}
diff --git a/apps/web/src/components/home/PillarsSection.tsx b/apps/web/src/components/home/PillarsSection.tsx
new file mode 100644
index 0000000..dabb456
--- /dev/null
+++ b/apps/web/src/components/home/PillarsSection.tsx
@@ -0,0 +1,120 @@
+"use client";
+
+import { motion } from "framer-motion";
+import { useTranslations } from "next-intl";
+
+const pillars = [
+ {
+ key: "servers",
+ icon: (
+
+
+
+ ),
+ },
+ {
+ key: "documentaries",
+ icon: (
+
+
+
+ ),
+ },
+ {
+ key: "preservation",
+ icon: (
+
+
+
+ ),
+ },
+];
+
+export function PillarsSection() {
+ const t = useTranslations("home");
+
+ return (
+
+
+ {/* Header */}
+
+
+ {t("pillars_label")}
+
+
+ {t("pillars_title")}
+ {t("pillars_title_span")} ?
+
+
+ {t("pillars_subtitle")}
+
+
+
+ {/* Grid */}
+
+ {pillars.map((pillar, index) => (
+
+ {/* Top accent line */}
+ (e.currentTarget.style.transform = "scaleX(1)")}
+ onMouseLeave={(e) => (e.currentTarget.style.transform = "scaleX(0)")}
+ />
+
+ {/* Icon */}
+
+ {pillar.icon}
+
+
+ {/* Title */}
+
+ {t(`pillar_${pillar.key}_title`)}
+
+
+ {/* Description */}
+
+ {t(`pillar_${pillar.key}_desc`)}
+
+
+ ))}
+
+
+
+ );
+}
diff --git a/apps/web/src/components/home/TechStackSection.tsx b/apps/web/src/components/home/TechStackSection.tsx
new file mode 100644
index 0000000..0429582
--- /dev/null
+++ b/apps/web/src/components/home/TechStackSection.tsx
@@ -0,0 +1,140 @@
+"use client";
+
+import { motion } from "framer-motion";
+import { useTranslations } from "next-intl";
+
+const columns = [
+ {
+ key: "frontend",
+ icon: (
+
+
+
+ ),
+ 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: (
+
+
+
+ ),
+ items: [
+ { name: "Strapi 5", desc: "CMS Headless" },
+ { name: "PostgreSQL", desc: "Base de datos" },
+ { name: "MinIO", desc: "Almacenamiento de medios" },
+ ],
+ },
+ {
+ key: "infra",
+ icon: (
+
+
+
+ ),
+ 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 (
+
+
+ {/* Header */}
+
+
+ {t("stack_label")}
+
+
+ {t("stack_title")}
+ {t("stack_title_span")}
+
+
+ {t("stack_subtitle")}
+
+
+
+ {/* Grid */}
+
+ {columns.map((col, colIndex) => (
+
+ {/* Header */}
+
+
+ {col.icon}
+
+
+ {t(`stack_${col.key}`)}
+
+
+
+ {/* Items */}
+
+ {col.items.map((item, index) => (
+
+
+
+
+ {item.name}
+
+
+ {item.desc}
+
+
+
+ ))}
+
+
+ ))}
+
+
+
+ );
+}
diff --git a/apps/web/src/components/layout/Footer.tsx b/apps/web/src/components/layout/Footer.tsx
index fbaa56d..1d739e7 100644
--- a/apps/web/src/components/layout/Footer.tsx
+++ b/apps/web/src/components/layout/Footer.tsx
@@ -1,12 +1,21 @@
+"use client";
+
import { useTranslations } from "next-intl";
export function Footer() {
const t = useTranslations("footer");
return (
-