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>
139 lines
4.6 KiB
TypeScript
139 lines
4.6 KiB
TypeScript
"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>
|
|
);
|
|
}
|