feat: phase 3 redesign, game images, auth system, vm guides, service isolation
Some checks failed
Deploy Multi-VM / Deploy VM Web (push) Has been cancelled
Deploy Multi-VM / Deploy VM Auth (push) Has been cancelled
Deploy Multi-VM / Deploy Game Servers (docker-compose.fusionfall.yml, VM_FUSIONFALL_HOST, VM_FUSIONFALL_SSH_KEY, VM_FUSIONFALL_USER, fusionfall) (push) Has been cancelled
Deploy Multi-VM / Deploy Game Servers (docker-compose.maple2.yml, VM_MAPLE2_HOST, VM_MAPLE2_SSH_KEY, VM_MAPLE2_USER, maple2) (push) Has been cancelled
Deploy Multi-VM / Deploy Game Servers (docker-compose.minecraft.yml, VM_MINECRAFT_HOST, VM_MINECRAFT_SSH_KEY, VM_MINECRAFT_USER, minecraft) (push) Has been cancelled
Deploy Multi-VM / Deploy Game Servers (docker-compose.retro.yml, VM_RETRO_HOST, VM_RETRO_SSH_KEY, VM_RETRO_USER, retro) (push) Has been cancelled
Some checks failed
Deploy Multi-VM / Deploy VM Web (push) Has been cancelled
Deploy Multi-VM / Deploy VM Auth (push) Has been cancelled
Deploy Multi-VM / Deploy Game Servers (docker-compose.fusionfall.yml, VM_FUSIONFALL_HOST, VM_FUSIONFALL_SSH_KEY, VM_FUSIONFALL_USER, fusionfall) (push) Has been cancelled
Deploy Multi-VM / Deploy Game Servers (docker-compose.maple2.yml, VM_MAPLE2_HOST, VM_MAPLE2_SSH_KEY, VM_MAPLE2_USER, maple2) (push) Has been cancelled
Deploy Multi-VM / Deploy Game Servers (docker-compose.minecraft.yml, VM_MINECRAFT_HOST, VM_MINECRAFT_SSH_KEY, VM_MINECRAFT_USER, minecraft) (push) Has been cancelled
Deploy Multi-VM / Deploy Game Servers (docker-compose.retro.yml, VM_RETRO_HOST, VM_RETRO_SSH_KEY, VM_RETRO_USER, retro) (push) Has been cancelled
- Redesign all internal pages to warm/gold aesthetic (catalog, game detail, documentary, about, donate, community, guides, contact, server-status, login, profile, admin, not-found) - Add real cover images for all 4 games via Strapi CMS with getImageUrl helper - Integrate NextAuth v5 with Authentik OIDC authentication - Add new public pages: community, guides, contact, server-status - Add new protected pages: login, profile, admin dashboard - Remove legacy AFC/MercadoPago system entirely - Add Docker Compose split files for service isolation (main, auth, fusionfall, nier) - Add OpenFusion VM deployment configs (config.vm.ini, systemd service, README-VM) - Add NieR Reincarnation server guide and desktop client guide - Add architecture docs for multi-VM deployment - Add healthcheck, SSE, contact, newsletter, admin API routes - Add reusable UI components, skeleton loaders, activity feed, bookmark system - Update deployment and game server documentation
This commit is contained in:
@@ -1,11 +1,23 @@
|
||||
import type { Metadata } from "next";
|
||||
import { useTranslations } from "next-intl";
|
||||
|
||||
export function generateMetadata(): Metadata {
|
||||
return {
|
||||
title: "About Us",
|
||||
};
|
||||
}
|
||||
|
||||
export default function AboutPage() {
|
||||
const t = useTranslations("about");
|
||||
|
||||
return (
|
||||
<div className="max-w-4xl mx-auto px-4 py-12">
|
||||
<h1 className="text-4xl font-bold mb-12">{t("title")}</h1>
|
||||
<h1
|
||||
className="text-[clamp(2rem,4vw,3rem)] font-extrabold mb-12"
|
||||
style={{ fontFamily: "var(--font-display)", letterSpacing: "-0.02em" }}
|
||||
>
|
||||
{t("title")}
|
||||
</h1>
|
||||
|
||||
<section className="mb-12">
|
||||
<h2 className="text-2xl font-semibold mb-4">{t("mission")}</h2>
|
||||
@@ -22,8 +34,8 @@ export default function AboutPage() {
|
||||
<section className="mb-12">
|
||||
<h2 className="text-2xl font-semibold mb-4">{t("team")}</h2>
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
|
||||
<div className="bg-gray-900 rounded-lg p-6 border border-white/5">
|
||||
<p className="text-gray-500 text-sm">Team members coming soon.</p>
|
||||
<div className="bg-[#12121a] rounded-lg p-6 border border-[rgba(255,255,255,0.08)]">
|
||||
<p className="text-[#6b6b75] text-sm">Team members coming soon.</p>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
214
apps/web/src/app/[locale]/admin/page.tsx
Normal file
214
apps/web/src/app/[locale]/admin/page.tsx
Normal file
@@ -0,0 +1,214 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useState, useMemo } from "react";
|
||||
import { SubscriberChart } from "@/components/admin/SubscriberChart";
|
||||
|
||||
interface Subscriber {
|
||||
id: number;
|
||||
email: string;
|
||||
locale: string;
|
||||
created_at: string;
|
||||
}
|
||||
|
||||
interface ContactMessage {
|
||||
id: number;
|
||||
name: string;
|
||||
email: string;
|
||||
subject: string | null;
|
||||
message: string;
|
||||
created_at: string;
|
||||
}
|
||||
|
||||
function exportCSV(data: Subscriber[]) {
|
||||
const headers = ["ID", "Email", "Locale", "Created At"];
|
||||
const rows = data.map((s) => [s.id, s.email, s.locale, s.created_at]);
|
||||
const csv = [headers, ...rows].map((r) => r.join(",")).join("\n");
|
||||
const blob = new Blob([csv], { type: "text/csv" });
|
||||
const url = URL.createObjectURL(blob);
|
||||
const a = document.createElement("a");
|
||||
a.href = url;
|
||||
a.download = `subscribers-${new Date().toISOString().split("T")[0]}.csv`;
|
||||
a.click();
|
||||
URL.revokeObjectURL(url);
|
||||
}
|
||||
|
||||
function formatDate(iso: string) {
|
||||
return new Date(iso).toLocaleDateString("es-ES", {
|
||||
year: "numeric",
|
||||
month: "short",
|
||||
day: "numeric",
|
||||
hour: "2-digit",
|
||||
minute: "2-digit",
|
||||
});
|
||||
}
|
||||
|
||||
export default function AdminPage() {
|
||||
const [apiKey, setApiKey] = useState("");
|
||||
const [subscribers, setSubscribers] = useState<Subscriber[]>([]);
|
||||
const [messages, setMessages] = useState<ContactMessage[]>([]);
|
||||
const [subTotal, setSubTotal] = useState(0);
|
||||
const [msgTotal, setMsgTotal] = useState(0);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [error, setError] = useState("");
|
||||
|
||||
async function fetchData() {
|
||||
if (!apiKey.trim()) return;
|
||||
setLoading(true);
|
||||
setError("");
|
||||
|
||||
try {
|
||||
const [subRes, msgRes] = await Promise.all([
|
||||
fetch("/api/admin/subscribers?limit=50", {
|
||||
headers: { "x-admin-key": apiKey },
|
||||
}),
|
||||
fetch("/api/admin/messages?limit=50", {
|
||||
headers: { "x-admin-key": apiKey },
|
||||
}),
|
||||
]);
|
||||
|
||||
if (!subRes.ok || !msgRes.ok) {
|
||||
setError("Invalid API key or unauthorized");
|
||||
setLoading(false);
|
||||
return;
|
||||
}
|
||||
|
||||
const subData = await subRes.json();
|
||||
const msgData = await msgRes.json();
|
||||
|
||||
setSubscribers(subData.subscribers || []);
|
||||
setSubTotal(subData.total || 0);
|
||||
setMessages(msgData.messages || []);
|
||||
setMsgTotal(msgData.total || 0);
|
||||
} catch {
|
||||
setError("Failed to fetch data");
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="max-w-6xl mx-auto px-4 py-12">
|
||||
<h1
|
||||
className="text-[clamp(1.75rem,3vw,2.5rem)] font-extrabold mb-8"
|
||||
style={{ fontFamily: "var(--font-display)", letterSpacing: "-0.02em" }}
|
||||
>
|
||||
Admin Dashboard
|
||||
</h1>
|
||||
|
||||
<div className="mb-8 flex flex-col sm:flex-row gap-3 items-start">
|
||||
<input
|
||||
type="password"
|
||||
value={apiKey}
|
||||
onChange={(e) => setApiKey(e.target.value)}
|
||||
placeholder="Enter admin API key"
|
||||
className="flex-1 bg-[#12121a] border border-[rgba(255,255,255,0.08)] rounded-lg px-4 py-2.5 text-sm text-[#f5f5f7] focus:outline-none focus:border-[rgba(212,165,116,0.4)]"
|
||||
/>
|
||||
<button
|
||||
onClick={fetchData}
|
||||
disabled={loading}
|
||||
className="px-6 py-2.5 bg-[#d4a574] text-[#0a0a0f] text-sm font-semibold rounded-lg hover:bg-[#e8c4a0] transition-colors disabled:opacity-50"
|
||||
>
|
||||
{loading ? "Loading..." : "Load Data"}
|
||||
</button>
|
||||
{subscribers.length > 0 && (
|
||||
<button
|
||||
onClick={() => exportCSV(subscribers)}
|
||||
className="px-6 py-2.5 bg-emerald-600 text-[#f5f5f7] text-sm font-semibold rounded-lg hover:bg-emerald-500 transition-colors"
|
||||
>
|
||||
Export CSV
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{error && (
|
||||
<div className="mb-6 p-4 rounded-xl bg-red-500/10 border border-red-500/20 text-red-400 text-sm">
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Stats */}
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4 mb-10">
|
||||
<div className="p-6 rounded-2xl bg-[#12121a] border border-[rgba(255,255,255,0.08)]">
|
||||
<div className="text-3xl font-bold text-[#f5f5f7]">{subTotal}</div>
|
||||
<div className="text-sm text-[#a0a0a8] mt-1">Newsletter Subscribers</div>
|
||||
</div>
|
||||
<div className="p-6 rounded-2xl bg-[#12121a] border border-[rgba(255,255,255,0.08)]">
|
||||
<div className="text-3xl font-bold text-[#f5f5f7]">{msgTotal}</div>
|
||||
<div className="text-sm text-[#a0a0a8] mt-1">Contact Messages</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Subscribers Chart */}
|
||||
{subscribers.length > 0 && (
|
||||
<div className="mb-10 p-6 rounded-2xl bg-[#12121a] border border-[rgba(255,255,255,0.08)]">
|
||||
<h2 className="text-lg font-semibold mb-4">Subscriber Growth</h2>
|
||||
<SubscriberChart
|
||||
data={useMemo(() => {
|
||||
const map = new Map<string, number>();
|
||||
subscribers.forEach((s) => {
|
||||
const date = new Date(s.created_at).toLocaleDateString("en-US", {
|
||||
month: "short",
|
||||
day: "numeric",
|
||||
});
|
||||
map.set(date, (map.get(date) || 0) + 1);
|
||||
});
|
||||
return Array.from(map.entries()).map(([date, count]) => ({ date, count }));
|
||||
}, [subscribers])}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Subscribers Table */}
|
||||
{subscribers.length > 0 && (
|
||||
<div className="mb-10">
|
||||
<h2 className="text-lg font-semibold mb-4">Recent Subscribers</h2>
|
||||
<div className="overflow-x-auto rounded-2xl border border-[rgba(255,255,255,0.08)]">
|
||||
<table className="w-full text-sm">
|
||||
<thead className="bg-[rgba(255,255,255,0.03)] text-[#a0a0a8]">
|
||||
<tr>
|
||||
<th className="text-left px-4 py-3 font-medium">Email</th>
|
||||
<th className="text-left px-4 py-3 font-medium">Locale</th>
|
||||
<th className="text-left px-4 py-3 font-medium">Date</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-[rgba(255,255,255,0.06)]">
|
||||
{subscribers.map((sub) => (
|
||||
<tr key={sub.id} className="hover:bg-[rgba(255,255,255,0.02)]">
|
||||
<td className="px-4 py-3 text-[#a0a0a8]">{sub.email}</td>
|
||||
<td className="px-4 py-3 text-[#6b6b75]">{sub.locale}</td>
|
||||
<td className="px-4 py-3 text-[#6b6b75]">{formatDate(sub.created_at)}</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Messages Table */}
|
||||
{messages.length > 0 && (
|
||||
<div>
|
||||
<h2 className="text-lg font-semibold mb-4">Recent Contact Messages</h2>
|
||||
<div className="space-y-3">
|
||||
{messages.map((msg) => (
|
||||
<div
|
||||
key={msg.id}
|
||||
className="p-4 rounded-xl bg-[#12121a] border border-[rgba(255,255,255,0.08)] hover:border-[rgba(212,165,116,0.2)] transition-colors"
|
||||
>
|
||||
<div className="flex items-center justify-between mb-2">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="font-medium text-[#f5f5f7]">{msg.name}</span>
|
||||
<span className="text-[#6b6b75]"><{msg.email}></span>
|
||||
</div>
|
||||
<span className="text-xs text-[#6b6b75]">{formatDate(msg.created_at)}</span>
|
||||
</div>
|
||||
{msg.subject && <div className="text-sm text-[#a0a0a8] mb-1">{msg.subject}</div>}
|
||||
<div className="text-sm text-[#6b6b75]">{msg.message}</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,33 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import Link from "next/link";
|
||||
import { useLocale, useTranslations } from "next-intl";
|
||||
|
||||
export default function BuyFailurePage() {
|
||||
const t = useTranslations("afc");
|
||||
const locale = useLocale();
|
||||
|
||||
return (
|
||||
<div className="max-w-lg mx-auto px-4 py-20 text-center">
|
||||
<div className="w-20 h-20 mx-auto mb-6 rounded-full bg-red-500/15 border-2 border-red-500/30 flex items-center justify-center">
|
||||
<span className="text-4xl">✕</span>
|
||||
</div>
|
||||
<h1 className="text-3xl font-bold mb-3 text-white">{t("payment_failure_title")}</h1>
|
||||
<p className="text-gray-400 mb-8">{t("payment_failure_description")}</p>
|
||||
<div className="flex flex-col sm:flex-row gap-3 justify-center">
|
||||
<Link
|
||||
href={`/${locale}/afc/buy`}
|
||||
className="px-6 py-3 bg-amber-500 hover:bg-amber-400 text-black font-semibold rounded-xl transition-colors"
|
||||
>
|
||||
{t("try_again")}
|
||||
</Link>
|
||||
<Link
|
||||
href={`/${locale}/afc`}
|
||||
className="px-6 py-3 bg-gray-800 hover:bg-gray-700 text-white font-semibold rounded-xl transition-colors"
|
||||
>
|
||||
{t("back_to_store")}
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,138 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import Link from "next/link";
|
||||
import { useLocale, useTranslations } from "next-intl";
|
||||
import { useDiskId } from "@/hooks/useDiskId";
|
||||
import { createPreference } from "@/lib/afc";
|
||||
import { DiskIdInput } from "@/components/afc/DiskIdInput";
|
||||
import { BalanceDisplay } from "@/components/afc/BalanceDisplay";
|
||||
import { AfcPackageCard } from "@/components/afc/AfcPackageCard";
|
||||
|
||||
const PRICE_PER_AFC = 15;
|
||||
|
||||
const PACKAGES = [
|
||||
{ amount: 10, popular: false },
|
||||
{ amount: 25, popular: true },
|
||||
{ amount: 50, popular: false },
|
||||
{ amount: 100, popular: false },
|
||||
];
|
||||
|
||||
export default function BuyAfcPage() {
|
||||
const t = useTranslations("afc");
|
||||
const locale = useLocale();
|
||||
const disk = useDiskId();
|
||||
const [customAmount, setCustomAmount] = useState("");
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
async function handleBuy(amount: number) {
|
||||
if (!disk.verified || !disk.diskId) return;
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
try {
|
||||
const data = await createPreference(disk.diskId, amount);
|
||||
// Redirect to MercadoPago checkout
|
||||
window.location.href = data.initPoint;
|
||||
} catch (e: any) {
|
||||
setError(e.message);
|
||||
setLoading(false);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="max-w-3xl mx-auto px-4 py-12">
|
||||
{/* Back */}
|
||||
<Link
|
||||
href={`/${locale}/afc`}
|
||||
className="inline-flex items-center gap-2 text-sm text-gray-500 hover:text-gray-300 transition-colors mb-8"
|
||||
>
|
||||
← {t("back_to_store")}
|
||||
</Link>
|
||||
|
||||
<h1 className="text-3xl font-bold mb-2">{t("buy_title")}</h1>
|
||||
<p className="text-gray-400 mb-8">{t("buy_subtitle")}</p>
|
||||
|
||||
{/* Disk ID */}
|
||||
<div className="mb-8">
|
||||
<DiskIdInput
|
||||
diskId={disk.diskId}
|
||||
onChange={disk.setDiskId}
|
||||
onVerify={() => disk.verify(disk.diskId)}
|
||||
loading={disk.loading}
|
||||
verified={disk.verified}
|
||||
playerName={disk.playerName}
|
||||
error={disk.error}
|
||||
onClear={disk.clear}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{disk.verified && (
|
||||
<>
|
||||
{/* Balance */}
|
||||
<div className="mb-8">
|
||||
<BalanceDisplay balance={disk.balance} compact />
|
||||
</div>
|
||||
|
||||
{/* Packages */}
|
||||
<div className="space-y-3 mb-8">
|
||||
<h2 className="text-lg font-semibold text-white mb-4">{t("select_package")}</h2>
|
||||
{PACKAGES.map((pkg) => (
|
||||
<AfcPackageCard
|
||||
key={pkg.amount}
|
||||
amount={pkg.amount}
|
||||
priceMxn={pkg.amount * PRICE_PER_AFC}
|
||||
popular={pkg.popular}
|
||||
loading={loading}
|
||||
onSelect={() => handleBuy(pkg.amount)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Custom amount */}
|
||||
<div className="bg-gray-900 border border-white/5 rounded-2xl p-6">
|
||||
<h3 className="text-sm font-medium text-gray-400 mb-3">{t("custom_amount")}</h3>
|
||||
<div className="flex gap-3">
|
||||
<div className="flex-1 relative">
|
||||
<input
|
||||
type="number"
|
||||
min="1"
|
||||
value={customAmount}
|
||||
onChange={(e) => setCustomAmount(e.target.value)}
|
||||
placeholder="AFC"
|
||||
className="w-full bg-gray-800 border border-white/10 rounded-xl px-4 py-3 text-white placeholder:text-gray-600 focus:outline-none focus:border-amber-500/50 transition-all"
|
||||
/>
|
||||
{customAmount && Number(customAmount) > 0 && (
|
||||
<span className="absolute right-4 top-1/2 -translate-y-1/2 text-sm text-gray-500">
|
||||
= ${Number(customAmount) * PRICE_PER_AFC} MXN
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<button
|
||||
onClick={() => {
|
||||
const amt = Number(customAmount);
|
||||
if (amt >= 1) handleBuy(amt);
|
||||
}}
|
||||
disabled={loading || !customAmount || Number(customAmount) < 1}
|
||||
className="px-6 py-3 bg-amber-500 hover:bg-amber-400 disabled:bg-gray-700 disabled:text-gray-500 text-black font-semibold rounded-xl transition-colors"
|
||||
>
|
||||
{t("buy")}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{error && (
|
||||
<div className="mt-4 bg-red-500/10 border border-red-500/30 rounded-xl p-4 text-sm text-red-400">
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Payment info */}
|
||||
<p className="mt-6 text-xs text-gray-600 text-center">
|
||||
{t("payment_info")}
|
||||
</p>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,39 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import Link from "next/link";
|
||||
import { useLocale, useTranslations } from "next-intl";
|
||||
import { useSearchParams } from "next/navigation";
|
||||
|
||||
export default function BuyPendingPage() {
|
||||
const t = useTranslations("afc");
|
||||
const locale = useLocale();
|
||||
const searchParams = useSearchParams();
|
||||
const paymentId = searchParams.get("payment_id");
|
||||
|
||||
return (
|
||||
<div className="max-w-lg mx-auto px-4 py-20 text-center">
|
||||
<div className="w-20 h-20 mx-auto mb-6 rounded-full bg-yellow-500/15 border-2 border-yellow-500/30 flex items-center justify-center">
|
||||
<span className="text-4xl">⏳</span>
|
||||
</div>
|
||||
<h1 className="text-3xl font-bold mb-3 text-white">{t("payment_pending_title")}</h1>
|
||||
<p className="text-gray-400 mb-2">{t("payment_pending_description")}</p>
|
||||
{paymentId && (
|
||||
<p className="text-xs text-gray-600 mb-8 font-mono">ID: {paymentId}</p>
|
||||
)}
|
||||
<div className="flex flex-col sm:flex-row gap-3 justify-center">
|
||||
<Link
|
||||
href={`/${locale}/afc/history`}
|
||||
className="px-6 py-3 bg-amber-500 hover:bg-amber-400 text-black font-semibold rounded-xl transition-colors"
|
||||
>
|
||||
{t("view_history")}
|
||||
</Link>
|
||||
<Link
|
||||
href={`/${locale}/afc`}
|
||||
className="px-6 py-3 bg-gray-800 hover:bg-gray-700 text-white font-semibold rounded-xl transition-colors"
|
||||
>
|
||||
{t("back_to_store")}
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,39 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import Link from "next/link";
|
||||
import { useLocale, useTranslations } from "next-intl";
|
||||
import { useSearchParams } from "next/navigation";
|
||||
|
||||
export default function BuySuccessPage() {
|
||||
const t = useTranslations("afc");
|
||||
const locale = useLocale();
|
||||
const searchParams = useSearchParams();
|
||||
const paymentId = searchParams.get("payment_id");
|
||||
|
||||
return (
|
||||
<div className="max-w-lg mx-auto px-4 py-20 text-center">
|
||||
<div className="w-20 h-20 mx-auto mb-6 rounded-full bg-green-500/15 border-2 border-green-500/30 flex items-center justify-center">
|
||||
<span className="text-4xl">✓</span>
|
||||
</div>
|
||||
<h1 className="text-3xl font-bold mb-3 text-white">{t("payment_success_title")}</h1>
|
||||
<p className="text-gray-400 mb-2">{t("payment_success_description")}</p>
|
||||
{paymentId && (
|
||||
<p className="text-xs text-gray-600 mb-8 font-mono">ID: {paymentId}</p>
|
||||
)}
|
||||
<div className="flex flex-col sm:flex-row gap-3 justify-center">
|
||||
<Link
|
||||
href={`/${locale}/afc`}
|
||||
className="px-6 py-3 bg-amber-500 hover:bg-amber-400 text-black font-semibold rounded-xl transition-colors"
|
||||
>
|
||||
{t("back_to_store")}
|
||||
</Link>
|
||||
<Link
|
||||
href={`/${locale}/afc/history`}
|
||||
className="px-6 py-3 bg-gray-800 hover:bg-gray-700 text-white font-semibold rounded-xl transition-colors"
|
||||
>
|
||||
{t("view_history")}
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,101 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import { useState, useEffect } from "react";
|
||||
import Link from "next/link";
|
||||
import { useLocale, useTranslations } from "next-intl";
|
||||
import { useDiskId } from "@/hooks/useDiskId";
|
||||
import { getPaymentHistory, getRedemptionHistory } from "@/lib/afc";
|
||||
import type { Payment, Redemption } from "@/lib/afc";
|
||||
import { DiskIdInput } from "@/components/afc/DiskIdInput";
|
||||
import { BalanceDisplay } from "@/components/afc/BalanceDisplay";
|
||||
import { PaymentHistoryTable } from "@/components/afc/PaymentHistoryTable";
|
||||
import { RedemptionHistoryTable } from "@/components/afc/RedemptionHistoryTable";
|
||||
|
||||
type Tab = "payments" | "redemptions";
|
||||
|
||||
export default function HistoryPage() {
|
||||
const t = useTranslations("afc");
|
||||
const locale = useLocale();
|
||||
const disk = useDiskId();
|
||||
const [tab, setTab] = useState<Tab>("payments");
|
||||
const [payments, setPayments] = useState<Payment[]>([]);
|
||||
const [redemptions, setRedemptions] = useState<Redemption[]>([]);
|
||||
const [loadingData, setLoadingData] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
if (!disk.verified || !disk.diskId) return;
|
||||
setLoadingData(true);
|
||||
Promise.all([
|
||||
getPaymentHistory(disk.diskId).then((d) => setPayments(d.payments || [])).catch(() => {}),
|
||||
getRedemptionHistory(disk.diskId).then((d) => setRedemptions(d.redemptions || [])).catch(() => {}),
|
||||
]).finally(() => setLoadingData(false));
|
||||
}, [disk.verified, disk.diskId]);
|
||||
|
||||
return (
|
||||
<div className="max-w-3xl mx-auto px-4 py-12">
|
||||
<Link
|
||||
href={`/${locale}/afc`}
|
||||
className="inline-flex items-center gap-2 text-sm text-gray-500 hover:text-gray-300 transition-colors mb-8"
|
||||
>
|
||||
← {t("back_to_store")}
|
||||
</Link>
|
||||
|
||||
<h1 className="text-3xl font-bold mb-2">{t("history_title")}</h1>
|
||||
<p className="text-gray-400 mb-8">{t("history_subtitle")}</p>
|
||||
|
||||
{/* Disk ID */}
|
||||
<div className="mb-8">
|
||||
<DiskIdInput
|
||||
diskId={disk.diskId}
|
||||
onChange={disk.setDiskId}
|
||||
onVerify={() => disk.verify(disk.diskId)}
|
||||
loading={disk.loading}
|
||||
verified={disk.verified}
|
||||
playerName={disk.playerName}
|
||||
error={disk.error}
|
||||
onClear={disk.clear}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{disk.verified && (
|
||||
<>
|
||||
<div className="mb-8">
|
||||
<BalanceDisplay balance={disk.balance} compact />
|
||||
</div>
|
||||
|
||||
{/* Tabs */}
|
||||
<div className="flex gap-1 bg-gray-900 rounded-xl p-1 mb-6">
|
||||
<button
|
||||
onClick={() => setTab("payments")}
|
||||
className={`flex-1 py-2.5 text-sm font-medium rounded-lg transition-colors ${
|
||||
tab === "payments"
|
||||
? "bg-amber-500 text-black"
|
||||
: "text-gray-400 hover:text-white"
|
||||
}`}
|
||||
>
|
||||
{t("purchases")} ({payments.length})
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setTab("redemptions")}
|
||||
className={`flex-1 py-2.5 text-sm font-medium rounded-lg transition-colors ${
|
||||
tab === "redemptions"
|
||||
? "bg-amber-500 text-black"
|
||||
: "text-gray-400 hover:text-white"
|
||||
}`}
|
||||
>
|
||||
{t("redemptions")} ({redemptions.length})
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{loadingData ? (
|
||||
<div className="text-center py-12 text-gray-500">{t("loading")}</div>
|
||||
) : tab === "payments" ? (
|
||||
<PaymentHistoryTable payments={payments} />
|
||||
) : (
|
||||
<RedemptionHistoryTable redemptions={redemptions} />
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,90 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import Link from "next/link";
|
||||
import { useLocale, useTranslations } from "next-intl";
|
||||
import { useDiskId } from "@/hooks/useDiskId";
|
||||
import { DiskIdInput } from "@/components/afc/DiskIdInput";
|
||||
import { BalanceDisplay } from "@/components/afc/BalanceDisplay";
|
||||
|
||||
export default function AfcHubPage() {
|
||||
const t = useTranslations("afc");
|
||||
const locale = useLocale();
|
||||
const disk = useDiskId();
|
||||
|
||||
return (
|
||||
<div className="max-w-4xl mx-auto px-4 py-12">
|
||||
{/* Header */}
|
||||
<div className="text-center mb-12">
|
||||
<div className="w-20 h-20 mx-auto mb-6 rounded-full bg-gradient-to-br from-amber-400 to-amber-600 flex items-center justify-center shadow-lg shadow-amber-500/20">
|
||||
<span className="text-3xl font-bold text-black">A</span>
|
||||
</div>
|
||||
<h1 className="text-4xl font-bold mb-3">{t("store_title")}</h1>
|
||||
<p className="text-gray-400 text-lg max-w-xl mx-auto">
|
||||
{t("store_subtitle")}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Disk ID */}
|
||||
<div className="mb-10">
|
||||
<DiskIdInput
|
||||
diskId={disk.diskId}
|
||||
onChange={disk.setDiskId}
|
||||
onVerify={() => disk.verify(disk.diskId)}
|
||||
loading={disk.loading}
|
||||
verified={disk.verified}
|
||||
playerName={disk.playerName}
|
||||
error={disk.error}
|
||||
onClear={disk.clear}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Balance */}
|
||||
{disk.verified && (
|
||||
<div className="mb-10">
|
||||
<BalanceDisplay balance={disk.balance} />
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Action cards */}
|
||||
<div className="grid grid-cols-1 md:grid-cols-3 gap-6">
|
||||
<Link
|
||||
href={`/${locale}/afc/buy`}
|
||||
className="group block bg-gray-900 rounded-2xl p-8 border border-white/5 hover:border-amber-500/40 transition-all duration-200 hover:shadow-lg hover:shadow-amber-500/5"
|
||||
>
|
||||
<div className="w-12 h-12 rounded-xl bg-green-500/10 flex items-center justify-center mb-4 group-hover:bg-green-500/20 transition-colors">
|
||||
<span className="text-2xl">+</span>
|
||||
</div>
|
||||
<h2 className="text-xl font-bold mb-2 text-white">{t("buy_title")}</h2>
|
||||
<p className="text-gray-500 text-sm">{t("buy_description")}</p>
|
||||
</Link>
|
||||
|
||||
<Link
|
||||
href={`/${locale}/afc/redeem`}
|
||||
className="group block bg-gray-900 rounded-2xl p-8 border border-white/5 hover:border-amber-500/40 transition-all duration-200 hover:shadow-lg hover:shadow-amber-500/5"
|
||||
>
|
||||
<div className="w-12 h-12 rounded-xl bg-amber-500/10 flex items-center justify-center mb-4 group-hover:bg-amber-500/20 transition-colors">
|
||||
<span className="text-2xl">★</span>
|
||||
</div>
|
||||
<h2 className="text-xl font-bold mb-2 text-white">{t("redeem_title")}</h2>
|
||||
<p className="text-gray-500 text-sm">{t("redeem_description")}</p>
|
||||
</Link>
|
||||
|
||||
<Link
|
||||
href={`/${locale}/afc/history`}
|
||||
className="group block bg-gray-900 rounded-2xl p-8 border border-white/5 hover:border-amber-500/40 transition-all duration-200 hover:shadow-lg hover:shadow-amber-500/5"
|
||||
>
|
||||
<div className="w-12 h-12 rounded-xl bg-blue-500/10 flex items-center justify-center mb-4 group-hover:bg-blue-500/20 transition-colors">
|
||||
<span className="text-2xl">≡</span>
|
||||
</div>
|
||||
<h2 className="text-xl font-bold mb-2 text-white">{t("history_title")}</h2>
|
||||
<p className="text-gray-500 text-sm">{t("history_description")}</p>
|
||||
</Link>
|
||||
</div>
|
||||
|
||||
{/* Info */}
|
||||
<div className="mt-12 bg-gray-900/50 border border-white/5 rounded-2xl p-6 text-sm text-gray-500">
|
||||
<p>{t("store_info")}</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,184 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import Link from "next/link";
|
||||
import { useLocale, useTranslations } from "next-intl";
|
||||
import { useDiskId } from "@/hooks/useDiskId";
|
||||
import { redeemAfc } from "@/lib/afc";
|
||||
import { DiskIdInput } from "@/components/afc/DiskIdInput";
|
||||
import { BalanceDisplay } from "@/components/afc/BalanceDisplay";
|
||||
import { PrizeCard } from "@/components/afc/PrizeCard";
|
||||
import { RedeemForm } from "@/components/afc/RedeemForm";
|
||||
|
||||
interface Prize {
|
||||
icon: string;
|
||||
brand: string;
|
||||
label: string;
|
||||
costAfc: number;
|
||||
valueMxn: number;
|
||||
prizeType: string;
|
||||
prizeDetail: string;
|
||||
}
|
||||
|
||||
const GIFT_CARDS: Prize[] = [
|
||||
{ icon: "🎮", brand: "Steam", label: "$200 MXN", costAfc: 14, valueMxn: 200, prizeType: "gift_card", prizeDetail: "Steam $200 MXN" },
|
||||
{ icon: "🎮", brand: "Steam", label: "$500 MXN", costAfc: 34, valueMxn: 500, prizeType: "gift_card", prizeDetail: "Steam $500 MXN" },
|
||||
{ icon: "🎮", brand: "Steam", label: "$1,000 MXN", costAfc: 67, valueMxn: 1000, prizeType: "gift_card", prizeDetail: "Steam $1,000 MXN" },
|
||||
{ icon: "🟢", brand: "Xbox", label: "$200 MXN", costAfc: 14, valueMxn: 200, prizeType: "gift_card", prizeDetail: "Xbox $200 MXN" },
|
||||
{ icon: "🟢", brand: "Xbox", label: "$500 MXN", costAfc: 34, valueMxn: 500, prizeType: "gift_card", prizeDetail: "Xbox $500 MXN" },
|
||||
{ icon: "🔵", brand: "PlayStation", label: "$200 MXN", costAfc: 14, valueMxn: 200, prizeType: "gift_card", prizeDetail: "PlayStation $200 MXN" },
|
||||
{ icon: "🔵", brand: "PlayStation", label: "$500 MXN", costAfc: 14, valueMxn: 500, prizeType: "gift_card", prizeDetail: "PlayStation $500 MXN" },
|
||||
{ icon: "📦", brand: "Amazon", label: "$200 MXN", costAfc: 14, valueMxn: 200, prizeType: "gift_card", prizeDetail: "Amazon $200 MXN" },
|
||||
{ icon: "📦", brand: "Amazon", label: "$500 MXN", costAfc: 34, valueMxn: 500, prizeType: "gift_card", prizeDetail: "Amazon $500 MXN" },
|
||||
];
|
||||
|
||||
const CASH_OUT: Prize[] = [
|
||||
{ icon: "🏦", brand: "Banco (CLABE)", label: "$750+ MXN", costAfc: 50, valueMxn: 750, prizeType: "bank_transfer", prizeDetail: "Transferencia bancaria $750 MXN" },
|
||||
{ icon: "💳", brand: "MercadoPago", label: "$750+ MXN", costAfc: 50, valueMxn: 750, prizeType: "mercadopago", prizeDetail: "Retiro MercadoPago $750 MXN" },
|
||||
];
|
||||
|
||||
export default function RedeemPage() {
|
||||
const t = useTranslations("afc");
|
||||
const locale = useLocale();
|
||||
const disk = useDiskId();
|
||||
const [selected, setSelected] = useState<Prize | null>(null);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [success, setSuccess] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
async function handleRedeem(deliveryInfo: string) {
|
||||
if (!selected || !disk.diskId) return;
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
try {
|
||||
await redeemAfc({
|
||||
diskId: disk.diskId,
|
||||
amountAfc: selected.costAfc,
|
||||
prizeType: selected.prizeType,
|
||||
prizeDetail: selected.prizeDetail,
|
||||
deliveryInfo,
|
||||
});
|
||||
setSuccess(true);
|
||||
disk.refreshBalance();
|
||||
} catch (e: any) {
|
||||
setError(e.message);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}
|
||||
|
||||
if (success) {
|
||||
return (
|
||||
<div className="max-w-lg mx-auto px-4 py-20 text-center">
|
||||
<div className="w-20 h-20 mx-auto mb-6 rounded-full bg-green-500/15 border-2 border-green-500/30 flex items-center justify-center">
|
||||
<span className="text-4xl">✓</span>
|
||||
</div>
|
||||
<h1 className="text-3xl font-bold mb-3">{t("redeem_success_title")}</h1>
|
||||
<p className="text-gray-400 mb-8">{t("redeem_success_description")}</p>
|
||||
<div className="flex flex-col sm:flex-row gap-3 justify-center">
|
||||
<Link
|
||||
href={`/${locale}/afc/history`}
|
||||
className="px-6 py-3 bg-amber-500 hover:bg-amber-400 text-black font-semibold rounded-xl transition-colors"
|
||||
>
|
||||
{t("view_history")}
|
||||
</Link>
|
||||
<Link
|
||||
href={`/${locale}/afc`}
|
||||
className="px-6 py-3 bg-gray-800 hover:bg-gray-700 text-white font-semibold rounded-xl transition-colors"
|
||||
>
|
||||
{t("back_to_store")}
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="max-w-3xl mx-auto px-4 py-12">
|
||||
<Link
|
||||
href={`/${locale}/afc`}
|
||||
className="inline-flex items-center gap-2 text-sm text-gray-500 hover:text-gray-300 transition-colors mb-8"
|
||||
>
|
||||
← {t("back_to_store")}
|
||||
</Link>
|
||||
|
||||
<h1 className="text-3xl font-bold mb-2">{t("redeem_title")}</h1>
|
||||
<p className="text-gray-400 mb-8">{t("redeem_subtitle")}</p>
|
||||
|
||||
{/* Disk ID */}
|
||||
<div className="mb-8">
|
||||
<DiskIdInput
|
||||
diskId={disk.diskId}
|
||||
onChange={disk.setDiskId}
|
||||
onVerify={() => disk.verify(disk.diskId)}
|
||||
loading={disk.loading}
|
||||
verified={disk.verified}
|
||||
playerName={disk.playerName}
|
||||
error={disk.error}
|
||||
onClear={disk.clear}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{disk.verified && (
|
||||
<>
|
||||
<div className="mb-8">
|
||||
<BalanceDisplay balance={disk.balance} />
|
||||
</div>
|
||||
|
||||
{selected ? (
|
||||
<RedeemForm
|
||||
prizeType={selected.prizeType}
|
||||
prizeDetail={selected.prizeDetail}
|
||||
costAfc={selected.costAfc}
|
||||
onSubmit={handleRedeem}
|
||||
onCancel={() => setSelected(null)}
|
||||
loading={loading}
|
||||
/>
|
||||
) : (
|
||||
<>
|
||||
{/* Gift Cards */}
|
||||
<h2 className="text-lg font-semibold text-white mb-4">{t("gift_cards")}</h2>
|
||||
<div className="space-y-3 mb-8">
|
||||
{GIFT_CARDS.map((prize, i) => (
|
||||
<PrizeCard
|
||||
key={i}
|
||||
icon={prize.icon}
|
||||
brand={prize.brand}
|
||||
label={prize.label}
|
||||
costAfc={prize.costAfc}
|
||||
valueMxn={prize.valueMxn}
|
||||
disabled={disk.balance !== null && disk.balance < prize.costAfc}
|
||||
onSelect={() => setSelected(prize)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Cash Out */}
|
||||
<h2 className="text-lg font-semibold text-white mb-4">{t("cash_out")}</h2>
|
||||
<div className="space-y-3">
|
||||
{CASH_OUT.map((prize, i) => (
|
||||
<PrizeCard
|
||||
key={i}
|
||||
icon={prize.icon}
|
||||
brand={prize.brand}
|
||||
label={prize.label}
|
||||
costAfc={prize.costAfc}
|
||||
valueMxn={prize.valueMxn}
|
||||
disabled={disk.balance !== null && disk.balance < prize.costAfc}
|
||||
onSelect={() => setSelected(prize)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
{error && (
|
||||
<div className="mt-4 bg-red-500/10 border border-red-500/30 rounded-xl p-4 text-sm text-red-400">
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,8 +1,13 @@
|
||||
import type { Metadata } from "next";
|
||||
import { Suspense } from "react";
|
||||
import { getGames } from "@/lib/api";
|
||||
import { CatalogFilters } from "@/components/catalog/CatalogFilters";
|
||||
import { CatalogGrid } from "@/components/catalog/CatalogGrid";
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: "Game Catalog | Project Afterlife",
|
||||
};
|
||||
|
||||
export default async function CatalogPage({
|
||||
params,
|
||||
}: {
|
||||
@@ -20,8 +25,14 @@ export default async function CatalogPage({
|
||||
|
||||
return (
|
||||
<div className="max-w-7xl mx-auto px-4 py-12">
|
||||
<h1 className="text-4xl font-bold mb-8">
|
||||
{locale === "es" ? "Catálogo de Juegos" : "Game Catalog"}
|
||||
<h1
|
||||
className="text-[clamp(2rem,4vw,3rem)] font-extrabold mb-8"
|
||||
style={{ fontFamily: "var(--font-display)", letterSpacing: "-0.02em" }}
|
||||
>
|
||||
{locale === "es" ? "Catálogo de " : "Game "}
|
||||
<span style={{ color: "var(--accent-primary)" }}>
|
||||
{locale === "es" ? "Juegos" : "Catalog"}
|
||||
</span>
|
||||
</h1>
|
||||
<Suspense>
|
||||
<CatalogFilters />
|
||||
|
||||
183
apps/web/src/app/[locale]/community/page.tsx
Normal file
183
apps/web/src/app/[locale]/community/page.tsx
Normal file
@@ -0,0 +1,183 @@
|
||||
"use client";
|
||||
|
||||
import { useTranslations } from "next-intl";
|
||||
import { motion } from "framer-motion";
|
||||
import { useLocale } from "next-intl";
|
||||
import Link from "next/link";
|
||||
|
||||
function DiscordIcon({ className }: { className?: string }) {
|
||||
return (
|
||||
<svg className={className} viewBox="0 0 24 24" fill="currentColor">
|
||||
<path d="M20.317 4.37a19.791 19.791 0 0 0-4.885-1.515.074.074 0 0 0-.079.037c-.21.375-.444.864-.608 1.25a18.27 18.27 0 0 0-5.487 0 12.64 12.64 0 0 0-.617-1.25.077.077 0 0 0-.079-.037A19.736 19.736 0 0 0 3.677 4.37a.07.07 0 0 0-.032.027C.533 9.046-.32 13.58.099 18.057a.082.082 0 0 0 .031.057 19.9 19.9 0 0 0 5.993 3.03.078.078 0 0 0 .084-.028c.462-.63.874-1.295 1.226-1.994a.076.076 0 0 0-.041-.106 13.107 13.107 0 0 1-1.872-.892.077.077 0 0 1-.008-.128 10.2 10.2 0 0 0 .372-.292.074.074 0 0 1 .077-.01c3.928 1.793 8.18 1.793 12.062 0a.074.074 0 0 1 .078.01c.12.098.246.198.373.292a.077.077 0 0 1-.006.127 12.299 12.299 0 0 1-1.873.892.077.077 0 0 0-.041.107c.36.698.772 1.362 1.225 1.993a.076.076 0 0 0 .084.028 19.839 19.839 0 0 0 6.002-3.03.077.077 0 0 0 .032-.054c.5-5.177-.838-9.674-3.549-13.66a.061.061 0 0 0-.031-.03zM8.02 15.33c-1.183 0-2.157-1.085-2.157-2.419 0-1.333.956-2.419 2.157-2.419 1.21 0 2.176 1.096 2.157 2.42 0 1.333-.956 2.418-2.157 2.418zm7.975 0c-1.183 0-2.157-1.085-2.157-2.419 0-1.333.955-2.419 2.157-2.419 1.21 0 2.176 1.096 2.157 2.42 0 1.333-.946 2.418-2.157 2.418z" />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
function GitHubIcon({ className }: { className?: string }) {
|
||||
return (
|
||||
<svg className={className} viewBox="0 0 24 24" fill="currentColor">
|
||||
<path d="M12 .297c-6.63 0-12 5.373-12 12 0 5.303 3.438 9.8 8.205 11.385.6.113.82-.258.82-.577 0-.285-.01-1.04-.015-2.04-3.338.724-4.042-1.61-4.042-1.61C4.422 18.07 3.633 17.7 3.633 17.7c-1.087-.744.084-.729.084-.729 1.205.084 1.838 1.236 1.838 1.236 1.07 1.835 2.809 1.305 3.495.998.108-.776.417-1.305.76-1.605-2.665-.3-5.466-1.332-5.466-5.93 0-1.31.465-2.38 1.235-3.22-.135-.303-.54-1.523.105-3.176 0 0 1.005-.322 3.3 1.23.96-.267 1.98-.399 3-.405 1.02.006 2.04.138 3 .405 2.28-1.552 3.285-1.23 3.285-1.23.645 1.653.24 2.873.12 3.176.765.84 1.23 1.91 1.23 3.22 0 4.61-2.805 5.625-5.475 5.92.42.36.81 1.096.81 2.22 0 1.606-.015 2.896-.015 3.286 0 .315.21.69.825.57C20.565 22.092 24 17.592 24 12.297c0-6.627-5.373-12-12-12" />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
function HeartIcon({ className }: { className?: string }) {
|
||||
return (
|
||||
<svg className={className} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
||||
<path d="M20.84 4.61a5.5 5.5 0 0 0-7.78 0L12 5.67l-1.06-1.06a5.5 5.5 0 0 0-7.78 7.78l1.06 1.06L12 21.23l7.78-7.78 1.06-1.06a5.5 5.5 0 0 0 0-7.78z" />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
function MessageIcon({ className }: { className?: string }) {
|
||||
return (
|
||||
<svg className={className} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
||||
<path d="M21 11.5a8.38 8.38 0 0 1-.9 3.8 8.5 8.5 0 0 1-7.6 4.7 8.38 8.38 0 0 1-3.8-.9L3 21l1.9-5.7a8.38 8.38 0 0 1-.9-3.8 8.5 8.5 0 0 1 4.7-7.6 8.38 8.38 0 0 1 3.8-.9h.5a8.48 8.48 0 0 1 8 8v.5z" />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
const cardVariants = {
|
||||
hidden: { opacity: 0, y: 20 },
|
||||
visible: (i: number) => ({
|
||||
opacity: 1,
|
||||
y: 0,
|
||||
transition: { delay: i * 0.1, duration: 0.5, ease: "easeOut" as const },
|
||||
}),
|
||||
};
|
||||
|
||||
const channels = [
|
||||
{
|
||||
name: "Discord",
|
||||
description: "Join our community Discord to chat with other players, get support, and stay updated on new releases.",
|
||||
href: "https://discord.gg/projectafterlife",
|
||||
icon: DiscordIcon,
|
||||
color: "text-[#d4a574]",
|
||||
bg: "bg-[rgba(212,165,116,0.08)] border-[rgba(212,165,116,0.15)] hover:border-[rgba(212,165,116,0.35)]",
|
||||
btn: "bg-[#d4a574] hover:bg-[#e8c4a0] text-[#0a0a0f]",
|
||||
btnText: "text-[#0a0a0f]",
|
||||
label: "Join Discord",
|
||||
},
|
||||
{
|
||||
name: "GitHub",
|
||||
description: "Our code is open source. Contribute to the project, report issues, or explore our repositories.",
|
||||
href: "https://github.com/projectafterlife",
|
||||
icon: GitHubIcon,
|
||||
color: "text-[#a0a0a8]",
|
||||
bg: "bg-[rgba(255,255,255,0.03)] border-[rgba(255,255,255,0.08)] hover:border-[rgba(255,255,255,0.15)]",
|
||||
btn: "bg-[#1a1a24] hover:bg-[#2a2a34] text-[#f5f5f7]",
|
||||
btnText: "text-[#f5f5f7]",
|
||||
label: "View GitHub",
|
||||
},
|
||||
{
|
||||
name: "Forums",
|
||||
description: "Long-form discussions, guides, bug reports, and feature requests. The heart of our community.",
|
||||
href: "#",
|
||||
icon: MessageIcon,
|
||||
color: "text-[#e8c4a0]",
|
||||
bg: "bg-[rgba(232,196,160,0.08)] border-[rgba(232,196,160,0.15)] hover:border-[rgba(232,196,160,0.35)]",
|
||||
btn: "bg-[#e8c4a0] hover:bg-[#d4a574] text-[#0a0a0f]",
|
||||
btnText: "text-[#0a0a0f]",
|
||||
label: "Coming Soon",
|
||||
},
|
||||
{
|
||||
name: "Contribute",
|
||||
description: "Help us preserve gaming history. We need developers, writers, translators, and testers.",
|
||||
href: "/donate",
|
||||
icon: HeartIcon,
|
||||
color: "text-rose-400",
|
||||
bg: "bg-rose-500/8 border-rose-500/15 hover:border-rose-500/35",
|
||||
btn: "bg-rose-600 hover:bg-rose-500 text-white",
|
||||
btnText: "text-white",
|
||||
label: "How to Help",
|
||||
},
|
||||
];
|
||||
|
||||
export default function CommunityPage() {
|
||||
const locale = useLocale();
|
||||
const isEs = locale === "es";
|
||||
|
||||
return (
|
||||
<div className="max-w-6xl mx-auto px-4 py-12">
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: 20 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ duration: 0.6 }}
|
||||
className="text-center mb-16"
|
||||
>
|
||||
<h1
|
||||
className="text-[clamp(2rem,4vw,3.5rem)] font-extrabold mb-4 tracking-tight"
|
||||
style={{ fontFamily: "var(--font-display)", letterSpacing: "-0.02em" }}
|
||||
>
|
||||
{isEs ? "Comunidad" : "Community"}
|
||||
</h1>
|
||||
<p className="text-lg text-[#a0a0a8] max-w-2xl mx-auto leading-relaxed">
|
||||
{isEs
|
||||
? "Project Afterlife es impulsado por su comunidad. Únete a nosotros para preservar la historia de los juegos juntos."
|
||||
: "Project Afterlife is driven by its community. Join us in preserving gaming history together."}
|
||||
</p>
|
||||
</motion.div>
|
||||
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
|
||||
{channels.map((channel, i) => {
|
||||
const Icon = channel.icon;
|
||||
const isExternal = channel.href.startsWith("http");
|
||||
const Wrapper = isExternal ? "a" : Link;
|
||||
const wrapperProps = isExternal
|
||||
? { href: channel.href, target: "_blank", rel: "noopener noreferrer" }
|
||||
: { href: channel.href };
|
||||
|
||||
return (
|
||||
<motion.div
|
||||
key={channel.name}
|
||||
custom={i}
|
||||
initial="hidden"
|
||||
whileInView="visible"
|
||||
viewport={{ once: true, margin: "-50px" }}
|
||||
variants={cardVariants}
|
||||
>
|
||||
<Wrapper
|
||||
{...wrapperProps}
|
||||
className={`block rounded-2xl border p-8 transition-all duration-300 hover:scale-[1.02] ${channel.bg}`}
|
||||
>
|
||||
<div className="flex items-start gap-4">
|
||||
<div className={`p-3 rounded-xl bg-black/30 ${channel.color}`}>
|
||||
<Icon className="w-6 h-6" />
|
||||
</div>
|
||||
<div className="flex-1">
|
||||
<h3 className="text-xl font-semibold text-[#f5f5f7] mb-2">{channel.name}</h3>
|
||||
<p className="text-sm text-[#a0a0a8] leading-relaxed mb-4">
|
||||
{channel.description}
|
||||
</p>
|
||||
<span
|
||||
className={`inline-block px-4 py-1.5 rounded-lg text-sm font-medium transition-colors ${channel.btn}`}
|
||||
>
|
||||
{channel.label}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</Wrapper>
|
||||
</motion.div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
<motion.div
|
||||
initial={{ opacity: 0 }}
|
||||
whileInView={{ opacity: 1 }}
|
||||
viewport={{ once: true }}
|
||||
transition={{ delay: 0.4, duration: 0.6 }}
|
||||
className="mt-16 rounded-2xl border border-[rgba(255,255,255,0.08)] bg-[#12121a] p-8 text-center"
|
||||
>
|
||||
<h2 className="text-2xl font-semibold mb-3">
|
||||
{isEs ? "Código de Conducta" : "Code of Conduct"}
|
||||
</h2>
|
||||
<p className="text-[#a0a0a8] max-w-2xl mx-auto leading-relaxed">
|
||||
{isEs
|
||||
? "Todos los miembros de nuestra comunidad deben tratar a los demás con respeto. No se tolera el acoso, la discriminación ni el comportamiento tóxico. Queremos que este sea un espacio seguro y acogedor para todos los amantes de los juegos."
|
||||
: "All members of our community must treat others with respect. Harassment, discrimination, and toxic behavior are not tolerated. We want this to be a safe and welcoming space for all game lovers."}
|
||||
</p>
|
||||
</motion.div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
116
apps/web/src/app/[locale]/contact/page.tsx
Normal file
116
apps/web/src/app/[locale]/contact/page.tsx
Normal file
@@ -0,0 +1,116 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import { useLocale } from "next-intl";
|
||||
import { motion } from "framer-motion";
|
||||
import { useToast } from "@/hooks/useToast";
|
||||
|
||||
export default function ContactPage() {
|
||||
const locale = useLocale();
|
||||
const isEs = locale === "es";
|
||||
const toast = useToast();
|
||||
const [form, setForm] = useState({ name: "", email: "", subject: "", message: "" });
|
||||
const [loading, setLoading] = useState(false);
|
||||
|
||||
async function handleSubmit(e: React.FormEvent) {
|
||||
e.preventDefault();
|
||||
if (!form.name || !form.email || !form.message) return;
|
||||
|
||||
setLoading(true);
|
||||
try {
|
||||
const res = await fetch("/api/contact", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify(form),
|
||||
});
|
||||
|
||||
const data = await res.json();
|
||||
if (res.ok) {
|
||||
toast.success(isEs ? "¡Mensaje enviado!" : "Message sent!");
|
||||
setForm({ name: "", email: "", subject: "", message: "" });
|
||||
} else {
|
||||
toast.error(data.error || (isEs ? "Error al enviar" : "Failed to send"));
|
||||
}
|
||||
} catch {
|
||||
toast.error(isEs ? "Error de conexión" : "Connection error");
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="max-w-2xl mx-auto px-4 py-12">
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: 20 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ duration: 0.6 }}
|
||||
className="text-center mb-10"
|
||||
>
|
||||
<h1
|
||||
className="text-[clamp(2rem,4vw,3rem)] font-extrabold mb-4"
|
||||
style={{ fontFamily: "var(--font-display)", letterSpacing: "-0.02em" }}
|
||||
>
|
||||
{isEs ? "Contacto" : "Contact Us"}
|
||||
</h1>
|
||||
<p className="text-[#a0a0a8]">
|
||||
{isEs
|
||||
? "¿Tienes preguntas, sugerencias o quieres contribuir? Escríbenos."
|
||||
: "Have questions, suggestions, or want to contribute? Reach out to us."}
|
||||
</p>
|
||||
</motion.div>
|
||||
|
||||
<form onSubmit={handleSubmit} className="space-y-5">
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 gap-5">
|
||||
<div>
|
||||
<label className="block text-sm text-[#a0a0a8] mb-1.5">{isEs ? "Nombre" : "Name"} *</label>
|
||||
<input
|
||||
type="text"
|
||||
value={form.name}
|
||||
onChange={(e) => setForm({ ...form, name: e.target.value })}
|
||||
required
|
||||
className="w-full bg-[#12121a] border border-[rgba(255,255,255,0.08)] rounded-lg px-4 py-2.5 text-sm text-[#f5f5f7] focus:outline-none focus:border-[rgba(212,165,116,0.4)] transition-colors"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm text-[#a0a0a8] mb-1.5">Email *</label>
|
||||
<input
|
||||
type="email"
|
||||
value={form.email}
|
||||
onChange={(e) => setForm({ ...form, email: e.target.value })}
|
||||
required
|
||||
className="w-full bg-[#12121a] border border-[rgba(255,255,255,0.08)] rounded-lg px-4 py-2.5 text-sm text-[#f5f5f7] focus:outline-none focus:border-[rgba(212,165,116,0.4)] transition-colors"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm text-[#a0a0a8] mb-1.5">{isEs ? "Asunto" : "Subject"}</label>
|
||||
<input
|
||||
type="text"
|
||||
value={form.subject}
|
||||
onChange={(e) => setForm({ ...form, subject: e.target.value })}
|
||||
className="w-full bg-[#12121a] border border-[rgba(255,255,255,0.08)] rounded-lg px-4 py-2.5 text-sm text-[#f5f5f7] focus:outline-none focus:border-[rgba(212,165,116,0.4)] transition-colors"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm text-[#a0a0a8] mb-1.5">{isEs ? "Mensaje" : "Message"} *</label>
|
||||
<textarea
|
||||
value={form.message}
|
||||
onChange={(e) => setForm({ ...form, message: e.target.value })}
|
||||
required
|
||||
rows={5}
|
||||
className="w-full bg-[#12121a] border border-[rgba(255,255,255,0.08)] rounded-lg px-4 py-2.5 text-sm text-[#f5f5f7] focus:outline-none focus:border-[rgba(212,165,116,0.4)] transition-colors resize-none"
|
||||
/>
|
||||
</div>
|
||||
<button
|
||||
type="submit"
|
||||
disabled={loading}
|
||||
className="w-full px-6 py-3 bg-white text-black text-sm font-semibold rounded-lg hover:bg-gray-200 transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
>
|
||||
{loading
|
||||
? isEs ? "Enviando..." : "Sending..."
|
||||
: isEs ? "Enviar mensaje" : "Send message"}
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,23 +1,35 @@
|
||||
import type { Metadata } from "next";
|
||||
import { useTranslations } from "next-intl";
|
||||
|
||||
export function generateMetadata(): Metadata {
|
||||
return {
|
||||
title: "Donations",
|
||||
};
|
||||
}
|
||||
|
||||
export default function DonatePage() {
|
||||
const t = useTranslations("donate");
|
||||
|
||||
return (
|
||||
<div className="max-w-4xl mx-auto px-4 py-12">
|
||||
<h1 className="text-4xl font-bold mb-6">{t("title")}</h1>
|
||||
<p className="text-lg text-gray-400 mb-12">{t("description")}</p>
|
||||
<h1
|
||||
className="text-[clamp(2rem,4vw,3rem)] font-extrabold mb-6"
|
||||
style={{ fontFamily: "var(--font-display)", letterSpacing: "-0.02em" }}
|
||||
>
|
||||
{t("title")}
|
||||
</h1>
|
||||
<p className="text-lg text-[#a0a0a8] mb-12">{t("description")}</p>
|
||||
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-6 mb-16">
|
||||
<a
|
||||
href="https://patreon.com/projectafterlife"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="block bg-gray-900 rounded-lg p-8 border border-white/5 hover:border-orange-500/50 transition-colors text-center"
|
||||
className="block bg-[#12121a] rounded-lg p-8 border border-[rgba(255,255,255,0.08)] hover:border-[rgba(212,165,116,0.3)] transition-colors text-center"
|
||||
>
|
||||
<h3 className="text-2xl font-bold mb-2 text-orange-400">Patreon</h3>
|
||||
<p className="text-gray-400 text-sm mb-4">Donaciones recurrentes mensuales</p>
|
||||
<span className="inline-block px-6 py-2 bg-orange-600 hover:bg-orange-700 text-white rounded-lg font-medium transition-colors">
|
||||
<h3 className="text-2xl font-bold mb-2 text-[#d4a574]">Patreon</h3>
|
||||
<p className="text-[#a0a0a8] text-sm mb-4">Donaciones recurrentes mensuales</p>
|
||||
<span className="inline-block px-6 py-2 bg-[#d4a574] hover:bg-[#e8c4a0] text-[#0a0a0f] rounded-lg font-medium transition-colors">
|
||||
{t("patreon")}
|
||||
</span>
|
||||
</a>
|
||||
@@ -26,11 +38,11 @@ export default function DonatePage() {
|
||||
href="https://ko-fi.com/projectafterlife"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="block bg-gray-900 rounded-lg p-8 border border-white/5 hover:border-sky-500/50 transition-colors text-center"
|
||||
className="block bg-[#12121a] rounded-lg p-8 border border-[rgba(255,255,255,0.08)] hover:border-[rgba(212,165,116,0.3)] transition-colors text-center"
|
||||
>
|
||||
<h3 className="text-2xl font-bold mb-2 text-sky-400">Ko-fi</h3>
|
||||
<p className="text-gray-400 text-sm mb-4">Donaciones puntuales</p>
|
||||
<span className="inline-block px-6 py-2 bg-sky-600 hover:bg-sky-700 text-white rounded-lg font-medium transition-colors">
|
||||
<h3 className="text-2xl font-bold mb-2 text-[#e8c4a0]">Ko-fi</h3>
|
||||
<p className="text-[#a0a0a8] text-sm mb-4">Donaciones puntuales</p>
|
||||
<span className="inline-block px-6 py-2 bg-[#e8c4a0] hover:bg-[#d4a574] text-[#0a0a0f] rounded-lg font-medium transition-colors">
|
||||
{t("kofi")}
|
||||
</span>
|
||||
</a>
|
||||
|
||||
@@ -1,7 +1,27 @@
|
||||
import type { Metadata } from "next";
|
||||
import { notFound } from "next/navigation";
|
||||
import { getDocumentaryByGameSlug } from "@/lib/api";
|
||||
import { DocumentaryLayout } from "@/components/documentary/DocumentaryLayout";
|
||||
|
||||
export async function generateMetadata({
|
||||
params,
|
||||
}: {
|
||||
params: Promise<{ locale: string; slug: string }>;
|
||||
}): Promise<Metadata> {
|
||||
const { slug, locale } = await params;
|
||||
let documentary;
|
||||
try {
|
||||
documentary = await getDocumentaryByGameSlug(slug, locale);
|
||||
} catch {
|
||||
return {};
|
||||
}
|
||||
if (!documentary) return {};
|
||||
return {
|
||||
title: documentary.title,
|
||||
description: documentary.description?.slice(0, 160),
|
||||
};
|
||||
}
|
||||
|
||||
export default async function DocumentaryPage({
|
||||
params,
|
||||
}: {
|
||||
|
||||
@@ -1,8 +1,47 @@
|
||||
import type { Metadata } from "next";
|
||||
import { notFound } from "next/navigation";
|
||||
import { getGameBySlug } from "@/lib/api";
|
||||
import { GameHeader } from "@/components/game/GameHeader";
|
||||
import { GameInfo } from "@/components/game/GameInfo";
|
||||
import { ScreenshotGallery } from "@/components/game/ScreenshotGallery";
|
||||
import { SocialShare } from "@/components/social/SocialShare";
|
||||
|
||||
const CMS_URL = process.env.STRAPI_URL || process.env.NEXT_PUBLIC_CMS_URL || "http://localhost:1337";
|
||||
|
||||
export async function generateMetadata({
|
||||
params,
|
||||
}: {
|
||||
params: Promise<{ locale: string; slug: string }>;
|
||||
}): Promise<Metadata> {
|
||||
const { slug, locale } = await params;
|
||||
|
||||
let game;
|
||||
try {
|
||||
const res = await getGameBySlug(slug, locale);
|
||||
game = Array.isArray(res.data) ? res.data[0] : res.data;
|
||||
} catch {
|
||||
return {};
|
||||
}
|
||||
|
||||
if (!game) return {};
|
||||
|
||||
const description = game.description?.slice(0, 160);
|
||||
const images = game.coverImage?.url
|
||||
? [
|
||||
{
|
||||
url: game.coverImage.url.startsWith("http")
|
||||
? game.coverImage.url
|
||||
: `${CMS_URL}${game.coverImage.url}`,
|
||||
},
|
||||
]
|
||||
: undefined;
|
||||
|
||||
return {
|
||||
title: game.title,
|
||||
description,
|
||||
openGraph: images ? { images } : undefined,
|
||||
};
|
||||
}
|
||||
|
||||
export default async function GamePage({
|
||||
params,
|
||||
@@ -25,6 +64,9 @@ export default async function GamePage({
|
||||
<>
|
||||
<GameHeader game={game} />
|
||||
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-16">
|
||||
<div className="flex items-center justify-between mb-6">
|
||||
<SocialShare title={game.title} description={game.description?.slice(0, 120)} />
|
||||
</div>
|
||||
<GameInfo game={game} locale={locale} />
|
||||
{game.screenshots && (
|
||||
<ScreenshotGallery screenshots={game.screenshots} />
|
||||
|
||||
259
apps/web/src/app/[locale]/guides/page.tsx
Normal file
259
apps/web/src/app/[locale]/guides/page.tsx
Normal file
@@ -0,0 +1,259 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import { useLocale } from "next-intl";
|
||||
import { motion, AnimatePresence } from "framer-motion";
|
||||
|
||||
interface Guide {
|
||||
game: string;
|
||||
steps: string[];
|
||||
requirements: string[];
|
||||
troubleshooting: string[];
|
||||
}
|
||||
|
||||
const guidesEn: Guide[] = [
|
||||
{
|
||||
game: "NieR Reincarnation",
|
||||
steps: [
|
||||
"Download the patched APK from our Discord or website.",
|
||||
"Install the APK on your Android device (enable Unknown Sources).",
|
||||
"Launch the game and tap 'Start Game'.",
|
||||
"When prompted, enter the server address: play.consultoria-as.com",
|
||||
"Create your character and enjoy!",
|
||||
],
|
||||
requirements: ["Android 8.0 or higher", "~2GB free storage", "Stable internet connection"],
|
||||
troubleshooting: [
|
||||
"If you get 'Connection failed', check your internet and try again.",
|
||||
"Make sure you're using the latest patched APK.",
|
||||
"Clear app cache and retry.",
|
||||
],
|
||||
},
|
||||
{
|
||||
game: "Dragon Ball Online",
|
||||
steps: [
|
||||
"Download the game client from our Discord.",
|
||||
"Extract the archive to a folder on your PC.",
|
||||
"Run Launcher.exe as Administrator.",
|
||||
"The launcher will auto-update if needed.",
|
||||
"Click 'Play' and log in with any username (no password required for now).",
|
||||
],
|
||||
requirements: ["Windows 10/11", "DirectX 11", "4GB RAM", "~5GB free storage"],
|
||||
troubleshooting: [
|
||||
"If the launcher freezes, disable your antivirus temporarily.",
|
||||
"Run both launcher and game as Administrator.",
|
||||
"Ensure Windows Defender is not blocking the executable.",
|
||||
],
|
||||
},
|
||||
{
|
||||
game: "MapleStory 2",
|
||||
steps: [
|
||||
"Download the MS2 client from the community portal.",
|
||||
"Install the game to a path without special characters.",
|
||||
"Run the launcher and let it patch.",
|
||||
"Create an account on the portal website.",
|
||||
"Log in and select a channel to start playing.",
|
||||
],
|
||||
requirements: ["Windows 10/11", "8GB RAM recommended", "NVIDIA/AMD GPU", "~10GB free storage"],
|
||||
troubleshooting: [
|
||||
"If you get a black screen, update your GPU drivers.",
|
||||
"Disable fullscreen optimizations in game executable properties.",
|
||||
"Run in compatibility mode for Windows 8 if crashing.",
|
||||
],
|
||||
},
|
||||
{
|
||||
game: "FusionFall",
|
||||
steps: [
|
||||
"Download the OpenFusion client for your platform.",
|
||||
"Extract and run OpenFusion.exe.",
|
||||
"The server should be pre-configured.",
|
||||
"Create a character and enter the Cartoon Network universe!",
|
||||
],
|
||||
requirements: ["Windows 10/11 or Linux (Wine)", "2GB RAM", "~2GB free storage"],
|
||||
troubleshooting: [
|
||||
"If textures are missing, verify file integrity via Discord.",
|
||||
"Linux users may need to install latest Wine/Proton.",
|
||||
],
|
||||
},
|
||||
];
|
||||
|
||||
const guidesEs: Guide[] = [
|
||||
{
|
||||
game: "NieR Reincarnation",
|
||||
steps: [
|
||||
"Descarga el APK parcheado desde nuestro Discord o sitio web.",
|
||||
"Instala el APK en tu dispositivo Android (activa Fuentes Desconocidas).",
|
||||
"Abre el juego y toca 'Comenzar'.",
|
||||
"Cuando se solicite, introduce la dirección del servidor: play.consultoria-as.com",
|
||||
"¡Crea tu personaje y disfruta!",
|
||||
],
|
||||
requirements: ["Android 8.0 o superior", "~2GB de almacenamiento libre", "Conexión a internet estable"],
|
||||
troubleshooting: [
|
||||
"Si aparece 'Conexión fallida', verifica tu internet e inténtalo de nuevo.",
|
||||
"Asegúrate de usar el APK parcheado más reciente.",
|
||||
"Limpia la caché de la app e inténtalo de nuevo.",
|
||||
],
|
||||
},
|
||||
{
|
||||
game: "Dragon Ball Online",
|
||||
steps: [
|
||||
"Descarga el cliente del juego desde nuestro Discord.",
|
||||
"Extrae el archivo a una carpeta en tu PC.",
|
||||
"Ejecuta Launcher.exe como Administrador.",
|
||||
"El launcher se actualizará automáticamente si es necesario.",
|
||||
"Haz clic en 'Jugar' e inicia sesión con cualquier usuario (sin contraseña por ahora).",
|
||||
],
|
||||
requirements: ["Windows 10/11", "DirectX 11", "4GB RAM", "~5GB de almacenamiento libre"],
|
||||
troubleshooting: [
|
||||
"Si el launcher se congela, desactiva temporalmente tu antivirus.",
|
||||
"Ejecuta tanto el launcher como el juego como Administrador.",
|
||||
"Asegúrate de que Windows Defender no bloquee el ejecutable.",
|
||||
],
|
||||
},
|
||||
{
|
||||
game: "MapleStory 2",
|
||||
steps: [
|
||||
"Descarga el cliente de MS2 desde el portal de la comunidad.",
|
||||
"Instala el juego en una ruta sin caracteres especiales.",
|
||||
"Ejecuta el launcher y déjalo parchear.",
|
||||
"Crea una cuenta en el sitio web del portal.",
|
||||
"Inicia sesión y selecciona un canal para empezar a jugar.",
|
||||
],
|
||||
requirements: ["Windows 10/11", "8GB RAM recomendados", "GPU NVIDIA/AMD", "~10GB de almacenamiento libre"],
|
||||
troubleshooting: [
|
||||
"Si aparece pantalla negra, actualiza los drivers de tu GPU.",
|
||||
"Desactiva las optimizaciones de pantalla completa en propiedades del ejecutable.",
|
||||
"Ejecuta en modo compatibilidad para Windows 8 si se cierra.",
|
||||
],
|
||||
},
|
||||
{
|
||||
game: "FusionFall",
|
||||
steps: [
|
||||
"Descarga el cliente de OpenFusion para tu plataforma.",
|
||||
"Extrae y ejecuta OpenFusion.exe.",
|
||||
"El servidor debería estar preconfigurado.",
|
||||
"¡Crea un personaje y entra al universo de Cartoon Network!",
|
||||
],
|
||||
requirements: ["Windows 10/11 o Linux (Wine)", "2GB RAM", "~2GB de almacenamiento libre"],
|
||||
troubleshooting: [
|
||||
"Si faltan texturas, verifica la integridad de archivos vía Discord.",
|
||||
"Usuarios de Linux pueden necesitar Wine/Proton más reciente.",
|
||||
],
|
||||
},
|
||||
];
|
||||
|
||||
function GuideCard({ guide, index }: { guide: Guide; index: number }) {
|
||||
const [open, setOpen] = useState(index === 0);
|
||||
|
||||
return (
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: 20 }}
|
||||
whileInView={{ opacity: 1, y: 0 }}
|
||||
viewport={{ once: true }}
|
||||
transition={{ delay: index * 0.1, duration: 0.5 }}
|
||||
className="rounded-2xl border border-[rgba(255,255,255,0.08)] bg-[#12121a] overflow-hidden"
|
||||
>
|
||||
<button
|
||||
onClick={() => setOpen(!open)}
|
||||
className="w-full flex items-center justify-between px-6 py-5 text-left hover:bg-[rgba(255,255,255,0.03)] transition-colors"
|
||||
>
|
||||
<h3 className="text-lg font-semibold text-[#f5f5f7]">{guide.game}</h3>
|
||||
<svg
|
||||
className={`w-5 h-5 text-[#6b6b75] transition-transform duration-300 ${open ? "rotate-180" : ""}`}
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth="2"
|
||||
strokeLinecap="round"
|
||||
>
|
||||
<polyline points="6 9 12 15 18 9" />
|
||||
</svg>
|
||||
</button>
|
||||
|
||||
<AnimatePresence>
|
||||
{open && (
|
||||
<motion.div
|
||||
initial={{ height: 0, opacity: 0 }}
|
||||
animate={{ height: "auto", opacity: 1 }}
|
||||
exit={{ height: 0, opacity: 0 }}
|
||||
transition={{ duration: 0.3 }}
|
||||
className="overflow-hidden"
|
||||
>
|
||||
<div className="px-6 pb-6 space-y-6">
|
||||
<div>
|
||||
<h4 className="text-sm font-semibold text-[#d4a574] uppercase tracking-wider mb-3">Steps</h4>
|
||||
<ol className="space-y-2">
|
||||
{guide.steps.map((step, i) => (
|
||||
<li key={i} className="flex items-start gap-3 text-sm text-[#a0a0a8]">
|
||||
<span className="flex-shrink-0 w-5 h-5 rounded-full bg-[rgba(212,165,116,0.15)] text-[#d4a574] text-xs font-bold flex items-center justify-center mt-0.5">
|
||||
{i + 1}
|
||||
</span>
|
||||
{step}
|
||||
</li>
|
||||
))}
|
||||
</ol>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<h4 className="text-sm font-semibold text-blue-400 uppercase tracking-wider mb-3">Requirements</h4>
|
||||
<ul className="flex flex-wrap gap-2">
|
||||
{guide.requirements.map((req, i) => (
|
||||
<li key={i} className="px-3 py-1 rounded-full bg-blue-500/10 border border-blue-500/20 text-xs text-blue-300">
|
||||
{req}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<h4 className="text-sm font-semibold text-red-400 uppercase tracking-wider mb-3">Troubleshooting</h4>
|
||||
<ul className="space-y-2">
|
||||
{guide.troubleshooting.map((tip, i) => (
|
||||
<li key={i} className="flex items-start gap-2 text-sm text-[#a0a0a8]">
|
||||
<span className="text-red-400 mt-0.5">•</span>
|
||||
{tip}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
</motion.div>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
</motion.div>
|
||||
);
|
||||
}
|
||||
|
||||
export default function GuidesPage() {
|
||||
const locale = useLocale();
|
||||
const isEs = locale === "es";
|
||||
const guides = isEs ? guidesEs : guidesEn;
|
||||
|
||||
return (
|
||||
<div className="max-w-4xl mx-auto px-4 py-12">
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: 20 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ duration: 0.6 }}
|
||||
className="text-center mb-12"
|
||||
>
|
||||
<h1
|
||||
className="text-[clamp(2rem,4vw,3.5rem)] font-extrabold mb-4 tracking-tight"
|
||||
style={{ fontFamily: "var(--font-display)", letterSpacing: "-0.02em" }}
|
||||
>
|
||||
{isEs ? "Guías de Conexión" : "Connection Guides"}
|
||||
</h1>
|
||||
<p className="text-lg text-[#a0a0a8] max-w-2xl mx-auto leading-relaxed">
|
||||
{isEs
|
||||
? "Todo lo que necesitas saber para conectarte a nuestros servidores privados."
|
||||
: "Everything you need to know to connect to our private servers."}
|
||||
</p>
|
||||
</motion.div>
|
||||
|
||||
<div className="space-y-4">
|
||||
{guides.map((guide, i) => (
|
||||
<GuideCard key={guide.game} guide={guide} index={i} />
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,21 +1,22 @@
|
||||
import type { Metadata } from "next";
|
||||
import { Playfair_Display, Source_Serif_4, DM_Sans } from "next/font/google";
|
||||
import { Syne, DM_Sans } from "next/font/google";
|
||||
import { NextIntlClientProvider } from "next-intl";
|
||||
import { getMessages } from "next-intl/server";
|
||||
import { notFound } from "next/navigation";
|
||||
import { routing } from "@/i18n/routing";
|
||||
import { Navbar } from "@/components/layout/Navbar";
|
||||
import { Footer } from "@/components/layout/Footer";
|
||||
import { AuthProvider } from "@/components/auth/AuthProvider";
|
||||
import { ToastProvider } from "@/hooks/useToast";
|
||||
import { CookieConsent } from "@/components/ui/CookieConsent";
|
||||
import { Analytics } from "@vercel/analytics/react";
|
||||
import { ThemeProvider } from "@/components/theme/ThemeProvider";
|
||||
import { Breadcrumb } from "@/components/navigation/Breadcrumb";
|
||||
import { ScrollToTop } from "@/components/ui/ScrollToTop";
|
||||
|
||||
const playfair = Playfair_Display({
|
||||
const syne = Syne({
|
||||
subsets: ["latin"],
|
||||
variable: "--font-playfair",
|
||||
display: "swap",
|
||||
});
|
||||
|
||||
const sourceSerif = Source_Serif_4({
|
||||
subsets: ["latin", "latin-ext"],
|
||||
variable: "--font-source-serif",
|
||||
variable: "--font-syne",
|
||||
display: "swap",
|
||||
});
|
||||
|
||||
@@ -50,14 +51,24 @@ export default async function LocaleLayout({
|
||||
return (
|
||||
<html
|
||||
lang={locale}
|
||||
className={`${playfair.variable} ${sourceSerif.variable} ${dmSans.variable}`}
|
||||
className={`${syne.variable} ${dmSans.variable}`}
|
||||
>
|
||||
<body className="bg-gray-950 text-white antialiased min-h-screen flex flex-col font-sans">
|
||||
<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>
|
||||
);
|
||||
|
||||
22
apps/web/src/app/[locale]/login/page.tsx
Normal file
22
apps/web/src/app/[locale]/login/page.tsx
Normal file
@@ -0,0 +1,22 @@
|
||||
import { getMessages } from "next-intl/server";
|
||||
import { NextIntlClientProvider } from "next-intl";
|
||||
import { LoginForm } from "@/components/auth/LoginForm";
|
||||
|
||||
export default async function LoginPage({
|
||||
params,
|
||||
}: {
|
||||
params: Promise<{ locale: string }>;
|
||||
}) {
|
||||
const { locale } = await params;
|
||||
const messages = await getMessages();
|
||||
|
||||
return (
|
||||
<NextIntlClientProvider messages={messages} locale={locale}>
|
||||
<div className="min-h-screen flex items-center justify-center bg-[#0a0a0f] px-4">
|
||||
<div className="max-w-md w-full">
|
||||
<LoginForm />
|
||||
</div>
|
||||
</div>
|
||||
</NextIntlClientProvider>
|
||||
);
|
||||
}
|
||||
37
apps/web/src/app/[locale]/not-found.tsx
Normal file
37
apps/web/src/app/[locale]/not-found.tsx
Normal file
@@ -0,0 +1,37 @@
|
||||
"use client";
|
||||
|
||||
import Link from "next/link";
|
||||
import { useLocale } from "next-intl";
|
||||
import { motion } from "framer-motion";
|
||||
|
||||
export default function NotFound() {
|
||||
const locale = useLocale();
|
||||
const isEs = locale === "es";
|
||||
|
||||
return (
|
||||
<div className="min-h-[70vh] flex items-center justify-center px-4">
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: 20 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ duration: 0.6 }}
|
||||
className="text-center max-w-lg"
|
||||
>
|
||||
<h1 className="text-8xl font-bold text-[#1a1a24] mb-4">404</h1>
|
||||
<h2 className="text-2xl font-semibold text-[#f5f5f7] mb-4">
|
||||
{isEs ? "Página no encontrada" : "Page Not Found"}
|
||||
</h2>
|
||||
<p className="text-[#a0a0a8] mb-8 leading-relaxed">
|
||||
{isEs
|
||||
? "La página que buscas no existe o ha sido movida."
|
||||
: "The page you are looking for does not exist or has been moved."}
|
||||
</p>
|
||||
<Link
|
||||
href={`/${locale}`}
|
||||
className="inline-block px-8 py-3 bg-[#d4a574] text-[#0a0a0f] font-semibold rounded-xl hover:bg-[#e8c4a0] transition-colors"
|
||||
>
|
||||
{isEs ? "Volver al inicio" : "Go back home"}
|
||||
</Link>
|
||||
</motion.div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,7 +1,10 @@
|
||||
import { getGames } from "@/lib/api";
|
||||
import { HeroSection } from "@/components/home/HeroSection";
|
||||
import { LatestGames } from "@/components/home/LatestGames";
|
||||
import { DonationCTA } from "@/components/home/DonationCTA";
|
||||
import { PillarsSection } from "@/components/home/PillarsSection";
|
||||
import { DocumentaryExperienceSection } from "@/components/home/DocumentaryExperienceSection";
|
||||
import { TechStackSection } from "@/components/home/TechStackSection";
|
||||
import { GamesShowcaseSection } from "@/components/home/GamesShowcaseSection";
|
||||
import { DonationSection } from "@/components/home/DonationSection";
|
||||
|
||||
export default async function HomePage({
|
||||
params,
|
||||
@@ -21,8 +24,11 @@ export default async function HomePage({
|
||||
return (
|
||||
<>
|
||||
<HeroSection />
|
||||
<LatestGames games={games} locale={locale} />
|
||||
<DonationCTA />
|
||||
<PillarsSection />
|
||||
<DocumentaryExperienceSection />
|
||||
<TechStackSection />
|
||||
<GamesShowcaseSection games={games} />
|
||||
<DonationSection />
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
30
apps/web/src/app/[locale]/profile/page.tsx
Normal file
30
apps/web/src/app/[locale]/profile/page.tsx
Normal file
@@ -0,0 +1,30 @@
|
||||
import { redirect } from "next/navigation";
|
||||
import { auth } from "@/lib/auth";
|
||||
import { getMessages } from "next-intl/server";
|
||||
import { NextIntlClientProvider } from "next-intl";
|
||||
import { ProfileCard } from "@/components/auth/ProfileCard";
|
||||
|
||||
export default async function ProfilePage({
|
||||
params,
|
||||
}: {
|
||||
params: Promise<{ locale: string }>;
|
||||
}) {
|
||||
const { locale } = await params;
|
||||
const session = await auth();
|
||||
|
||||
if (!session?.user) {
|
||||
redirect(`/${locale}/login`);
|
||||
}
|
||||
|
||||
const messages = await getMessages();
|
||||
|
||||
return (
|
||||
<NextIntlClientProvider messages={messages} locale={locale}>
|
||||
<div className="min-h-screen bg-[#0a0a0f] px-4 py-12">
|
||||
<div className="max-w-2xl mx-auto">
|
||||
<ProfileCard user={session.user} />
|
||||
</div>
|
||||
</div>
|
||||
</NextIntlClientProvider>
|
||||
);
|
||||
}
|
||||
79
apps/web/src/app/[locale]/server-status/page.tsx
Normal file
79
apps/web/src/app/[locale]/server-status/page.tsx
Normal file
@@ -0,0 +1,79 @@
|
||||
import { getMessages } from "next-intl/server";
|
||||
import { NextIntlClientProvider } from "next-intl";
|
||||
import { ServerStatusGrid } from "@/components/admin/ServerStatusGrid";
|
||||
import { HealthBanner } from "@/components/admin/HealthBanner";
|
||||
import type { Server } from "@/components/admin/ServerStatusGrid";
|
||||
|
||||
export const metadata = {
|
||||
title: "Server Status",
|
||||
};
|
||||
|
||||
export default async function ServerStatusPage({
|
||||
params,
|
||||
}: {
|
||||
params: Promise<{ locale: string }>;
|
||||
}) {
|
||||
const { locale } = await params;
|
||||
const messages = await getMessages();
|
||||
|
||||
const servers = [
|
||||
{
|
||||
name: "NieR Reincarnation",
|
||||
status: "online",
|
||||
ip: process.env.NEXT_PUBLIC_NIER_IP || "play.consultoria-as.com",
|
||||
ports: "80 / 443 (HTTP/2 gRPC)",
|
||||
type: "Mobile RPG",
|
||||
vm: "vm-nier (10.0.0.70)",
|
||||
},
|
||||
{
|
||||
name: "Dragon Ball Online",
|
||||
status: "maintenance",
|
||||
ip: process.env.NEXT_PUBLIC_DBO_IP || "play.consultoria-as.com",
|
||||
ports: "22000-22010",
|
||||
type: "MMORPG",
|
||||
vm: "vm-dbo (10.0.0.80)",
|
||||
},
|
||||
{
|
||||
name: "MapleStory 2",
|
||||
status: "online",
|
||||
ip: process.env.NEXT_PUBLIC_MAPLE2_IP || "play.consultoria-as.com",
|
||||
ports: "20001",
|
||||
type: "MMORPG",
|
||||
vm: "vm-maple2 (10.0.0.40)",
|
||||
},
|
||||
{
|
||||
name: "FusionFall",
|
||||
status: "online",
|
||||
ip: process.env.NEXT_PUBLIC_FUSIONFALL_IP || "play.consultoria-as.com",
|
||||
ports: "23000",
|
||||
type: "MMORPG",
|
||||
vm: "vm-fusionfall (10.0.0.30)",
|
||||
},
|
||||
] satisfies Server[];
|
||||
|
||||
return (
|
||||
<NextIntlClientProvider messages={messages} locale={locale}>
|
||||
<div className="min-h-screen bg-[#0a0a0f] px-4 py-12">
|
||||
<div className="max-w-6xl mx-auto">
|
||||
<div className="mb-10">
|
||||
<h1
|
||||
className="text-[clamp(1.75rem,3vw,2.5rem)] font-display font-bold text-[#f5f5f7]"
|
||||
style={{ letterSpacing: "-0.02em" }}
|
||||
>
|
||||
{locale === "es" ? "Estado de Servidores" : "Server Status"}
|
||||
</h1>
|
||||
<p className="mt-3 text-[#a0a0a8] max-w-2xl">
|
||||
{locale === "es"
|
||||
? "Información de conexión para todos los servidores de juego de Project Afterlife."
|
||||
: "Connection information for all Project Afterlife game servers."}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<HealthBanner locale={locale} />
|
||||
|
||||
<ServerStatusGrid servers={servers} />
|
||||
</div>
|
||||
</div>
|
||||
</NextIntlClientProvider>
|
||||
);
|
||||
}
|
||||
22
apps/web/src/app/[locale]/template.tsx
Normal file
22
apps/web/src/app/[locale]/template.tsx
Normal file
@@ -0,0 +1,22 @@
|
||||
"use client";
|
||||
|
||||
import { motion, AnimatePresence } from "framer-motion";
|
||||
import { usePathname } from "next/navigation";
|
||||
|
||||
export default function Template({ children }: { children: React.ReactNode }) {
|
||||
const pathname = usePathname();
|
||||
|
||||
return (
|
||||
<AnimatePresence mode="wait">
|
||||
<motion.div
|
||||
key={pathname}
|
||||
initial={{ opacity: 0, y: 8 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
exit={{ opacity: 0, y: -8 }}
|
||||
transition={{ duration: 0.25, ease: "easeInOut" }}
|
||||
>
|
||||
{children}
|
||||
</motion.div>
|
||||
</AnimatePresence>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user