feat: add AFC Store with MercadoPago purchases and prize redemption
Some checks failed
Deploy / deploy (push) Has been cancelled

Players can now buy AfterCoin with real money (MercadoPago Checkout Pro,
$15 MXN/AFC) and redeem AFC for gift cards or cash withdrawals. Admin
fulfills redemptions manually.

- Bridge: payments + redemptions tables, CRUD routes, PATCH auth
- Next.js API: verify-disk, balance, create-preference, webhook (idempotent
  minting with HMAC signature verification), redeem, payment/redemption history
- Frontend: hub, buy flow (4 packages + custom), redeem flow (gift cards +
  cash out), success/failure/pending pages, history with tabs, 8 components
- i18n: full English + Spanish translations
- Infra: nginx /api/afc/ → Next.js, docker-compose env vars, .env.example

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
consultoria-as
2026-02-26 02:26:13 +00:00
parent 7dc1d2e0e5
commit a76d513659
38 changed files with 2142 additions and 5 deletions

View File

@@ -12,10 +12,12 @@
"@afterlife/shared": "*",
"framer-motion": "^12.34.3",
"howler": "^2.2.4",
"mercadopago": "^2.12.0",
"next": "^15",
"next-intl": "^4.8.3",
"react": "^19",
"react-dom": "^19"
"react-dom": "^19",
"uuid": "^13.0.0"
},
"devDependencies": {
"@tailwindcss/postcss": "^4",

View File

@@ -0,0 +1,33 @@
"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

@@ -0,0 +1,138 @@
"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

@@ -0,0 +1,39 @@
"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

@@ -0,0 +1,39 @@
"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

@@ -0,0 +1,101 @@
"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

@@ -0,0 +1,90 @@
"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

@@ -0,0 +1,184 @@
"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

@@ -0,0 +1,16 @@
import { NextRequest, NextResponse } from "next/server";
import { bridgeGet } from "../lib/bridge";
export async function GET(req: NextRequest) {
const diskId = req.nextUrl.searchParams.get("diskId");
if (!diskId) {
return NextResponse.json({ error: "diskId is required" }, { status: 400 });
}
try {
const data = await bridgeGet(`/api/balance/${diskId}`);
return NextResponse.json({ balance: data.balance });
} catch (e: unknown) {
const message = e instanceof Error ? e.message : "Unknown error";
return NextResponse.json({ error: message }, { status: 500 });
}
}

View File

@@ -0,0 +1,71 @@
import { NextRequest, NextResponse } from "next/server";
import { randomUUID } from "crypto";
import { preferenceClient } from "../lib/mercadopago";
import { bridgePost, bridgePatch } from "../lib/bridge";
const PRICE_MXN = Number(process.env.AFC_PRICE_MXN) || 15;
const BASE_URL = process.env.NEXT_PUBLIC_SITE_URL || "http://localhost:3000";
export async function POST(req: NextRequest) {
try {
const { diskId, amountAfc } = await req.json();
if (!diskId || !amountAfc || amountAfc < 1) {
return NextResponse.json(
{ error: "diskId and amountAfc (>=1) required" },
{ status: 400 }
);
}
const amountMxn = amountAfc * PRICE_MXN;
const paymentId = randomUUID();
// Create payment record in bridge
await bridgePost("/api/payments", {
id: paymentId,
diskId,
amountAfc,
amountMxn,
});
// Create MercadoPago preference
const preference = await preferenceClient.create({
body: {
items: [
{
id: paymentId,
title: `${amountAfc} AfterCoin (AFC)`,
quantity: 1,
unit_price: amountMxn,
currency_id: "MXN",
},
],
external_reference: paymentId,
back_urls: {
success: `${BASE_URL}/afc/buy/success?payment_id=${paymentId}`,
failure: `${BASE_URL}/afc/buy/failure?payment_id=${paymentId}`,
pending: `${BASE_URL}/afc/buy/pending?payment_id=${paymentId}`,
},
auto_return: "approved",
notification_url:
process.env.MERCADOPAGO_WEBHOOK_URL ||
`${BASE_URL}/api/afc/webhook`,
},
});
// Store the MP preference ID
await bridgePatch(`/api/payments/${paymentId}`, {
mp_preference_id: preference.id,
});
return NextResponse.json({
paymentId,
initPoint: preference.init_point,
sandboxInitPoint: preference.sandbox_init_point,
});
} catch (e: unknown) {
const message = e instanceof Error ? e.message : "Unknown error";
console.error("create-preference error:", e);
return NextResponse.json({ error: message }, { status: 500 });
}
}

View File

@@ -0,0 +1,50 @@
const BRIDGE_URL = process.env.AFC_BRIDGE_URL || "http://afc-bridge:3001";
const BRIDGE_SECRET = process.env.AFC_BRIDGE_SECRET || "";
export async function bridgeGet(path: string) {
const res = await fetch(`${BRIDGE_URL}${path}`, {
cache: "no-store",
});
if (!res.ok) {
const body = await res.json().catch(() => ({}));
throw new Error(body.error || `Bridge error: ${res.status}`);
}
return res.json();
}
export async function bridgePost(path: string, body: Record<string, unknown>) {
const res = await fetch(`${BRIDGE_URL}${path}`, {
method: "POST",
headers: {
"Content-Type": "application/json",
"x-bridge-secret": BRIDGE_SECRET,
},
body: JSON.stringify(body),
cache: "no-store",
});
if (!res.ok) {
const data = await res.json().catch(() => ({}));
throw new Error(data.error || `Bridge error: ${res.status}`);
}
return res.json();
}
export async function bridgePatch(
path: string,
body: Record<string, unknown>
) {
const res = await fetch(`${BRIDGE_URL}${path}`, {
method: "PATCH",
headers: {
"Content-Type": "application/json",
"x-bridge-secret": BRIDGE_SECRET,
},
body: JSON.stringify(body),
cache: "no-store",
});
if (!res.ok) {
const data = await res.json().catch(() => ({}));
throw new Error(data.error || `Bridge error: ${res.status}`);
}
return res.json();
}

View File

@@ -0,0 +1,9 @@
import { MercadoPagoConfig, Preference, Payment } from "mercadopago";
const ACCESS_TOKEN = process.env.MERCADOPAGO_ACCESS_TOKEN || "";
const client = new MercadoPagoConfig({ accessToken: ACCESS_TOKEN });
export const preferenceClient = new Preference(client);
export const paymentClient = new Payment(client);
export { client as mpClient };

View File

@@ -0,0 +1,16 @@
import { NextRequest, NextResponse } from "next/server";
import { bridgeGet } from "../lib/bridge";
export async function GET(req: NextRequest) {
const diskId = req.nextUrl.searchParams.get("diskId");
if (!diskId) {
return NextResponse.json({ error: "diskId is required" }, { status: 400 });
}
try {
const data = await bridgeGet(`/api/payments/history/${diskId}`);
return NextResponse.json(data);
} catch (e: unknown) {
const message = e instanceof Error ? e.message : "Unknown error";
return NextResponse.json({ error: message }, { status: 500 });
}
}

View File

@@ -0,0 +1,49 @@
import { NextRequest, NextResponse } from "next/server";
import { randomUUID } from "crypto";
import { bridgePost } from "../lib/bridge";
export async function POST(req: NextRequest) {
try {
const { diskId, amountAfc, prizeType, prizeDetail, deliveryInfo } =
await req.json();
if (!diskId || !amountAfc || !prizeType || !prizeDetail) {
return NextResponse.json(
{
error:
"diskId, amountAfc, prizeType, and prizeDetail are required",
},
{ status: 400 }
);
}
// Burn the AFC via withdraw (burn) endpoint
const burnResult = await bridgePost("/api/withdraw", {
diskId,
amount: amountAfc,
});
const redemptionId = randomUUID();
// Create redemption record
await bridgePost("/api/redemptions", {
id: redemptionId,
diskId,
amountAfc,
prizeType,
prizeDetail,
deliveryInfo: deliveryInfo || "",
burnTxHash: burnResult.txHash,
});
return NextResponse.json({
redemptionId,
burnTxHash: burnResult.txHash,
balance: burnResult.balance,
});
} catch (e: unknown) {
const message = e instanceof Error ? e.message : "Unknown error";
console.error("redeem error:", e);
return NextResponse.json({ error: message }, { status: 500 });
}
}

View File

@@ -0,0 +1,16 @@
import { NextRequest, NextResponse } from "next/server";
import { bridgeGet } from "../lib/bridge";
export async function GET(req: NextRequest) {
const diskId = req.nextUrl.searchParams.get("diskId");
if (!diskId) {
return NextResponse.json({ error: "diskId is required" }, { status: 400 });
}
try {
const data = await bridgeGet(`/api/redemptions/history/${diskId}`);
return NextResponse.json(data);
} catch (e: unknown) {
const message = e instanceof Error ? e.message : "Unknown error";
return NextResponse.json({ error: message }, { status: 500 });
}
}

View File

@@ -0,0 +1,15 @@
import { NextRequest, NextResponse } from "next/server";
import { bridgeGet } from "../lib/bridge";
export async function GET(req: NextRequest) {
const diskId = req.nextUrl.searchParams.get("diskId");
if (!diskId) {
return NextResponse.json({ error: "diskId is required" }, { status: 400 });
}
try {
const data = await bridgeGet(`/api/wallet/${diskId}`);
return NextResponse.json({ valid: true, name: data.name || null });
} catch {
return NextResponse.json({ valid: false, name: null });
}
}

View File

@@ -0,0 +1,113 @@
import { NextRequest, NextResponse } from "next/server";
import { createHmac } from "crypto";
import { paymentClient } from "../lib/mercadopago";
import { bridgeGet, bridgePost, bridgePatch } from "../lib/bridge";
const WEBHOOK_SECRET = process.env.MERCADOPAGO_WEBHOOK_SECRET || "";
function verifySignature(req: NextRequest): boolean {
if (!WEBHOOK_SECRET) return true; // Skip in dev if no secret configured
const xSignature = req.headers.get("x-signature") || "";
const xRequestId = req.headers.get("x-request-id") || "";
// MercadoPago v2 signature: ts=xxx,v1=xxx
const parts = Object.fromEntries(
xSignature.split(",").map((p) => {
const [k, ...v] = p.trim().split("=");
return [k, v.join("=")];
})
);
const dataId = new URL(req.url).searchParams.get("data.id") || "";
const manifest = `id:${dataId};request-id:${xRequestId};ts:${parts.ts};`;
const hmac = createHmac("sha256", WEBHOOK_SECRET)
.update(manifest)
.digest("hex");
return hmac === parts.v1;
}
export async function POST(req: NextRequest) {
try {
const body = await req.text();
if (!verifySignature(req)) {
return NextResponse.json(
{ error: "Invalid signature" },
{ status: 401 }
);
}
const data = JSON.parse(body);
// Only process payment notifications
if (data.type !== "payment") {
return NextResponse.json({ ok: true });
}
const mpPaymentId = String(data.data?.id);
if (!mpPaymentId) {
return NextResponse.json({ ok: true });
}
// Fetch payment details from MercadoPago
const mpPayment = await paymentClient.get({ id: mpPaymentId });
if (mpPayment.status !== "approved") {
// Update our record status but don't mint
const externalRef = mpPayment.external_reference;
if (externalRef) {
await bridgePatch(`/api/payments/${externalRef}`, {
status: mpPayment.status,
mp_payment_id: mpPaymentId,
});
}
return NextResponse.json({ ok: true });
}
const paymentId = mpPayment.external_reference;
if (!paymentId) {
console.error("webhook: no external_reference in MP payment");
return NextResponse.json({ ok: true });
}
// Get our payment record
let payment;
try {
payment = (await bridgeGet(`/api/payments/${paymentId}`)).payment;
} catch {
console.error("webhook: payment not found:", paymentId);
return NextResponse.json({ ok: true });
}
// Idempotency: if already minted, skip
if (payment.status === "completed" && payment.tx_hash) {
return NextResponse.json({ ok: true, already_processed: true });
}
// Mint AFC via bridge deposit endpoint
const mintResult = await bridgePost("/api/deposit", {
diskId: payment.disk_id,
amount: payment.amount_afc,
});
// Update payment record as completed
await bridgePatch(`/api/payments/${paymentId}`, {
status: "completed",
mp_payment_id: mpPaymentId,
tx_hash: mintResult.txHash,
});
console.log(
`webhook: minted ${payment.amount_afc} AFC for disk ${payment.disk_id}, tx: ${mintResult.txHash}`
);
return NextResponse.json({ ok: true, minted: true });
} catch (e: unknown) {
const message = e instanceof Error ? e.message : "Unknown error";
console.error("webhook error:", e);
// Always return 200 to MP so it doesn't retry endlessly
return NextResponse.json({ ok: true, error: message });
}
}

View File

@@ -0,0 +1,49 @@
"use client";
interface AfcPackageCardProps {
amount: number;
priceMxn: number;
popular?: boolean;
loading?: boolean;
onSelect: () => void;
}
export function AfcPackageCard({
amount,
priceMxn,
popular,
loading,
onSelect,
}: AfcPackageCardProps) {
return (
<button
onClick={onSelect}
disabled={loading}
className={`relative group block w-full text-left bg-gray-900 rounded-2xl p-6 border transition-all duration-200 hover:scale-[1.02] active:scale-[0.98] disabled:opacity-50 ${
popular
? "border-amber-500/50 shadow-lg shadow-amber-500/10"
: "border-white/5 hover:border-amber-500/30"
}`}
>
{popular && (
<span className="absolute -top-3 left-1/2 -translate-x-1/2 bg-amber-500 text-black text-xs font-bold px-3 py-1 rounded-full">
POPULAR
</span>
)}
<div className="flex items-center gap-4">
<div className="w-14 h-14 rounded-full bg-amber-500/10 border border-amber-500/30 flex items-center justify-center shrink-0 group-hover:bg-amber-500/20 transition-colors">
<span className="text-xl font-bold text-amber-400">{amount}</span>
</div>
<div className="flex-1">
<p className="text-white font-semibold text-lg">{amount} AFC</p>
<p className="text-gray-500 text-sm">AfterCoin</p>
</div>
<div className="text-right">
<p className="text-2xl font-bold text-white">${priceMxn}</p>
<p className="text-xs text-gray-500">MXN</p>
</div>
</div>
</button>
);
}

View File

@@ -0,0 +1,36 @@
"use client";
import { useTranslations } from "next-intl";
interface BalanceDisplayProps {
balance: number | null;
compact?: boolean;
}
export function BalanceDisplay({ balance, compact }: BalanceDisplayProps) {
const t = useTranslations("afc");
if (balance === null) return null;
if (compact) {
return (
<span className="inline-flex items-center gap-1.5 text-amber-400 font-semibold">
<span className="w-4 h-4 rounded-full bg-amber-500/20 border border-amber-500/40 inline-flex items-center justify-center text-[10px]">
A
</span>
{balance} AFC
</span>
);
}
return (
<div className="bg-gradient-to-br from-amber-500/10 to-amber-600/5 border border-amber-500/20 rounded-2xl p-6 text-center">
<div className="w-16 h-16 mx-auto mb-3 rounded-full bg-amber-500/15 border-2 border-amber-500/30 flex items-center justify-center">
<span className="text-2xl font-bold text-amber-400">A</span>
</div>
<p className="text-sm text-gray-400 mb-1">{t("your_balance")}</p>
<p className="text-4xl font-bold text-white">{balance}</p>
<p className="text-sm text-amber-400 mt-1">AfterCoin</p>
</div>
);
}

View File

@@ -0,0 +1,77 @@
"use client";
import { useTranslations } from "next-intl";
interface DiskIdInputProps {
diskId: string;
onChange: (value: string) => void;
onVerify: () => void;
loading: boolean;
verified: boolean;
playerName: string | null;
error: string | null;
onClear?: () => void;
}
export function DiskIdInput({
diskId,
onChange,
onVerify,
loading,
verified,
playerName,
error,
onClear,
}: DiskIdInputProps) {
const t = useTranslations("afc");
if (verified && playerName) {
return (
<div className="flex items-center gap-4 bg-amber-500/10 border border-amber-500/30 rounded-xl px-5 py-4">
<div className="w-10 h-10 rounded-full bg-amber-500/20 flex items-center justify-center text-amber-400 font-bold text-lg shrink-0">
{playerName.charAt(0).toUpperCase()}
</div>
<div className="flex-1 min-w-0">
<p className="text-sm text-amber-400/70">{t("disk_id")}: {diskId}</p>
<p className="text-white font-semibold truncate">{playerName}</p>
</div>
{onClear && (
<button
onClick={onClear}
className="text-xs text-gray-500 hover:text-gray-300 transition-colors"
>
{t("change")}
</button>
)}
</div>
);
}
return (
<div className="space-y-3">
<label className="block text-sm font-medium text-gray-400">
{t("enter_disk_id")}
</label>
<div className="flex gap-3">
<input
type="text"
value={diskId}
onChange={(e) => onChange(e.target.value)}
onKeyDown={(e) => e.key === "Enter" && onVerify()}
placeholder={t("disk_id_placeholder")}
className="flex-1 bg-gray-900 border border-white/10 rounded-xl px-4 py-3 text-white placeholder:text-gray-600 focus:outline-none focus:border-amber-500/50 focus:ring-1 focus:ring-amber-500/25 transition-all"
/>
<button
onClick={onVerify}
disabled={loading || !diskId.trim()}
className="px-6 py-3 bg-amber-500 hover:bg-amber-400 disabled:bg-gray-700 disabled:text-gray-500 text-black font-semibold rounded-xl transition-colors"
>
{loading ? "..." : t("verify")}
</button>
</div>
{error && (
<p className="text-sm text-red-400">{error}</p>
)}
</div>
);
}

View File

@@ -0,0 +1,52 @@
"use client";
import { useTranslations } from "next-intl";
import { StatusBadge } from "./StatusBadge";
import type { Payment } from "@/lib/afc";
interface PaymentHistoryTableProps {
payments: Payment[];
}
export function PaymentHistoryTable({ payments }: PaymentHistoryTableProps) {
const t = useTranslations("afc");
if (payments.length === 0) {
return (
<p className="text-center text-gray-500 py-8">{t("no_payments")}</p>
);
}
return (
<div className="overflow-x-auto">
<table className="w-full text-sm">
<thead>
<tr className="border-b border-white/10 text-gray-400">
<th className="text-left py-3 px-2 font-medium">{t("date")}</th>
<th className="text-right py-3 px-2 font-medium">AFC</th>
<th className="text-right py-3 px-2 font-medium">MXN</th>
<th className="text-center py-3 px-2 font-medium">{t("status")}</th>
</tr>
</thead>
<tbody>
{payments.map((p) => (
<tr key={p.id} className="border-b border-white/5 hover:bg-white/[0.02]">
<td className="py-3 px-2 text-gray-300">
{new Date(p.created_at).toLocaleDateString()}
</td>
<td className="py-3 px-2 text-right text-amber-400 font-medium">
+{p.amount_afc}
</td>
<td className="py-3 px-2 text-right text-gray-400">
${p.amount_mxn}
</td>
<td className="py-3 px-2 text-center">
<StatusBadge status={p.status} />
</td>
</tr>
))}
</tbody>
</table>
</div>
);
}

View File

@@ -0,0 +1,41 @@
"use client";
interface PrizeCardProps {
icon: string;
brand: string;
label: string;
costAfc: number;
valueMxn: number;
disabled?: boolean;
onSelect: () => void;
}
export function PrizeCard({
icon,
brand,
label,
costAfc,
valueMxn,
disabled,
onSelect,
}: PrizeCardProps) {
return (
<button
onClick={onSelect}
disabled={disabled}
className="group block w-full text-left bg-gray-900 rounded-2xl p-5 border border-white/5 hover:border-amber-500/30 transition-all duration-200 hover:scale-[1.02] active:scale-[0.98] disabled:opacity-40 disabled:pointer-events-none"
>
<div className="flex items-center gap-4">
<span className="text-3xl">{icon}</span>
<div className="flex-1 min-w-0">
<p className="text-white font-semibold">{brand}</p>
<p className="text-gray-500 text-sm truncate">{label}</p>
</div>
<div className="text-right shrink-0">
<p className="text-lg font-bold text-amber-400">{costAfc} AFC</p>
<p className="text-xs text-gray-500">${valueMxn} MXN</p>
</div>
</div>
</button>
);
}

View File

@@ -0,0 +1,82 @@
"use client";
import { useState } from "react";
import { useTranslations } from "next-intl";
interface RedeemFormProps {
prizeType: string;
prizeDetail: string;
costAfc: number;
onSubmit: (deliveryInfo: string) => void;
onCancel: () => void;
loading: boolean;
}
export function RedeemForm({
prizeType,
prizeDetail,
costAfc,
onSubmit,
onCancel,
loading,
}: RedeemFormProps) {
const t = useTranslations("afc");
const [deliveryInfo, setDeliveryInfo] = useState("");
const isBankTransfer = prizeType === "bank_transfer";
const isMercadoPago = prizeType === "mercadopago";
const placeholder = isBankTransfer
? t("clabe_placeholder")
: isMercadoPago
? t("mp_account_placeholder")
: t("delivery_placeholder");
const label = isBankTransfer
? t("clabe_label")
: isMercadoPago
? t("mp_account_label")
: t("delivery_label");
return (
<div className="bg-gray-900 border border-white/10 rounded-2xl p-6 space-y-5">
<div className="flex items-center justify-between">
<div>
<h3 className="text-lg font-semibold text-white">{prizeDetail}</h3>
<p className="text-sm text-amber-400">{costAfc} AFC</p>
</div>
<button
onClick={onCancel}
className="text-sm text-gray-500 hover:text-gray-300 transition-colors"
>
{t("cancel")}
</button>
</div>
<div className="space-y-2">
<label className="block text-sm font-medium text-gray-400">
{label}
</label>
<input
type="text"
value={deliveryInfo}
onChange={(e) => setDeliveryInfo(e.target.value)}
placeholder={placeholder}
className="w-full bg-gray-800 border border-white/10 rounded-xl px-4 py-3 text-white placeholder:text-gray-600 focus:outline-none focus:border-amber-500/50 focus:ring-1 focus:ring-amber-500/25 transition-all"
/>
</div>
<div className="bg-amber-500/10 border border-amber-500/20 rounded-xl p-4 text-sm text-amber-300/80">
{t("redeem_warning")}
</div>
<button
onClick={() => onSubmit(deliveryInfo)}
disabled={loading || !deliveryInfo.trim()}
className="w-full py-3 bg-amber-500 hover:bg-amber-400 disabled:bg-gray-700 disabled:text-gray-500 text-black font-bold rounded-xl transition-colors"
>
{loading ? t("processing") : t("confirm_redeem")}
</button>
</div>
);
}

View File

@@ -0,0 +1,52 @@
"use client";
import { useTranslations } from "next-intl";
import { StatusBadge } from "./StatusBadge";
import type { Redemption } from "@/lib/afc";
interface RedemptionHistoryTableProps {
redemptions: Redemption[];
}
export function RedemptionHistoryTable({ redemptions }: RedemptionHistoryTableProps) {
const t = useTranslations("afc");
if (redemptions.length === 0) {
return (
<p className="text-center text-gray-500 py-8">{t("no_redemptions")}</p>
);
}
return (
<div className="overflow-x-auto">
<table className="w-full text-sm">
<thead>
<tr className="border-b border-white/10 text-gray-400">
<th className="text-left py-3 px-2 font-medium">{t("date")}</th>
<th className="text-left py-3 px-2 font-medium">{t("prize")}</th>
<th className="text-right py-3 px-2 font-medium">AFC</th>
<th className="text-center py-3 px-2 font-medium">{t("status")}</th>
</tr>
</thead>
<tbody>
{redemptions.map((r) => (
<tr key={r.id} className="border-b border-white/5 hover:bg-white/[0.02]">
<td className="py-3 px-2 text-gray-300">
{new Date(r.created_at).toLocaleDateString()}
</td>
<td className="py-3 px-2 text-white">
{r.prize_detail}
</td>
<td className="py-3 px-2 text-right text-red-400 font-medium">
-{r.amount_afc}
</td>
<td className="py-3 px-2 text-center">
<StatusBadge status={r.status} />
</td>
</tr>
))}
</tbody>
</table>
</div>
);
}

View File

@@ -0,0 +1,26 @@
"use client";
interface StatusBadgeProps {
status: string;
}
const STATUS_STYLES: Record<string, string> = {
pending: "bg-yellow-500/15 text-yellow-400 border-yellow-500/30",
completed: "bg-green-500/15 text-green-400 border-green-500/30",
approved: "bg-green-500/15 text-green-400 border-green-500/30",
fulfilled: "bg-green-500/15 text-green-400 border-green-500/30",
rejected: "bg-red-500/15 text-red-400 border-red-500/30",
failed: "bg-red-500/15 text-red-400 border-red-500/30",
};
const DEFAULT_STYLE = "bg-gray-500/15 text-gray-400 border-gray-500/30";
export function StatusBadge({ status }: StatusBadgeProps) {
const style = STATUS_STYLES[status] || DEFAULT_STYLE;
return (
<span className={`inline-block px-2.5 py-0.5 rounded-full text-xs font-medium border ${style}`}>
{status}
</span>
);
}

View File

@@ -11,6 +11,7 @@ export function Navbar() {
const links = [
{ href: `/${locale}`, label: t("home") },
{ href: `/${locale}/catalog`, label: t("catalog") },
{ href: `/${locale}/afc`, label: t("afc") },
{ href: `/${locale}/about`, label: t("about") },
{ href: `/${locale}/donate`, label: t("donate") },
];

View File

@@ -0,0 +1,82 @@
"use client";
import { useState, useEffect, useCallback } from "react";
import { verifyDiskId, getBalance } from "@/lib/afc";
const STORAGE_KEY = "afc_disk_id";
export function useDiskId() {
const [diskId, setDiskId] = useState("");
const [playerName, setPlayerName] = useState<string | null>(null);
const [balance, setBalance] = useState<number | null>(null);
const [verified, setVerified] = useState(false);
const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
// Restore from localStorage on mount
useEffect(() => {
const stored = localStorage.getItem(STORAGE_KEY);
if (stored) {
setDiskId(stored);
}
}, []);
const verify = useCallback(async (id: string) => {
if (!id.trim()) return;
setLoading(true);
setError(null);
try {
const result = await verifyDiskId(id);
if (result.valid) {
setVerified(true);
setPlayerName(result.name);
localStorage.setItem(STORAGE_KEY, id);
const balData = await getBalance(id);
setBalance(balData.balance ?? null);
} else {
setVerified(false);
setPlayerName(null);
setBalance(null);
setError("Disk ID not found");
}
} catch {
setError("Connection error");
setVerified(false);
} finally {
setLoading(false);
}
}, []);
const refreshBalance = useCallback(async () => {
if (!diskId || !verified) return;
try {
const data = await getBalance(diskId);
setBalance(data.balance ?? null);
} catch {
// silent
}
}, [diskId, verified]);
const clear = useCallback(() => {
setDiskId("");
setPlayerName(null);
setBalance(null);
setVerified(false);
setError(null);
localStorage.removeItem(STORAGE_KEY);
}, []);
return {
diskId,
setDiskId,
playerName,
balance,
verified,
loading,
error,
verify,
refreshBalance,
clear,
};
}

88
apps/web/src/lib/afc.ts Normal file
View File

@@ -0,0 +1,88 @@
/** Client-side fetch wrappers for AFC Store API routes */
export async function verifyDiskId(diskId: string) {
const res = await fetch(`/api/afc/verify-disk?diskId=${encodeURIComponent(diskId)}`);
return res.json() as Promise<{ valid: boolean; name: string | null }>;
}
export async function getBalance(diskId: string) {
const res = await fetch(`/api/afc/balance?diskId=${encodeURIComponent(diskId)}`);
return res.json() as Promise<{ balance: number; error?: string }>;
}
export async function createPreference(diskId: string, amountAfc: number) {
const res = await fetch("/api/afc/create-preference", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ diskId, amountAfc }),
});
if (!res.ok) {
const data = await res.json();
throw new Error(data.error || "Failed to create payment");
}
return res.json() as Promise<{
paymentId: string;
initPoint: string;
sandboxInitPoint: string;
}>;
}
export async function redeemAfc(params: {
diskId: string;
amountAfc: number;
prizeType: string;
prizeDetail: string;
deliveryInfo: string;
}) {
const res = await fetch("/api/afc/redeem", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(params),
});
if (!res.ok) {
const data = await res.json();
throw new Error(data.error || "Failed to redeem");
}
return res.json() as Promise<{
redemptionId: string;
burnTxHash: string;
balance: number;
}>;
}
export async function getPaymentHistory(diskId: string) {
const res = await fetch(`/api/afc/payments?diskId=${encodeURIComponent(diskId)}`);
return res.json() as Promise<{ payments: Payment[] }>;
}
export async function getRedemptionHistory(diskId: string) {
const res = await fetch(`/api/afc/redemptions?diskId=${encodeURIComponent(diskId)}`);
return res.json() as Promise<{ redemptions: Redemption[] }>;
}
export interface Payment {
id: string;
disk_id: string;
amount_afc: number;
amount_mxn: number;
status: string;
mp_preference_id: string | null;
mp_payment_id: string | null;
tx_hash: string | null;
created_at: string;
updated_at: string;
}
export interface Redemption {
id: string;
disk_id: string;
amount_afc: number;
prize_type: string;
prize_detail: string;
delivery_info: string | null;
status: string;
burn_tx_hash: string | null;
admin_notes: string | null;
created_at: string;
updated_at: string;
}

View File

@@ -3,7 +3,8 @@
"home": "Home",
"catalog": "Catalog",
"about": "About Us",
"donate": "Donations"
"donate": "Donations",
"afc": "AFC Store"
},
"home": {
"hero_title": "Project Afterlife",
@@ -59,5 +60,60 @@
"footer": {
"rights": "Project Afterlife. Preserving gaming history.",
"language": "Language"
},
"afc": {
"store_title": "AFC Store",
"store_subtitle": "Buy AfterCoin with real money or redeem your coins for prizes",
"store_info": "AfterCoin (AFC) is earned in the Minecraft casino. You can also buy AFC here with MercadoPago, or redeem your AFC for gift cards and cash. All redemptions are fulfilled manually by an admin within 24-48 hours.",
"disk_id": "Disk ID",
"enter_disk_id": "Enter your Disk ID to get started",
"disk_id_placeholder": "e.g. 7",
"verify": "Verify",
"change": "Change",
"your_balance": "Your balance",
"buy_title": "Buy AFC",
"buy_description": "Purchase AfterCoin with MercadoPago",
"buy_subtitle": "Select a package or enter a custom amount. Payment via MercadoPago.",
"redeem_title": "Redeem Prizes",
"redeem_description": "Exchange your AFC for gift cards or cash",
"redeem_subtitle": "Choose a prize to redeem with your AfterCoin.",
"history_title": "History",
"history_description": "View your purchase and redemption history",
"history_subtitle": "Track all your AFC transactions.",
"select_package": "Select a package",
"custom_amount": "Custom amount",
"buy": "Buy",
"back_to_store": "Back to AFC Store",
"payment_info": "Payments processed securely via MercadoPago. Supports credit/debit cards, OXXO, and bank transfers.",
"payment_success_title": "Payment Successful!",
"payment_success_description": "Your AfterCoin will be credited to your account within a few minutes.",
"payment_failure_title": "Payment Failed",
"payment_failure_description": "Something went wrong with your payment. No charges were made.",
"payment_pending_title": "Payment Pending",
"payment_pending_description": "Your payment is being processed. AFC will be credited once confirmed.",
"try_again": "Try Again",
"view_history": "View History",
"gift_cards": "Gift Cards",
"cash_out": "Cash Withdrawal",
"clabe_label": "CLABE (18 digits)",
"clabe_placeholder": "Enter your 18-digit CLABE",
"mp_account_label": "MercadoPago email or phone",
"mp_account_placeholder": "Email or phone number",
"delivery_label": "Delivery details",
"delivery_placeholder": "Email for gift card delivery",
"redeem_warning": "This action is irreversible. Your AFC will be burned immediately. The prize will be delivered by an admin within 24-48 hours.",
"confirm_redeem": "Confirm Redemption",
"cancel": "Cancel",
"processing": "Processing...",
"redeem_success_title": "Redemption Submitted!",
"redeem_success_description": "Your AFC has been burned. An admin will fulfill your prize within 24-48 hours.",
"purchases": "Purchases",
"redemptions": "Redemptions",
"loading": "Loading...",
"date": "Date",
"prize": "Prize",
"status": "Status",
"no_payments": "No purchases yet",
"no_redemptions": "No redemptions yet"
}
}

View File

@@ -3,7 +3,8 @@
"home": "Inicio",
"catalog": "Catálogo",
"about": "Sobre Nosotros",
"donate": "Donaciones"
"donate": "Donaciones",
"afc": "Tienda AFC"
},
"home": {
"hero_title": "Project Afterlife",
@@ -59,5 +60,60 @@
"footer": {
"rights": "Project Afterlife. Preservando la historia del gaming.",
"language": "Idioma"
},
"afc": {
"store_title": "Tienda AFC",
"store_subtitle": "Compra AfterCoin con dinero real o canjea tus monedas por premios",
"store_info": "AfterCoin (AFC) se gana en el casino de Minecraft. También puedes comprar AFC aquí con MercadoPago, o canjear tus AFC por tarjetas de regalo y efectivo. Todos los canjeos son cumplidos manualmente por un admin en 24-48 horas.",
"disk_id": "Disk ID",
"enter_disk_id": "Ingresa tu Disk ID para comenzar",
"disk_id_placeholder": "ej. 7",
"verify": "Verificar",
"change": "Cambiar",
"your_balance": "Tu saldo",
"buy_title": "Comprar AFC",
"buy_description": "Compra AfterCoin con MercadoPago",
"buy_subtitle": "Selecciona un paquete o ingresa una cantidad personalizada. Pago vía MercadoPago.",
"redeem_title": "Canjear Premios",
"redeem_description": "Cambia tus AFC por tarjetas de regalo o efectivo",
"redeem_subtitle": "Elige un premio para canjear con tus AfterCoin.",
"history_title": "Historial",
"history_description": "Consulta tu historial de compras y canjeos",
"history_subtitle": "Revisa todas tus transacciones de AFC.",
"select_package": "Selecciona un paquete",
"custom_amount": "Cantidad personalizada",
"buy": "Comprar",
"back_to_store": "Volver a Tienda AFC",
"payment_info": "Pagos procesados de forma segura vía MercadoPago. Acepta tarjetas de crédito/débito, OXXO y transferencias bancarias.",
"payment_success_title": "¡Pago Exitoso!",
"payment_success_description": "Tus AfterCoin serán acreditados a tu cuenta en unos minutos.",
"payment_failure_title": "Pago Fallido",
"payment_failure_description": "Algo salió mal con tu pago. No se realizó ningún cargo.",
"payment_pending_title": "Pago Pendiente",
"payment_pending_description": "Tu pago está siendo procesado. Los AFC serán acreditados una vez confirmado.",
"try_again": "Intentar de Nuevo",
"view_history": "Ver Historial",
"gift_cards": "Tarjetas de Regalo",
"cash_out": "Retiro de Efectivo",
"clabe_label": "CLABE (18 dígitos)",
"clabe_placeholder": "Ingresa tu CLABE de 18 dígitos",
"mp_account_label": "Email o teléfono de MercadoPago",
"mp_account_placeholder": "Email o número de teléfono",
"delivery_label": "Datos de entrega",
"delivery_placeholder": "Email para entrega de tarjeta de regalo",
"redeem_warning": "Esta acción es irreversible. Tus AFC serán quemados inmediatamente. El premio será entregado por un admin en 24-48 horas.",
"confirm_redeem": "Confirmar Canjeo",
"cancel": "Cancelar",
"processing": "Procesando...",
"redeem_success_title": "¡Canjeo Enviado!",
"redeem_success_description": "Tus AFC han sido quemados. Un admin cumplirá tu premio en 24-48 horas.",
"purchases": "Compras",
"redemptions": "Canjeos",
"loading": "Cargando...",
"date": "Fecha",
"prize": "Premio",
"status": "Estado",
"no_payments": "Sin compras aún",
"no_redemptions": "Sin canjeos aún"
}
}