feat: add AFC Store with MercadoPago purchases and prize redemption
Some checks failed
Deploy / deploy (push) Has been cancelled
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:
138
apps/web/src/app/[locale]/afc/buy/page.tsx
Normal file
138
apps/web/src/app/[locale]/afc/buy/page.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user