feat: phase 3 redesign, game images, auth system, vm guides, service isolation
Some checks failed
Deploy Multi-VM / Deploy VM Web (push) Has been cancelled
Deploy Multi-VM / Deploy VM Auth (push) Has been cancelled
Deploy Multi-VM / Deploy Game Servers (docker-compose.fusionfall.yml, VM_FUSIONFALL_HOST, VM_FUSIONFALL_SSH_KEY, VM_FUSIONFALL_USER, fusionfall) (push) Has been cancelled
Deploy Multi-VM / Deploy Game Servers (docker-compose.maple2.yml, VM_MAPLE2_HOST, VM_MAPLE2_SSH_KEY, VM_MAPLE2_USER, maple2) (push) Has been cancelled
Deploy Multi-VM / Deploy Game Servers (docker-compose.minecraft.yml, VM_MINECRAFT_HOST, VM_MINECRAFT_SSH_KEY, VM_MINECRAFT_USER, minecraft) (push) Has been cancelled
Deploy Multi-VM / Deploy Game Servers (docker-compose.retro.yml, VM_RETRO_HOST, VM_RETRO_SSH_KEY, VM_RETRO_USER, retro) (push) Has been cancelled

- Redesign all internal pages to warm/gold aesthetic (catalog, game detail,
  documentary, about, donate, community, guides, contact, server-status,
  login, profile, admin, not-found)
- Add real cover images for all 4 games via Strapi CMS with getImageUrl helper
- Integrate NextAuth v5 with Authentik OIDC authentication
- Add new public pages: community, guides, contact, server-status
- Add new protected pages: login, profile, admin dashboard
- Remove legacy AFC/MercadoPago system entirely
- Add Docker Compose split files for service isolation (main, auth, fusionfall, nier)
- Add OpenFusion VM deployment configs (config.vm.ini, systemd service, README-VM)
- Add NieR Reincarnation server guide and desktop client guide
- Add architecture docs for multi-VM deployment
- Add healthcheck, SSE, contact, newsletter, admin API routes
- Add reusable UI components, skeleton loaders, activity feed, bookmark system
- Update deployment and game server documentation
This commit is contained in:
consultoria-as
2026-04-28 05:15:38 +00:00
parent ea142501fa
commit 449c02eadc
151 changed files with 10053 additions and 2312 deletions

View File

@@ -1,11 +1,23 @@
import type { Metadata } from "next";
import { useTranslations } from "next-intl";
export function generateMetadata(): Metadata {
return {
title: "About Us",
};
}
export default function AboutPage() {
const t = useTranslations("about");
return (
<div className="max-w-4xl mx-auto px-4 py-12">
<h1 className="text-4xl font-bold mb-12">{t("title")}</h1>
<h1
className="text-[clamp(2rem,4vw,3rem)] font-extrabold mb-12"
style={{ fontFamily: "var(--font-display)", letterSpacing: "-0.02em" }}
>
{t("title")}
</h1>
<section className="mb-12">
<h2 className="text-2xl font-semibold mb-4">{t("mission")}</h2>
@@ -22,8 +34,8 @@ export default function AboutPage() {
<section className="mb-12">
<h2 className="text-2xl font-semibold mb-4">{t("team")}</h2>
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
<div className="bg-gray-900 rounded-lg p-6 border border-white/5">
<p className="text-gray-500 text-sm">Team members coming soon.</p>
<div className="bg-[#12121a] rounded-lg p-6 border border-[rgba(255,255,255,0.08)]">
<p className="text-[#6b6b75] text-sm">Team members coming soon.</p>
</div>
</div>
</section>

View File

@@ -0,0 +1,214 @@
"use client";
import { useEffect, useState, useMemo } from "react";
import { SubscriberChart } from "@/components/admin/SubscriberChart";
interface Subscriber {
id: number;
email: string;
locale: string;
created_at: string;
}
interface ContactMessage {
id: number;
name: string;
email: string;
subject: string | null;
message: string;
created_at: string;
}
function exportCSV(data: Subscriber[]) {
const headers = ["ID", "Email", "Locale", "Created At"];
const rows = data.map((s) => [s.id, s.email, s.locale, s.created_at]);
const csv = [headers, ...rows].map((r) => r.join(",")).join("\n");
const blob = new Blob([csv], { type: "text/csv" });
const url = URL.createObjectURL(blob);
const a = document.createElement("a");
a.href = url;
a.download = `subscribers-${new Date().toISOString().split("T")[0]}.csv`;
a.click();
URL.revokeObjectURL(url);
}
function formatDate(iso: string) {
return new Date(iso).toLocaleDateString("es-ES", {
year: "numeric",
month: "short",
day: "numeric",
hour: "2-digit",
minute: "2-digit",
});
}
export default function AdminPage() {
const [apiKey, setApiKey] = useState("");
const [subscribers, setSubscribers] = useState<Subscriber[]>([]);
const [messages, setMessages] = useState<ContactMessage[]>([]);
const [subTotal, setSubTotal] = useState(0);
const [msgTotal, setMsgTotal] = useState(0);
const [loading, setLoading] = useState(false);
const [error, setError] = useState("");
async function fetchData() {
if (!apiKey.trim()) return;
setLoading(true);
setError("");
try {
const [subRes, msgRes] = await Promise.all([
fetch("/api/admin/subscribers?limit=50", {
headers: { "x-admin-key": apiKey },
}),
fetch("/api/admin/messages?limit=50", {
headers: { "x-admin-key": apiKey },
}),
]);
if (!subRes.ok || !msgRes.ok) {
setError("Invalid API key or unauthorized");
setLoading(false);
return;
}
const subData = await subRes.json();
const msgData = await msgRes.json();
setSubscribers(subData.subscribers || []);
setSubTotal(subData.total || 0);
setMessages(msgData.messages || []);
setMsgTotal(msgData.total || 0);
} catch {
setError("Failed to fetch data");
} finally {
setLoading(false);
}
}
return (
<div className="max-w-6xl mx-auto px-4 py-12">
<h1
className="text-[clamp(1.75rem,3vw,2.5rem)] font-extrabold mb-8"
style={{ fontFamily: "var(--font-display)", letterSpacing: "-0.02em" }}
>
Admin Dashboard
</h1>
<div className="mb-8 flex flex-col sm:flex-row gap-3 items-start">
<input
type="password"
value={apiKey}
onChange={(e) => setApiKey(e.target.value)}
placeholder="Enter admin API key"
className="flex-1 bg-[#12121a] border border-[rgba(255,255,255,0.08)] rounded-lg px-4 py-2.5 text-sm text-[#f5f5f7] focus:outline-none focus:border-[rgba(212,165,116,0.4)]"
/>
<button
onClick={fetchData}
disabled={loading}
className="px-6 py-2.5 bg-[#d4a574] text-[#0a0a0f] text-sm font-semibold rounded-lg hover:bg-[#e8c4a0] transition-colors disabled:opacity-50"
>
{loading ? "Loading..." : "Load Data"}
</button>
{subscribers.length > 0 && (
<button
onClick={() => exportCSV(subscribers)}
className="px-6 py-2.5 bg-emerald-600 text-[#f5f5f7] text-sm font-semibold rounded-lg hover:bg-emerald-500 transition-colors"
>
Export CSV
</button>
)}
</div>
{error && (
<div className="mb-6 p-4 rounded-xl bg-red-500/10 border border-red-500/20 text-red-400 text-sm">
{error}
</div>
)}
{/* Stats */}
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4 mb-10">
<div className="p-6 rounded-2xl bg-[#12121a] border border-[rgba(255,255,255,0.08)]">
<div className="text-3xl font-bold text-[#f5f5f7]">{subTotal}</div>
<div className="text-sm text-[#a0a0a8] mt-1">Newsletter Subscribers</div>
</div>
<div className="p-6 rounded-2xl bg-[#12121a] border border-[rgba(255,255,255,0.08)]">
<div className="text-3xl font-bold text-[#f5f5f7]">{msgTotal}</div>
<div className="text-sm text-[#a0a0a8] mt-1">Contact Messages</div>
</div>
</div>
{/* Subscribers Chart */}
{subscribers.length > 0 && (
<div className="mb-10 p-6 rounded-2xl bg-[#12121a] border border-[rgba(255,255,255,0.08)]">
<h2 className="text-lg font-semibold mb-4">Subscriber Growth</h2>
<SubscriberChart
data={useMemo(() => {
const map = new Map<string, number>();
subscribers.forEach((s) => {
const date = new Date(s.created_at).toLocaleDateString("en-US", {
month: "short",
day: "numeric",
});
map.set(date, (map.get(date) || 0) + 1);
});
return Array.from(map.entries()).map(([date, count]) => ({ date, count }));
}, [subscribers])}
/>
</div>
)}
{/* Subscribers Table */}
{subscribers.length > 0 && (
<div className="mb-10">
<h2 className="text-lg font-semibold mb-4">Recent Subscribers</h2>
<div className="overflow-x-auto rounded-2xl border border-[rgba(255,255,255,0.08)]">
<table className="w-full text-sm">
<thead className="bg-[rgba(255,255,255,0.03)] text-[#a0a0a8]">
<tr>
<th className="text-left px-4 py-3 font-medium">Email</th>
<th className="text-left px-4 py-3 font-medium">Locale</th>
<th className="text-left px-4 py-3 font-medium">Date</th>
</tr>
</thead>
<tbody className="divide-y divide-[rgba(255,255,255,0.06)]">
{subscribers.map((sub) => (
<tr key={sub.id} className="hover:bg-[rgba(255,255,255,0.02)]">
<td className="px-4 py-3 text-[#a0a0a8]">{sub.email}</td>
<td className="px-4 py-3 text-[#6b6b75]">{sub.locale}</td>
<td className="px-4 py-3 text-[#6b6b75]">{formatDate(sub.created_at)}</td>
</tr>
))}
</tbody>
</table>
</div>
</div>
)}
{/* Messages Table */}
{messages.length > 0 && (
<div>
<h2 className="text-lg font-semibold mb-4">Recent Contact Messages</h2>
<div className="space-y-3">
{messages.map((msg) => (
<div
key={msg.id}
className="p-4 rounded-xl bg-[#12121a] border border-[rgba(255,255,255,0.08)] hover:border-[rgba(212,165,116,0.2)] transition-colors"
>
<div className="flex items-center justify-between mb-2">
<div className="flex items-center gap-2">
<span className="font-medium text-[#f5f5f7]">{msg.name}</span>
<span className="text-[#6b6b75]">&lt;{msg.email}&gt;</span>
</div>
<span className="text-xs text-[#6b6b75]">{formatDate(msg.created_at)}</span>
</div>
{msg.subject && <div className="text-sm text-[#a0a0a8] mb-1">{msg.subject}</div>}
<div className="text-sm text-[#6b6b75]">{msg.message}</div>
</div>
))}
</div>
</div>
)}
</div>
);
}

View File

@@ -1,33 +0,0 @@
"use client";
import Link from "next/link";
import { useLocale, useTranslations } from "next-intl";
export default function BuyFailurePage() {
const t = useTranslations("afc");
const locale = useLocale();
return (
<div className="max-w-lg mx-auto px-4 py-20 text-center">
<div className="w-20 h-20 mx-auto mb-6 rounded-full bg-red-500/15 border-2 border-red-500/30 flex items-center justify-center">
<span className="text-4xl"></span>
</div>
<h1 className="text-3xl font-bold mb-3 text-white">{t("payment_failure_title")}</h1>
<p className="text-gray-400 mb-8">{t("payment_failure_description")}</p>
<div className="flex flex-col sm:flex-row gap-3 justify-center">
<Link
href={`/${locale}/afc/buy`}
className="px-6 py-3 bg-amber-500 hover:bg-amber-400 text-black font-semibold rounded-xl transition-colors"
>
{t("try_again")}
</Link>
<Link
href={`/${locale}/afc`}
className="px-6 py-3 bg-gray-800 hover:bg-gray-700 text-white font-semibold rounded-xl transition-colors"
>
{t("back_to_store")}
</Link>
</div>
</div>
);
}

View File

@@ -1,138 +0,0 @@
"use client";
import { useState } from "react";
import Link from "next/link";
import { useLocale, useTranslations } from "next-intl";
import { useDiskId } from "@/hooks/useDiskId";
import { createPreference } from "@/lib/afc";
import { DiskIdInput } from "@/components/afc/DiskIdInput";
import { BalanceDisplay } from "@/components/afc/BalanceDisplay";
import { AfcPackageCard } from "@/components/afc/AfcPackageCard";
const PRICE_PER_AFC = 15;
const PACKAGES = [
{ amount: 10, popular: false },
{ amount: 25, popular: true },
{ amount: 50, popular: false },
{ amount: 100, popular: false },
];
export default function BuyAfcPage() {
const t = useTranslations("afc");
const locale = useLocale();
const disk = useDiskId();
const [customAmount, setCustomAmount] = useState("");
const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
async function handleBuy(amount: number) {
if (!disk.verified || !disk.diskId) return;
setLoading(true);
setError(null);
try {
const data = await createPreference(disk.diskId, amount);
// Redirect to MercadoPago checkout
window.location.href = data.initPoint;
} catch (e: any) {
setError(e.message);
setLoading(false);
}
}
return (
<div className="max-w-3xl mx-auto px-4 py-12">
{/* Back */}
<Link
href={`/${locale}/afc`}
className="inline-flex items-center gap-2 text-sm text-gray-500 hover:text-gray-300 transition-colors mb-8"
>
{t("back_to_store")}
</Link>
<h1 className="text-3xl font-bold mb-2">{t("buy_title")}</h1>
<p className="text-gray-400 mb-8">{t("buy_subtitle")}</p>
{/* Disk ID */}
<div className="mb-8">
<DiskIdInput
diskId={disk.diskId}
onChange={disk.setDiskId}
onVerify={() => disk.verify(disk.diskId)}
loading={disk.loading}
verified={disk.verified}
playerName={disk.playerName}
error={disk.error}
onClear={disk.clear}
/>
</div>
{disk.verified && (
<>
{/* Balance */}
<div className="mb-8">
<BalanceDisplay balance={disk.balance} compact />
</div>
{/* Packages */}
<div className="space-y-3 mb-8">
<h2 className="text-lg font-semibold text-white mb-4">{t("select_package")}</h2>
{PACKAGES.map((pkg) => (
<AfcPackageCard
key={pkg.amount}
amount={pkg.amount}
priceMxn={pkg.amount * PRICE_PER_AFC}
popular={pkg.popular}
loading={loading}
onSelect={() => handleBuy(pkg.amount)}
/>
))}
</div>
{/* Custom amount */}
<div className="bg-gray-900 border border-white/5 rounded-2xl p-6">
<h3 className="text-sm font-medium text-gray-400 mb-3">{t("custom_amount")}</h3>
<div className="flex gap-3">
<div className="flex-1 relative">
<input
type="number"
min="1"
value={customAmount}
onChange={(e) => setCustomAmount(e.target.value)}
placeholder="AFC"
className="w-full bg-gray-800 border border-white/10 rounded-xl px-4 py-3 text-white placeholder:text-gray-600 focus:outline-none focus:border-amber-500/50 transition-all"
/>
{customAmount && Number(customAmount) > 0 && (
<span className="absolute right-4 top-1/2 -translate-y-1/2 text-sm text-gray-500">
= ${Number(customAmount) * PRICE_PER_AFC} MXN
</span>
)}
</div>
<button
onClick={() => {
const amt = Number(customAmount);
if (amt >= 1) handleBuy(amt);
}}
disabled={loading || !customAmount || Number(customAmount) < 1}
className="px-6 py-3 bg-amber-500 hover:bg-amber-400 disabled:bg-gray-700 disabled:text-gray-500 text-black font-semibold rounded-xl transition-colors"
>
{t("buy")}
</button>
</div>
</div>
{error && (
<div className="mt-4 bg-red-500/10 border border-red-500/30 rounded-xl p-4 text-sm text-red-400">
{error}
</div>
)}
{/* Payment info */}
<p className="mt-6 text-xs text-gray-600 text-center">
{t("payment_info")}
</p>
</>
)}
</div>
);
}

View File

@@ -1,39 +0,0 @@
"use client";
import Link from "next/link";
import { useLocale, useTranslations } from "next-intl";
import { useSearchParams } from "next/navigation";
export default function BuyPendingPage() {
const t = useTranslations("afc");
const locale = useLocale();
const searchParams = useSearchParams();
const paymentId = searchParams.get("payment_id");
return (
<div className="max-w-lg mx-auto px-4 py-20 text-center">
<div className="w-20 h-20 mx-auto mb-6 rounded-full bg-yellow-500/15 border-2 border-yellow-500/30 flex items-center justify-center">
<span className="text-4xl"></span>
</div>
<h1 className="text-3xl font-bold mb-3 text-white">{t("payment_pending_title")}</h1>
<p className="text-gray-400 mb-2">{t("payment_pending_description")}</p>
{paymentId && (
<p className="text-xs text-gray-600 mb-8 font-mono">ID: {paymentId}</p>
)}
<div className="flex flex-col sm:flex-row gap-3 justify-center">
<Link
href={`/${locale}/afc/history`}
className="px-6 py-3 bg-amber-500 hover:bg-amber-400 text-black font-semibold rounded-xl transition-colors"
>
{t("view_history")}
</Link>
<Link
href={`/${locale}/afc`}
className="px-6 py-3 bg-gray-800 hover:bg-gray-700 text-white font-semibold rounded-xl transition-colors"
>
{t("back_to_store")}
</Link>
</div>
</div>
);
}

View File

@@ -1,39 +0,0 @@
"use client";
import Link from "next/link";
import { useLocale, useTranslations } from "next-intl";
import { useSearchParams } from "next/navigation";
export default function BuySuccessPage() {
const t = useTranslations("afc");
const locale = useLocale();
const searchParams = useSearchParams();
const paymentId = searchParams.get("payment_id");
return (
<div className="max-w-lg mx-auto px-4 py-20 text-center">
<div className="w-20 h-20 mx-auto mb-6 rounded-full bg-green-500/15 border-2 border-green-500/30 flex items-center justify-center">
<span className="text-4xl"></span>
</div>
<h1 className="text-3xl font-bold mb-3 text-white">{t("payment_success_title")}</h1>
<p className="text-gray-400 mb-2">{t("payment_success_description")}</p>
{paymentId && (
<p className="text-xs text-gray-600 mb-8 font-mono">ID: {paymentId}</p>
)}
<div className="flex flex-col sm:flex-row gap-3 justify-center">
<Link
href={`/${locale}/afc`}
className="px-6 py-3 bg-amber-500 hover:bg-amber-400 text-black font-semibold rounded-xl transition-colors"
>
{t("back_to_store")}
</Link>
<Link
href={`/${locale}/afc/history`}
className="px-6 py-3 bg-gray-800 hover:bg-gray-700 text-white font-semibold rounded-xl transition-colors"
>
{t("view_history")}
</Link>
</div>
</div>
);
}

View File

@@ -1,101 +0,0 @@
"use client";
import { useState, useEffect } from "react";
import Link from "next/link";
import { useLocale, useTranslations } from "next-intl";
import { useDiskId } from "@/hooks/useDiskId";
import { getPaymentHistory, getRedemptionHistory } from "@/lib/afc";
import type { Payment, Redemption } from "@/lib/afc";
import { DiskIdInput } from "@/components/afc/DiskIdInput";
import { BalanceDisplay } from "@/components/afc/BalanceDisplay";
import { PaymentHistoryTable } from "@/components/afc/PaymentHistoryTable";
import { RedemptionHistoryTable } from "@/components/afc/RedemptionHistoryTable";
type Tab = "payments" | "redemptions";
export default function HistoryPage() {
const t = useTranslations("afc");
const locale = useLocale();
const disk = useDiskId();
const [tab, setTab] = useState<Tab>("payments");
const [payments, setPayments] = useState<Payment[]>([]);
const [redemptions, setRedemptions] = useState<Redemption[]>([]);
const [loadingData, setLoadingData] = useState(false);
useEffect(() => {
if (!disk.verified || !disk.diskId) return;
setLoadingData(true);
Promise.all([
getPaymentHistory(disk.diskId).then((d) => setPayments(d.payments || [])).catch(() => {}),
getRedemptionHistory(disk.diskId).then((d) => setRedemptions(d.redemptions || [])).catch(() => {}),
]).finally(() => setLoadingData(false));
}, [disk.verified, disk.diskId]);
return (
<div className="max-w-3xl mx-auto px-4 py-12">
<Link
href={`/${locale}/afc`}
className="inline-flex items-center gap-2 text-sm text-gray-500 hover:text-gray-300 transition-colors mb-8"
>
{t("back_to_store")}
</Link>
<h1 className="text-3xl font-bold mb-2">{t("history_title")}</h1>
<p className="text-gray-400 mb-8">{t("history_subtitle")}</p>
{/* Disk ID */}
<div className="mb-8">
<DiskIdInput
diskId={disk.diskId}
onChange={disk.setDiskId}
onVerify={() => disk.verify(disk.diskId)}
loading={disk.loading}
verified={disk.verified}
playerName={disk.playerName}
error={disk.error}
onClear={disk.clear}
/>
</div>
{disk.verified && (
<>
<div className="mb-8">
<BalanceDisplay balance={disk.balance} compact />
</div>
{/* Tabs */}
<div className="flex gap-1 bg-gray-900 rounded-xl p-1 mb-6">
<button
onClick={() => setTab("payments")}
className={`flex-1 py-2.5 text-sm font-medium rounded-lg transition-colors ${
tab === "payments"
? "bg-amber-500 text-black"
: "text-gray-400 hover:text-white"
}`}
>
{t("purchases")} ({payments.length})
</button>
<button
onClick={() => setTab("redemptions")}
className={`flex-1 py-2.5 text-sm font-medium rounded-lg transition-colors ${
tab === "redemptions"
? "bg-amber-500 text-black"
: "text-gray-400 hover:text-white"
}`}
>
{t("redemptions")} ({redemptions.length})
</button>
</div>
{loadingData ? (
<div className="text-center py-12 text-gray-500">{t("loading")}</div>
) : tab === "payments" ? (
<PaymentHistoryTable payments={payments} />
) : (
<RedemptionHistoryTable redemptions={redemptions} />
)}
</>
)}
</div>
);
}

View File

@@ -1,90 +0,0 @@
"use client";
import Link from "next/link";
import { useLocale, useTranslations } from "next-intl";
import { useDiskId } from "@/hooks/useDiskId";
import { DiskIdInput } from "@/components/afc/DiskIdInput";
import { BalanceDisplay } from "@/components/afc/BalanceDisplay";
export default function AfcHubPage() {
const t = useTranslations("afc");
const locale = useLocale();
const disk = useDiskId();
return (
<div className="max-w-4xl mx-auto px-4 py-12">
{/* Header */}
<div className="text-center mb-12">
<div className="w-20 h-20 mx-auto mb-6 rounded-full bg-gradient-to-br from-amber-400 to-amber-600 flex items-center justify-center shadow-lg shadow-amber-500/20">
<span className="text-3xl font-bold text-black">A</span>
</div>
<h1 className="text-4xl font-bold mb-3">{t("store_title")}</h1>
<p className="text-gray-400 text-lg max-w-xl mx-auto">
{t("store_subtitle")}
</p>
</div>
{/* Disk ID */}
<div className="mb-10">
<DiskIdInput
diskId={disk.diskId}
onChange={disk.setDiskId}
onVerify={() => disk.verify(disk.diskId)}
loading={disk.loading}
verified={disk.verified}
playerName={disk.playerName}
error={disk.error}
onClear={disk.clear}
/>
</div>
{/* Balance */}
{disk.verified && (
<div className="mb-10">
<BalanceDisplay balance={disk.balance} />
</div>
)}
{/* Action cards */}
<div className="grid grid-cols-1 md:grid-cols-3 gap-6">
<Link
href={`/${locale}/afc/buy`}
className="group block bg-gray-900 rounded-2xl p-8 border border-white/5 hover:border-amber-500/40 transition-all duration-200 hover:shadow-lg hover:shadow-amber-500/5"
>
<div className="w-12 h-12 rounded-xl bg-green-500/10 flex items-center justify-center mb-4 group-hover:bg-green-500/20 transition-colors">
<span className="text-2xl">+</span>
</div>
<h2 className="text-xl font-bold mb-2 text-white">{t("buy_title")}</h2>
<p className="text-gray-500 text-sm">{t("buy_description")}</p>
</Link>
<Link
href={`/${locale}/afc/redeem`}
className="group block bg-gray-900 rounded-2xl p-8 border border-white/5 hover:border-amber-500/40 transition-all duration-200 hover:shadow-lg hover:shadow-amber-500/5"
>
<div className="w-12 h-12 rounded-xl bg-amber-500/10 flex items-center justify-center mb-4 group-hover:bg-amber-500/20 transition-colors">
<span className="text-2xl"></span>
</div>
<h2 className="text-xl font-bold mb-2 text-white">{t("redeem_title")}</h2>
<p className="text-gray-500 text-sm">{t("redeem_description")}</p>
</Link>
<Link
href={`/${locale}/afc/history`}
className="group block bg-gray-900 rounded-2xl p-8 border border-white/5 hover:border-amber-500/40 transition-all duration-200 hover:shadow-lg hover:shadow-amber-500/5"
>
<div className="w-12 h-12 rounded-xl bg-blue-500/10 flex items-center justify-center mb-4 group-hover:bg-blue-500/20 transition-colors">
<span className="text-2xl"></span>
</div>
<h2 className="text-xl font-bold mb-2 text-white">{t("history_title")}</h2>
<p className="text-gray-500 text-sm">{t("history_description")}</p>
</Link>
</div>
{/* Info */}
<div className="mt-12 bg-gray-900/50 border border-white/5 rounded-2xl p-6 text-sm text-gray-500">
<p>{t("store_info")}</p>
</div>
</div>
);
}

View File

@@ -1,184 +0,0 @@
"use client";
import { useState } from "react";
import Link from "next/link";
import { useLocale, useTranslations } from "next-intl";
import { useDiskId } from "@/hooks/useDiskId";
import { redeemAfc } from "@/lib/afc";
import { DiskIdInput } from "@/components/afc/DiskIdInput";
import { BalanceDisplay } from "@/components/afc/BalanceDisplay";
import { PrizeCard } from "@/components/afc/PrizeCard";
import { RedeemForm } from "@/components/afc/RedeemForm";
interface Prize {
icon: string;
brand: string;
label: string;
costAfc: number;
valueMxn: number;
prizeType: string;
prizeDetail: string;
}
const GIFT_CARDS: Prize[] = [
{ icon: "🎮", brand: "Steam", label: "$200 MXN", costAfc: 14, valueMxn: 200, prizeType: "gift_card", prizeDetail: "Steam $200 MXN" },
{ icon: "🎮", brand: "Steam", label: "$500 MXN", costAfc: 34, valueMxn: 500, prizeType: "gift_card", prizeDetail: "Steam $500 MXN" },
{ icon: "🎮", brand: "Steam", label: "$1,000 MXN", costAfc: 67, valueMxn: 1000, prizeType: "gift_card", prizeDetail: "Steam $1,000 MXN" },
{ icon: "🟢", brand: "Xbox", label: "$200 MXN", costAfc: 14, valueMxn: 200, prizeType: "gift_card", prizeDetail: "Xbox $200 MXN" },
{ icon: "🟢", brand: "Xbox", label: "$500 MXN", costAfc: 34, valueMxn: 500, prizeType: "gift_card", prizeDetail: "Xbox $500 MXN" },
{ icon: "🔵", brand: "PlayStation", label: "$200 MXN", costAfc: 14, valueMxn: 200, prizeType: "gift_card", prizeDetail: "PlayStation $200 MXN" },
{ icon: "🔵", brand: "PlayStation", label: "$500 MXN", costAfc: 14, valueMxn: 500, prizeType: "gift_card", prizeDetail: "PlayStation $500 MXN" },
{ icon: "📦", brand: "Amazon", label: "$200 MXN", costAfc: 14, valueMxn: 200, prizeType: "gift_card", prizeDetail: "Amazon $200 MXN" },
{ icon: "📦", brand: "Amazon", label: "$500 MXN", costAfc: 34, valueMxn: 500, prizeType: "gift_card", prizeDetail: "Amazon $500 MXN" },
];
const CASH_OUT: Prize[] = [
{ icon: "🏦", brand: "Banco (CLABE)", label: "$750+ MXN", costAfc: 50, valueMxn: 750, prizeType: "bank_transfer", prizeDetail: "Transferencia bancaria $750 MXN" },
{ icon: "💳", brand: "MercadoPago", label: "$750+ MXN", costAfc: 50, valueMxn: 750, prizeType: "mercadopago", prizeDetail: "Retiro MercadoPago $750 MXN" },
];
export default function RedeemPage() {
const t = useTranslations("afc");
const locale = useLocale();
const disk = useDiskId();
const [selected, setSelected] = useState<Prize | null>(null);
const [loading, setLoading] = useState(false);
const [success, setSuccess] = useState(false);
const [error, setError] = useState<string | null>(null);
async function handleRedeem(deliveryInfo: string) {
if (!selected || !disk.diskId) return;
setLoading(true);
setError(null);
try {
await redeemAfc({
diskId: disk.diskId,
amountAfc: selected.costAfc,
prizeType: selected.prizeType,
prizeDetail: selected.prizeDetail,
deliveryInfo,
});
setSuccess(true);
disk.refreshBalance();
} catch (e: any) {
setError(e.message);
} finally {
setLoading(false);
}
}
if (success) {
return (
<div className="max-w-lg mx-auto px-4 py-20 text-center">
<div className="w-20 h-20 mx-auto mb-6 rounded-full bg-green-500/15 border-2 border-green-500/30 flex items-center justify-center">
<span className="text-4xl"></span>
</div>
<h1 className="text-3xl font-bold mb-3">{t("redeem_success_title")}</h1>
<p className="text-gray-400 mb-8">{t("redeem_success_description")}</p>
<div className="flex flex-col sm:flex-row gap-3 justify-center">
<Link
href={`/${locale}/afc/history`}
className="px-6 py-3 bg-amber-500 hover:bg-amber-400 text-black font-semibold rounded-xl transition-colors"
>
{t("view_history")}
</Link>
<Link
href={`/${locale}/afc`}
className="px-6 py-3 bg-gray-800 hover:bg-gray-700 text-white font-semibold rounded-xl transition-colors"
>
{t("back_to_store")}
</Link>
</div>
</div>
);
}
return (
<div className="max-w-3xl mx-auto px-4 py-12">
<Link
href={`/${locale}/afc`}
className="inline-flex items-center gap-2 text-sm text-gray-500 hover:text-gray-300 transition-colors mb-8"
>
{t("back_to_store")}
</Link>
<h1 className="text-3xl font-bold mb-2">{t("redeem_title")}</h1>
<p className="text-gray-400 mb-8">{t("redeem_subtitle")}</p>
{/* Disk ID */}
<div className="mb-8">
<DiskIdInput
diskId={disk.diskId}
onChange={disk.setDiskId}
onVerify={() => disk.verify(disk.diskId)}
loading={disk.loading}
verified={disk.verified}
playerName={disk.playerName}
error={disk.error}
onClear={disk.clear}
/>
</div>
{disk.verified && (
<>
<div className="mb-8">
<BalanceDisplay balance={disk.balance} />
</div>
{selected ? (
<RedeemForm
prizeType={selected.prizeType}
prizeDetail={selected.prizeDetail}
costAfc={selected.costAfc}
onSubmit={handleRedeem}
onCancel={() => setSelected(null)}
loading={loading}
/>
) : (
<>
{/* Gift Cards */}
<h2 className="text-lg font-semibold text-white mb-4">{t("gift_cards")}</h2>
<div className="space-y-3 mb-8">
{GIFT_CARDS.map((prize, i) => (
<PrizeCard
key={i}
icon={prize.icon}
brand={prize.brand}
label={prize.label}
costAfc={prize.costAfc}
valueMxn={prize.valueMxn}
disabled={disk.balance !== null && disk.balance < prize.costAfc}
onSelect={() => setSelected(prize)}
/>
))}
</div>
{/* Cash Out */}
<h2 className="text-lg font-semibold text-white mb-4">{t("cash_out")}</h2>
<div className="space-y-3">
{CASH_OUT.map((prize, i) => (
<PrizeCard
key={i}
icon={prize.icon}
brand={prize.brand}
label={prize.label}
costAfc={prize.costAfc}
valueMxn={prize.valueMxn}
disabled={disk.balance !== null && disk.balance < prize.costAfc}
onSelect={() => setSelected(prize)}
/>
))}
</div>
</>
)}
{error && (
<div className="mt-4 bg-red-500/10 border border-red-500/30 rounded-xl p-4 text-sm text-red-400">
{error}
</div>
)}
</>
)}
</div>
);
}

View File

@@ -1,8 +1,13 @@
import type { Metadata } from "next";
import { Suspense } from "react";
import { getGames } from "@/lib/api";
import { CatalogFilters } from "@/components/catalog/CatalogFilters";
import { CatalogGrid } from "@/components/catalog/CatalogGrid";
export const metadata: Metadata = {
title: "Game Catalog | Project Afterlife",
};
export default async function CatalogPage({
params,
}: {
@@ -20,8 +25,14 @@ export default async function CatalogPage({
return (
<div className="max-w-7xl mx-auto px-4 py-12">
<h1 className="text-4xl font-bold mb-8">
{locale === "es" ? "Catálogo de Juegos" : "Game Catalog"}
<h1
className="text-[clamp(2rem,4vw,3rem)] font-extrabold mb-8"
style={{ fontFamily: "var(--font-display)", letterSpacing: "-0.02em" }}
>
{locale === "es" ? "Catálogo de " : "Game "}
<span style={{ color: "var(--accent-primary)" }}>
{locale === "es" ? "Juegos" : "Catalog"}
</span>
</h1>
<Suspense>
<CatalogFilters />

View File

@@ -0,0 +1,183 @@
"use client";
import { useTranslations } from "next-intl";
import { motion } from "framer-motion";
import { useLocale } from "next-intl";
import Link from "next/link";
function DiscordIcon({ className }: { className?: string }) {
return (
<svg className={className} viewBox="0 0 24 24" fill="currentColor">
<path d="M20.317 4.37a19.791 19.791 0 0 0-4.885-1.515.074.074 0 0 0-.079.037c-.21.375-.444.864-.608 1.25a18.27 18.27 0 0 0-5.487 0 12.64 12.64 0 0 0-.617-1.25.077.077 0 0 0-.079-.037A19.736 19.736 0 0 0 3.677 4.37a.07.07 0 0 0-.032.027C.533 9.046-.32 13.58.099 18.057a.082.082 0 0 0 .031.057 19.9 19.9 0 0 0 5.993 3.03.078.078 0 0 0 .084-.028c.462-.63.874-1.295 1.226-1.994a.076.076 0 0 0-.041-.106 13.107 13.107 0 0 1-1.872-.892.077.077 0 0 1-.008-.128 10.2 10.2 0 0 0 .372-.292.074.074 0 0 1 .077-.01c3.928 1.793 8.18 1.793 12.062 0a.074.074 0 0 1 .078.01c.12.098.246.198.373.292a.077.077 0 0 1-.006.127 12.299 12.299 0 0 1-1.873.892.077.077 0 0 0-.041.107c.36.698.772 1.362 1.225 1.993a.076.076 0 0 0 .084.028 19.839 19.839 0 0 0 6.002-3.03.077.077 0 0 0 .032-.054c.5-5.177-.838-9.674-3.549-13.66a.061.061 0 0 0-.031-.03zM8.02 15.33c-1.183 0-2.157-1.085-2.157-2.419 0-1.333.956-2.419 2.157-2.419 1.21 0 2.176 1.096 2.157 2.42 0 1.333-.956 2.418-2.157 2.418zm7.975 0c-1.183 0-2.157-1.085-2.157-2.419 0-1.333.955-2.419 2.157-2.419 1.21 0 2.176 1.096 2.157 2.42 0 1.333-.946 2.418-2.157 2.418z" />
</svg>
);
}
function GitHubIcon({ className }: { className?: string }) {
return (
<svg className={className} viewBox="0 0 24 24" fill="currentColor">
<path d="M12 .297c-6.63 0-12 5.373-12 12 0 5.303 3.438 9.8 8.205 11.385.6.113.82-.258.82-.577 0-.285-.01-1.04-.015-2.04-3.338.724-4.042-1.61-4.042-1.61C4.422 18.07 3.633 17.7 3.633 17.7c-1.087-.744.084-.729.084-.729 1.205.084 1.838 1.236 1.838 1.236 1.07 1.835 2.809 1.305 3.495.998.108-.776.417-1.305.76-1.605-2.665-.3-5.466-1.332-5.466-5.93 0-1.31.465-2.38 1.235-3.22-.135-.303-.54-1.523.105-3.176 0 0 1.005-.322 3.3 1.23.96-.267 1.98-.399 3-.405 1.02.006 2.04.138 3 .405 2.28-1.552 3.285-1.23 3.285-1.23.645 1.653.24 2.873.12 3.176.765.84 1.23 1.91 1.23 3.22 0 4.61-2.805 5.625-5.475 5.92.42.36.81 1.096.81 2.22 0 1.606-.015 2.896-.015 3.286 0 .315.21.69.825.57C20.565 22.092 24 17.592 24 12.297c0-6.627-5.373-12-12-12" />
</svg>
);
}
function HeartIcon({ className }: { className?: string }) {
return (
<svg className={className} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<path d="M20.84 4.61a5.5 5.5 0 0 0-7.78 0L12 5.67l-1.06-1.06a5.5 5.5 0 0 0-7.78 7.78l1.06 1.06L12 21.23l7.78-7.78 1.06-1.06a5.5 5.5 0 0 0 0-7.78z" />
</svg>
);
}
function MessageIcon({ className }: { className?: string }) {
return (
<svg className={className} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<path d="M21 11.5a8.38 8.38 0 0 1-.9 3.8 8.5 8.5 0 0 1-7.6 4.7 8.38 8.38 0 0 1-3.8-.9L3 21l1.9-5.7a8.38 8.38 0 0 1-.9-3.8 8.5 8.5 0 0 1 4.7-7.6 8.38 8.38 0 0 1 3.8-.9h.5a8.48 8.48 0 0 1 8 8v.5z" />
</svg>
);
}
const cardVariants = {
hidden: { opacity: 0, y: 20 },
visible: (i: number) => ({
opacity: 1,
y: 0,
transition: { delay: i * 0.1, duration: 0.5, ease: "easeOut" as const },
}),
};
const channels = [
{
name: "Discord",
description: "Join our community Discord to chat with other players, get support, and stay updated on new releases.",
href: "https://discord.gg/projectafterlife",
icon: DiscordIcon,
color: "text-[#d4a574]",
bg: "bg-[rgba(212,165,116,0.08)] border-[rgba(212,165,116,0.15)] hover:border-[rgba(212,165,116,0.35)]",
btn: "bg-[#d4a574] hover:bg-[#e8c4a0] text-[#0a0a0f]",
btnText: "text-[#0a0a0f]",
label: "Join Discord",
},
{
name: "GitHub",
description: "Our code is open source. Contribute to the project, report issues, or explore our repositories.",
href: "https://github.com/projectafterlife",
icon: GitHubIcon,
color: "text-[#a0a0a8]",
bg: "bg-[rgba(255,255,255,0.03)] border-[rgba(255,255,255,0.08)] hover:border-[rgba(255,255,255,0.15)]",
btn: "bg-[#1a1a24] hover:bg-[#2a2a34] text-[#f5f5f7]",
btnText: "text-[#f5f5f7]",
label: "View GitHub",
},
{
name: "Forums",
description: "Long-form discussions, guides, bug reports, and feature requests. The heart of our community.",
href: "#",
icon: MessageIcon,
color: "text-[#e8c4a0]",
bg: "bg-[rgba(232,196,160,0.08)] border-[rgba(232,196,160,0.15)] hover:border-[rgba(232,196,160,0.35)]",
btn: "bg-[#e8c4a0] hover:bg-[#d4a574] text-[#0a0a0f]",
btnText: "text-[#0a0a0f]",
label: "Coming Soon",
},
{
name: "Contribute",
description: "Help us preserve gaming history. We need developers, writers, translators, and testers.",
href: "/donate",
icon: HeartIcon,
color: "text-rose-400",
bg: "bg-rose-500/8 border-rose-500/15 hover:border-rose-500/35",
btn: "bg-rose-600 hover:bg-rose-500 text-white",
btnText: "text-white",
label: "How to Help",
},
];
export default function CommunityPage() {
const locale = useLocale();
const isEs = locale === "es";
return (
<div className="max-w-6xl mx-auto px-4 py-12">
<motion.div
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.6 }}
className="text-center mb-16"
>
<h1
className="text-[clamp(2rem,4vw,3.5rem)] font-extrabold mb-4 tracking-tight"
style={{ fontFamily: "var(--font-display)", letterSpacing: "-0.02em" }}
>
{isEs ? "Comunidad" : "Community"}
</h1>
<p className="text-lg text-[#a0a0a8] max-w-2xl mx-auto leading-relaxed">
{isEs
? "Project Afterlife es impulsado por su comunidad. Únete a nosotros para preservar la historia de los juegos juntos."
: "Project Afterlife is driven by its community. Join us in preserving gaming history together."}
</p>
</motion.div>
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
{channels.map((channel, i) => {
const Icon = channel.icon;
const isExternal = channel.href.startsWith("http");
const Wrapper = isExternal ? "a" : Link;
const wrapperProps = isExternal
? { href: channel.href, target: "_blank", rel: "noopener noreferrer" }
: { href: channel.href };
return (
<motion.div
key={channel.name}
custom={i}
initial="hidden"
whileInView="visible"
viewport={{ once: true, margin: "-50px" }}
variants={cardVariants}
>
<Wrapper
{...wrapperProps}
className={`block rounded-2xl border p-8 transition-all duration-300 hover:scale-[1.02] ${channel.bg}`}
>
<div className="flex items-start gap-4">
<div className={`p-3 rounded-xl bg-black/30 ${channel.color}`}>
<Icon className="w-6 h-6" />
</div>
<div className="flex-1">
<h3 className="text-xl font-semibold text-[#f5f5f7] mb-2">{channel.name}</h3>
<p className="text-sm text-[#a0a0a8] leading-relaxed mb-4">
{channel.description}
</p>
<span
className={`inline-block px-4 py-1.5 rounded-lg text-sm font-medium transition-colors ${channel.btn}`}
>
{channel.label}
</span>
</div>
</div>
</Wrapper>
</motion.div>
);
})}
</div>
<motion.div
initial={{ opacity: 0 }}
whileInView={{ opacity: 1 }}
viewport={{ once: true }}
transition={{ delay: 0.4, duration: 0.6 }}
className="mt-16 rounded-2xl border border-[rgba(255,255,255,0.08)] bg-[#12121a] p-8 text-center"
>
<h2 className="text-2xl font-semibold mb-3">
{isEs ? "Código de Conducta" : "Code of Conduct"}
</h2>
<p className="text-[#a0a0a8] max-w-2xl mx-auto leading-relaxed">
{isEs
? "Todos los miembros de nuestra comunidad deben tratar a los demás con respeto. No se tolera el acoso, la discriminación ni el comportamiento tóxico. Queremos que este sea un espacio seguro y acogedor para todos los amantes de los juegos."
: "All members of our community must treat others with respect. Harassment, discrimination, and toxic behavior are not tolerated. We want this to be a safe and welcoming space for all game lovers."}
</p>
</motion.div>
</div>
);
}

View File

@@ -0,0 +1,116 @@
"use client";
import { useState } from "react";
import { useLocale } from "next-intl";
import { motion } from "framer-motion";
import { useToast } from "@/hooks/useToast";
export default function ContactPage() {
const locale = useLocale();
const isEs = locale === "es";
const toast = useToast();
const [form, setForm] = useState({ name: "", email: "", subject: "", message: "" });
const [loading, setLoading] = useState(false);
async function handleSubmit(e: React.FormEvent) {
e.preventDefault();
if (!form.name || !form.email || !form.message) return;
setLoading(true);
try {
const res = await fetch("/api/contact", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(form),
});
const data = await res.json();
if (res.ok) {
toast.success(isEs ? "¡Mensaje enviado!" : "Message sent!");
setForm({ name: "", email: "", subject: "", message: "" });
} else {
toast.error(data.error || (isEs ? "Error al enviar" : "Failed to send"));
}
} catch {
toast.error(isEs ? "Error de conexión" : "Connection error");
} finally {
setLoading(false);
}
}
return (
<div className="max-w-2xl mx-auto px-4 py-12">
<motion.div
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.6 }}
className="text-center mb-10"
>
<h1
className="text-[clamp(2rem,4vw,3rem)] font-extrabold mb-4"
style={{ fontFamily: "var(--font-display)", letterSpacing: "-0.02em" }}
>
{isEs ? "Contacto" : "Contact Us"}
</h1>
<p className="text-[#a0a0a8]">
{isEs
? "¿Tienes preguntas, sugerencias o quieres contribuir? Escríbenos."
: "Have questions, suggestions, or want to contribute? Reach out to us."}
</p>
</motion.div>
<form onSubmit={handleSubmit} className="space-y-5">
<div className="grid grid-cols-1 sm:grid-cols-2 gap-5">
<div>
<label className="block text-sm text-[#a0a0a8] mb-1.5">{isEs ? "Nombre" : "Name"} *</label>
<input
type="text"
value={form.name}
onChange={(e) => setForm({ ...form, name: e.target.value })}
required
className="w-full bg-[#12121a] border border-[rgba(255,255,255,0.08)] rounded-lg px-4 py-2.5 text-sm text-[#f5f5f7] focus:outline-none focus:border-[rgba(212,165,116,0.4)] transition-colors"
/>
</div>
<div>
<label className="block text-sm text-[#a0a0a8] mb-1.5">Email *</label>
<input
type="email"
value={form.email}
onChange={(e) => setForm({ ...form, email: e.target.value })}
required
className="w-full bg-[#12121a] border border-[rgba(255,255,255,0.08)] rounded-lg px-4 py-2.5 text-sm text-[#f5f5f7] focus:outline-none focus:border-[rgba(212,165,116,0.4)] transition-colors"
/>
</div>
</div>
<div>
<label className="block text-sm text-[#a0a0a8] mb-1.5">{isEs ? "Asunto" : "Subject"}</label>
<input
type="text"
value={form.subject}
onChange={(e) => setForm({ ...form, subject: e.target.value })}
className="w-full bg-[#12121a] border border-[rgba(255,255,255,0.08)] rounded-lg px-4 py-2.5 text-sm text-[#f5f5f7] focus:outline-none focus:border-[rgba(212,165,116,0.4)] transition-colors"
/>
</div>
<div>
<label className="block text-sm text-[#a0a0a8] mb-1.5">{isEs ? "Mensaje" : "Message"} *</label>
<textarea
value={form.message}
onChange={(e) => setForm({ ...form, message: e.target.value })}
required
rows={5}
className="w-full bg-[#12121a] border border-[rgba(255,255,255,0.08)] rounded-lg px-4 py-2.5 text-sm text-[#f5f5f7] focus:outline-none focus:border-[rgba(212,165,116,0.4)] transition-colors resize-none"
/>
</div>
<button
type="submit"
disabled={loading}
className="w-full px-6 py-3 bg-white text-black text-sm font-semibold rounded-lg hover:bg-gray-200 transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
>
{loading
? isEs ? "Enviando..." : "Sending..."
: isEs ? "Enviar mensaje" : "Send message"}
</button>
</form>
</div>
);
}

View File

@@ -1,23 +1,35 @@
import type { Metadata } from "next";
import { useTranslations } from "next-intl";
export function generateMetadata(): Metadata {
return {
title: "Donations",
};
}
export default function DonatePage() {
const t = useTranslations("donate");
return (
<div className="max-w-4xl mx-auto px-4 py-12">
<h1 className="text-4xl font-bold mb-6">{t("title")}</h1>
<p className="text-lg text-gray-400 mb-12">{t("description")}</p>
<h1
className="text-[clamp(2rem,4vw,3rem)] font-extrabold mb-6"
style={{ fontFamily: "var(--font-display)", letterSpacing: "-0.02em" }}
>
{t("title")}
</h1>
<p className="text-lg text-[#a0a0a8] mb-12">{t("description")}</p>
<div className="grid grid-cols-1 md:grid-cols-2 gap-6 mb-16">
<a
href="https://patreon.com/projectafterlife"
target="_blank"
rel="noopener noreferrer"
className="block bg-gray-900 rounded-lg p-8 border border-white/5 hover:border-orange-500/50 transition-colors text-center"
className="block bg-[#12121a] rounded-lg p-8 border border-[rgba(255,255,255,0.08)] hover:border-[rgba(212,165,116,0.3)] transition-colors text-center"
>
<h3 className="text-2xl font-bold mb-2 text-orange-400">Patreon</h3>
<p className="text-gray-400 text-sm mb-4">Donaciones recurrentes mensuales</p>
<span className="inline-block px-6 py-2 bg-orange-600 hover:bg-orange-700 text-white rounded-lg font-medium transition-colors">
<h3 className="text-2xl font-bold mb-2 text-[#d4a574]">Patreon</h3>
<p className="text-[#a0a0a8] text-sm mb-4">Donaciones recurrentes mensuales</p>
<span className="inline-block px-6 py-2 bg-[#d4a574] hover:bg-[#e8c4a0] text-[#0a0a0f] rounded-lg font-medium transition-colors">
{t("patreon")}
</span>
</a>
@@ -26,11 +38,11 @@ export default function DonatePage() {
href="https://ko-fi.com/projectafterlife"
target="_blank"
rel="noopener noreferrer"
className="block bg-gray-900 rounded-lg p-8 border border-white/5 hover:border-sky-500/50 transition-colors text-center"
className="block bg-[#12121a] rounded-lg p-8 border border-[rgba(255,255,255,0.08)] hover:border-[rgba(212,165,116,0.3)] transition-colors text-center"
>
<h3 className="text-2xl font-bold mb-2 text-sky-400">Ko-fi</h3>
<p className="text-gray-400 text-sm mb-4">Donaciones puntuales</p>
<span className="inline-block px-6 py-2 bg-sky-600 hover:bg-sky-700 text-white rounded-lg font-medium transition-colors">
<h3 className="text-2xl font-bold mb-2 text-[#e8c4a0]">Ko-fi</h3>
<p className="text-[#a0a0a8] text-sm mb-4">Donaciones puntuales</p>
<span className="inline-block px-6 py-2 bg-[#e8c4a0] hover:bg-[#d4a574] text-[#0a0a0f] rounded-lg font-medium transition-colors">
{t("kofi")}
</span>
</a>

View File

@@ -1,7 +1,27 @@
import type { Metadata } from "next";
import { notFound } from "next/navigation";
import { getDocumentaryByGameSlug } from "@/lib/api";
import { DocumentaryLayout } from "@/components/documentary/DocumentaryLayout";
export async function generateMetadata({
params,
}: {
params: Promise<{ locale: string; slug: string }>;
}): Promise<Metadata> {
const { slug, locale } = await params;
let documentary;
try {
documentary = await getDocumentaryByGameSlug(slug, locale);
} catch {
return {};
}
if (!documentary) return {};
return {
title: documentary.title,
description: documentary.description?.slice(0, 160),
};
}
export default async function DocumentaryPage({
params,
}: {

View File

@@ -1,8 +1,47 @@
import type { Metadata } from "next";
import { notFound } from "next/navigation";
import { getGameBySlug } from "@/lib/api";
import { GameHeader } from "@/components/game/GameHeader";
import { GameInfo } from "@/components/game/GameInfo";
import { ScreenshotGallery } from "@/components/game/ScreenshotGallery";
import { SocialShare } from "@/components/social/SocialShare";
const CMS_URL = process.env.STRAPI_URL || process.env.NEXT_PUBLIC_CMS_URL || "http://localhost:1337";
export async function generateMetadata({
params,
}: {
params: Promise<{ locale: string; slug: string }>;
}): Promise<Metadata> {
const { slug, locale } = await params;
let game;
try {
const res = await getGameBySlug(slug, locale);
game = Array.isArray(res.data) ? res.data[0] : res.data;
} catch {
return {};
}
if (!game) return {};
const description = game.description?.slice(0, 160);
const images = game.coverImage?.url
? [
{
url: game.coverImage.url.startsWith("http")
? game.coverImage.url
: `${CMS_URL}${game.coverImage.url}`,
},
]
: undefined;
return {
title: game.title,
description,
openGraph: images ? { images } : undefined,
};
}
export default async function GamePage({
params,
@@ -25,6 +64,9 @@ export default async function GamePage({
<>
<GameHeader game={game} />
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-16">
<div className="flex items-center justify-between mb-6">
<SocialShare title={game.title} description={game.description?.slice(0, 120)} />
</div>
<GameInfo game={game} locale={locale} />
{game.screenshots && (
<ScreenshotGallery screenshots={game.screenshots} />

View File

@@ -0,0 +1,259 @@
"use client";
import { useState } from "react";
import { useLocale } from "next-intl";
import { motion, AnimatePresence } from "framer-motion";
interface Guide {
game: string;
steps: string[];
requirements: string[];
troubleshooting: string[];
}
const guidesEn: Guide[] = [
{
game: "NieR Reincarnation",
steps: [
"Download the patched APK from our Discord or website.",
"Install the APK on your Android device (enable Unknown Sources).",
"Launch the game and tap 'Start Game'.",
"When prompted, enter the server address: play.consultoria-as.com",
"Create your character and enjoy!",
],
requirements: ["Android 8.0 or higher", "~2GB free storage", "Stable internet connection"],
troubleshooting: [
"If you get 'Connection failed', check your internet and try again.",
"Make sure you're using the latest patched APK.",
"Clear app cache and retry.",
],
},
{
game: "Dragon Ball Online",
steps: [
"Download the game client from our Discord.",
"Extract the archive to a folder on your PC.",
"Run Launcher.exe as Administrator.",
"The launcher will auto-update if needed.",
"Click 'Play' and log in with any username (no password required for now).",
],
requirements: ["Windows 10/11", "DirectX 11", "4GB RAM", "~5GB free storage"],
troubleshooting: [
"If the launcher freezes, disable your antivirus temporarily.",
"Run both launcher and game as Administrator.",
"Ensure Windows Defender is not blocking the executable.",
],
},
{
game: "MapleStory 2",
steps: [
"Download the MS2 client from the community portal.",
"Install the game to a path without special characters.",
"Run the launcher and let it patch.",
"Create an account on the portal website.",
"Log in and select a channel to start playing.",
],
requirements: ["Windows 10/11", "8GB RAM recommended", "NVIDIA/AMD GPU", "~10GB free storage"],
troubleshooting: [
"If you get a black screen, update your GPU drivers.",
"Disable fullscreen optimizations in game executable properties.",
"Run in compatibility mode for Windows 8 if crashing.",
],
},
{
game: "FusionFall",
steps: [
"Download the OpenFusion client for your platform.",
"Extract and run OpenFusion.exe.",
"The server should be pre-configured.",
"Create a character and enter the Cartoon Network universe!",
],
requirements: ["Windows 10/11 or Linux (Wine)", "2GB RAM", "~2GB free storage"],
troubleshooting: [
"If textures are missing, verify file integrity via Discord.",
"Linux users may need to install latest Wine/Proton.",
],
},
];
const guidesEs: Guide[] = [
{
game: "NieR Reincarnation",
steps: [
"Descarga el APK parcheado desde nuestro Discord o sitio web.",
"Instala el APK en tu dispositivo Android (activa Fuentes Desconocidas).",
"Abre el juego y toca 'Comenzar'.",
"Cuando se solicite, introduce la dirección del servidor: play.consultoria-as.com",
"¡Crea tu personaje y disfruta!",
],
requirements: ["Android 8.0 o superior", "~2GB de almacenamiento libre", "Conexión a internet estable"],
troubleshooting: [
"Si aparece 'Conexión fallida', verifica tu internet e inténtalo de nuevo.",
"Asegúrate de usar el APK parcheado más reciente.",
"Limpia la caché de la app e inténtalo de nuevo.",
],
},
{
game: "Dragon Ball Online",
steps: [
"Descarga el cliente del juego desde nuestro Discord.",
"Extrae el archivo a una carpeta en tu PC.",
"Ejecuta Launcher.exe como Administrador.",
"El launcher se actualizará automáticamente si es necesario.",
"Haz clic en 'Jugar' e inicia sesión con cualquier usuario (sin contraseña por ahora).",
],
requirements: ["Windows 10/11", "DirectX 11", "4GB RAM", "~5GB de almacenamiento libre"],
troubleshooting: [
"Si el launcher se congela, desactiva temporalmente tu antivirus.",
"Ejecuta tanto el launcher como el juego como Administrador.",
"Asegúrate de que Windows Defender no bloquee el ejecutable.",
],
},
{
game: "MapleStory 2",
steps: [
"Descarga el cliente de MS2 desde el portal de la comunidad.",
"Instala el juego en una ruta sin caracteres especiales.",
"Ejecuta el launcher y déjalo parchear.",
"Crea una cuenta en el sitio web del portal.",
"Inicia sesión y selecciona un canal para empezar a jugar.",
],
requirements: ["Windows 10/11", "8GB RAM recomendados", "GPU NVIDIA/AMD", "~10GB de almacenamiento libre"],
troubleshooting: [
"Si aparece pantalla negra, actualiza los drivers de tu GPU.",
"Desactiva las optimizaciones de pantalla completa en propiedades del ejecutable.",
"Ejecuta en modo compatibilidad para Windows 8 si se cierra.",
],
},
{
game: "FusionFall",
steps: [
"Descarga el cliente de OpenFusion para tu plataforma.",
"Extrae y ejecuta OpenFusion.exe.",
"El servidor debería estar preconfigurado.",
"¡Crea un personaje y entra al universo de Cartoon Network!",
],
requirements: ["Windows 10/11 o Linux (Wine)", "2GB RAM", "~2GB de almacenamiento libre"],
troubleshooting: [
"Si faltan texturas, verifica la integridad de archivos vía Discord.",
"Usuarios de Linux pueden necesitar Wine/Proton más reciente.",
],
},
];
function GuideCard({ guide, index }: { guide: Guide; index: number }) {
const [open, setOpen] = useState(index === 0);
return (
<motion.div
initial={{ opacity: 0, y: 20 }}
whileInView={{ opacity: 1, y: 0 }}
viewport={{ once: true }}
transition={{ delay: index * 0.1, duration: 0.5 }}
className="rounded-2xl border border-[rgba(255,255,255,0.08)] bg-[#12121a] overflow-hidden"
>
<button
onClick={() => setOpen(!open)}
className="w-full flex items-center justify-between px-6 py-5 text-left hover:bg-[rgba(255,255,255,0.03)] transition-colors"
>
<h3 className="text-lg font-semibold text-[#f5f5f7]">{guide.game}</h3>
<svg
className={`w-5 h-5 text-[#6b6b75] transition-transform duration-300 ${open ? "rotate-180" : ""}`}
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
>
<polyline points="6 9 12 15 18 9" />
</svg>
</button>
<AnimatePresence>
{open && (
<motion.div
initial={{ height: 0, opacity: 0 }}
animate={{ height: "auto", opacity: 1 }}
exit={{ height: 0, opacity: 0 }}
transition={{ duration: 0.3 }}
className="overflow-hidden"
>
<div className="px-6 pb-6 space-y-6">
<div>
<h4 className="text-sm font-semibold text-[#d4a574] uppercase tracking-wider mb-3">Steps</h4>
<ol className="space-y-2">
{guide.steps.map((step, i) => (
<li key={i} className="flex items-start gap-3 text-sm text-[#a0a0a8]">
<span className="flex-shrink-0 w-5 h-5 rounded-full bg-[rgba(212,165,116,0.15)] text-[#d4a574] text-xs font-bold flex items-center justify-center mt-0.5">
{i + 1}
</span>
{step}
</li>
))}
</ol>
</div>
<div>
<h4 className="text-sm font-semibold text-blue-400 uppercase tracking-wider mb-3">Requirements</h4>
<ul className="flex flex-wrap gap-2">
{guide.requirements.map((req, i) => (
<li key={i} className="px-3 py-1 rounded-full bg-blue-500/10 border border-blue-500/20 text-xs text-blue-300">
{req}
</li>
))}
</ul>
</div>
<div>
<h4 className="text-sm font-semibold text-red-400 uppercase tracking-wider mb-3">Troubleshooting</h4>
<ul className="space-y-2">
{guide.troubleshooting.map((tip, i) => (
<li key={i} className="flex items-start gap-2 text-sm text-[#a0a0a8]">
<span className="text-red-400 mt-0.5"></span>
{tip}
</li>
))}
</ul>
</div>
</div>
</motion.div>
)}
</AnimatePresence>
</motion.div>
);
}
export default function GuidesPage() {
const locale = useLocale();
const isEs = locale === "es";
const guides = isEs ? guidesEs : guidesEn;
return (
<div className="max-w-4xl mx-auto px-4 py-12">
<motion.div
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.6 }}
className="text-center mb-12"
>
<h1
className="text-[clamp(2rem,4vw,3.5rem)] font-extrabold mb-4 tracking-tight"
style={{ fontFamily: "var(--font-display)", letterSpacing: "-0.02em" }}
>
{isEs ? "Guías de Conexión" : "Connection Guides"}
</h1>
<p className="text-lg text-[#a0a0a8] max-w-2xl mx-auto leading-relaxed">
{isEs
? "Todo lo que necesitas saber para conectarte a nuestros servidores privados."
: "Everything you need to know to connect to our private servers."}
</p>
</motion.div>
<div className="space-y-4">
{guides.map((guide, i) => (
<GuideCard key={guide.game} guide={guide} index={i} />
))}
</div>
</div>
);
}

View File

@@ -1,21 +1,22 @@
import type { Metadata } from "next";
import { Playfair_Display, Source_Serif_4, DM_Sans } from "next/font/google";
import { Syne, DM_Sans } from "next/font/google";
import { NextIntlClientProvider } from "next-intl";
import { getMessages } from "next-intl/server";
import { notFound } from "next/navigation";
import { routing } from "@/i18n/routing";
import { Navbar } from "@/components/layout/Navbar";
import { Footer } from "@/components/layout/Footer";
import { AuthProvider } from "@/components/auth/AuthProvider";
import { ToastProvider } from "@/hooks/useToast";
import { CookieConsent } from "@/components/ui/CookieConsent";
import { Analytics } from "@vercel/analytics/react";
import { ThemeProvider } from "@/components/theme/ThemeProvider";
import { Breadcrumb } from "@/components/navigation/Breadcrumb";
import { ScrollToTop } from "@/components/ui/ScrollToTop";
const playfair = Playfair_Display({
const syne = Syne({
subsets: ["latin"],
variable: "--font-playfair",
display: "swap",
});
const sourceSerif = Source_Serif_4({
subsets: ["latin", "latin-ext"],
variable: "--font-source-serif",
variable: "--font-syne",
display: "swap",
});
@@ -50,14 +51,24 @@ export default async function LocaleLayout({
return (
<html
lang={locale}
className={`${playfair.variable} ${sourceSerif.variable} ${dmSans.variable}`}
className={`${syne.variable} ${dmSans.variable}`}
>
<body className="bg-gray-950 text-white antialiased min-h-screen flex flex-col font-sans">
<NextIntlClientProvider messages={messages}>
<Navbar />
<main className="flex-1 pt-16">{children}</main>
<Footer />
</NextIntlClientProvider>
<body className="antialiased min-h-screen flex flex-col font-sans">
<AuthProvider>
<NextIntlClientProvider messages={messages}>
<ThemeProvider>
<ToastProvider>
<Navbar />
<Breadcrumb />
<main className="flex-1 pt-12">{children}</main>
<Footer />
<ScrollToTop />
<CookieConsent />
<Analytics />
</ToastProvider>
</ThemeProvider>
</NextIntlClientProvider>
</AuthProvider>
</body>
</html>
);

View File

@@ -0,0 +1,22 @@
import { getMessages } from "next-intl/server";
import { NextIntlClientProvider } from "next-intl";
import { LoginForm } from "@/components/auth/LoginForm";
export default async function LoginPage({
params,
}: {
params: Promise<{ locale: string }>;
}) {
const { locale } = await params;
const messages = await getMessages();
return (
<NextIntlClientProvider messages={messages} locale={locale}>
<div className="min-h-screen flex items-center justify-center bg-[#0a0a0f] px-4">
<div className="max-w-md w-full">
<LoginForm />
</div>
</div>
</NextIntlClientProvider>
);
}

View File

@@ -0,0 +1,37 @@
"use client";
import Link from "next/link";
import { useLocale } from "next-intl";
import { motion } from "framer-motion";
export default function NotFound() {
const locale = useLocale();
const isEs = locale === "es";
return (
<div className="min-h-[70vh] flex items-center justify-center px-4">
<motion.div
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.6 }}
className="text-center max-w-lg"
>
<h1 className="text-8xl font-bold text-[#1a1a24] mb-4">404</h1>
<h2 className="text-2xl font-semibold text-[#f5f5f7] mb-4">
{isEs ? "Página no encontrada" : "Page Not Found"}
</h2>
<p className="text-[#a0a0a8] mb-8 leading-relaxed">
{isEs
? "La página que buscas no existe o ha sido movida."
: "The page you are looking for does not exist or has been moved."}
</p>
<Link
href={`/${locale}`}
className="inline-block px-8 py-3 bg-[#d4a574] text-[#0a0a0f] font-semibold rounded-xl hover:bg-[#e8c4a0] transition-colors"
>
{isEs ? "Volver al inicio" : "Go back home"}
</Link>
</motion.div>
</div>
);
}

View File

@@ -1,7 +1,10 @@
import { getGames } from "@/lib/api";
import { HeroSection } from "@/components/home/HeroSection";
import { LatestGames } from "@/components/home/LatestGames";
import { DonationCTA } from "@/components/home/DonationCTA";
import { PillarsSection } from "@/components/home/PillarsSection";
import { DocumentaryExperienceSection } from "@/components/home/DocumentaryExperienceSection";
import { TechStackSection } from "@/components/home/TechStackSection";
import { GamesShowcaseSection } from "@/components/home/GamesShowcaseSection";
import { DonationSection } from "@/components/home/DonationSection";
export default async function HomePage({
params,
@@ -21,8 +24,11 @@ export default async function HomePage({
return (
<>
<HeroSection />
<LatestGames games={games} locale={locale} />
<DonationCTA />
<PillarsSection />
<DocumentaryExperienceSection />
<TechStackSection />
<GamesShowcaseSection games={games} />
<DonationSection />
</>
);
}

View File

@@ -0,0 +1,30 @@
import { redirect } from "next/navigation";
import { auth } from "@/lib/auth";
import { getMessages } from "next-intl/server";
import { NextIntlClientProvider } from "next-intl";
import { ProfileCard } from "@/components/auth/ProfileCard";
export default async function ProfilePage({
params,
}: {
params: Promise<{ locale: string }>;
}) {
const { locale } = await params;
const session = await auth();
if (!session?.user) {
redirect(`/${locale}/login`);
}
const messages = await getMessages();
return (
<NextIntlClientProvider messages={messages} locale={locale}>
<div className="min-h-screen bg-[#0a0a0f] px-4 py-12">
<div className="max-w-2xl mx-auto">
<ProfileCard user={session.user} />
</div>
</div>
</NextIntlClientProvider>
);
}

View File

@@ -0,0 +1,79 @@
import { getMessages } from "next-intl/server";
import { NextIntlClientProvider } from "next-intl";
import { ServerStatusGrid } from "@/components/admin/ServerStatusGrid";
import { HealthBanner } from "@/components/admin/HealthBanner";
import type { Server } from "@/components/admin/ServerStatusGrid";
export const metadata = {
title: "Server Status",
};
export default async function ServerStatusPage({
params,
}: {
params: Promise<{ locale: string }>;
}) {
const { locale } = await params;
const messages = await getMessages();
const servers = [
{
name: "NieR Reincarnation",
status: "online",
ip: process.env.NEXT_PUBLIC_NIER_IP || "play.consultoria-as.com",
ports: "80 / 443 (HTTP/2 gRPC)",
type: "Mobile RPG",
vm: "vm-nier (10.0.0.70)",
},
{
name: "Dragon Ball Online",
status: "maintenance",
ip: process.env.NEXT_PUBLIC_DBO_IP || "play.consultoria-as.com",
ports: "22000-22010",
type: "MMORPG",
vm: "vm-dbo (10.0.0.80)",
},
{
name: "MapleStory 2",
status: "online",
ip: process.env.NEXT_PUBLIC_MAPLE2_IP || "play.consultoria-as.com",
ports: "20001",
type: "MMORPG",
vm: "vm-maple2 (10.0.0.40)",
},
{
name: "FusionFall",
status: "online",
ip: process.env.NEXT_PUBLIC_FUSIONFALL_IP || "play.consultoria-as.com",
ports: "23000",
type: "MMORPG",
vm: "vm-fusionfall (10.0.0.30)",
},
] satisfies Server[];
return (
<NextIntlClientProvider messages={messages} locale={locale}>
<div className="min-h-screen bg-[#0a0a0f] px-4 py-12">
<div className="max-w-6xl mx-auto">
<div className="mb-10">
<h1
className="text-[clamp(1.75rem,3vw,2.5rem)] font-display font-bold text-[#f5f5f7]"
style={{ letterSpacing: "-0.02em" }}
>
{locale === "es" ? "Estado de Servidores" : "Server Status"}
</h1>
<p className="mt-3 text-[#a0a0a8] max-w-2xl">
{locale === "es"
? "Información de conexión para todos los servidores de juego de Project Afterlife."
: "Connection information for all Project Afterlife game servers."}
</p>
</div>
<HealthBanner locale={locale} />
<ServerStatusGrid servers={servers} />
</div>
</div>
</NextIntlClientProvider>
);
}

View File

@@ -0,0 +1,22 @@
"use client";
import { motion, AnimatePresence } from "framer-motion";
import { usePathname } from "next/navigation";
export default function Template({ children }: { children: React.ReactNode }) {
const pathname = usePathname();
return (
<AnimatePresence mode="wait">
<motion.div
key={pathname}
initial={{ opacity: 0, y: 8 }}
animate={{ opacity: 1, y: 0 }}
exit={{ opacity: 0, y: -8 }}
transition={{ duration: 0.25, ease: "easeInOut" }}
>
{children}
</motion.div>
</AnimatePresence>
);
}