From a76d51365916cda37248dd35308c423791410d34 Mon Sep 17 00:00:00 2001 From: consultoria-as Date: Thu, 26 Feb 2026 02:26:13 +0000 Subject: [PATCH] feat: add AFC Store with MercadoPago purchases and prize redemption MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- apps/web/package.json | 4 +- .../src/app/[locale]/afc/buy/failure/page.tsx | 33 ++++ apps/web/src/app/[locale]/afc/buy/page.tsx | 138 +++++++++++++ .../src/app/[locale]/afc/buy/pending/page.tsx | 39 ++++ .../src/app/[locale]/afc/buy/success/page.tsx | 39 ++++ .../web/src/app/[locale]/afc/history/page.tsx | 101 ++++++++++ apps/web/src/app/[locale]/afc/page.tsx | 90 +++++++++ apps/web/src/app/[locale]/afc/redeem/page.tsx | 184 ++++++++++++++++++ apps/web/src/app/api/afc/balance/route.ts | 16 ++ .../app/api/afc/create-preference/route.ts | 71 +++++++ apps/web/src/app/api/afc/lib/bridge.ts | 50 +++++ apps/web/src/app/api/afc/lib/mercadopago.ts | 9 + apps/web/src/app/api/afc/payments/route.ts | 16 ++ apps/web/src/app/api/afc/redeem/route.ts | 49 +++++ apps/web/src/app/api/afc/redemptions/route.ts | 16 ++ apps/web/src/app/api/afc/verify-disk/route.ts | 15 ++ apps/web/src/app/api/afc/webhook/route.ts | 113 +++++++++++ .../web/src/components/afc/AfcPackageCard.tsx | 49 +++++ .../web/src/components/afc/BalanceDisplay.tsx | 36 ++++ apps/web/src/components/afc/DiskIdInput.tsx | 77 ++++++++ .../components/afc/PaymentHistoryTable.tsx | 52 +++++ apps/web/src/components/afc/PrizeCard.tsx | 41 ++++ apps/web/src/components/afc/RedeemForm.tsx | 82 ++++++++ .../components/afc/RedemptionHistoryTable.tsx | 52 +++++ apps/web/src/components/afc/StatusBadge.tsx | 26 +++ apps/web/src/components/layout/Navbar.tsx | 1 + apps/web/src/hooks/useDiskId.ts | 82 ++++++++ apps/web/src/lib/afc.ts | 88 +++++++++ apps/web/src/messages/en.json | 58 +++++- apps/web/src/messages/es.json | 58 +++++- docker/.env.example | 7 + docker/docker-compose.dev.yml | 8 + docker/nginx/nginx.conf | 10 + package-lock.json | 115 ++++++++++- services/afc-bridge/src/db.js | 117 +++++++++++ services/afc-bridge/src/index.js | 6 +- services/afc-bridge/src/routes/payments.js | 93 +++++++++ services/afc-bridge/src/routes/redemptions.js | 106 ++++++++++ 38 files changed, 2142 insertions(+), 5 deletions(-) create mode 100644 apps/web/src/app/[locale]/afc/buy/failure/page.tsx create mode 100644 apps/web/src/app/[locale]/afc/buy/page.tsx create mode 100644 apps/web/src/app/[locale]/afc/buy/pending/page.tsx create mode 100644 apps/web/src/app/[locale]/afc/buy/success/page.tsx create mode 100644 apps/web/src/app/[locale]/afc/history/page.tsx create mode 100644 apps/web/src/app/[locale]/afc/page.tsx create mode 100644 apps/web/src/app/[locale]/afc/redeem/page.tsx create mode 100644 apps/web/src/app/api/afc/balance/route.ts create mode 100644 apps/web/src/app/api/afc/create-preference/route.ts create mode 100644 apps/web/src/app/api/afc/lib/bridge.ts create mode 100644 apps/web/src/app/api/afc/lib/mercadopago.ts create mode 100644 apps/web/src/app/api/afc/payments/route.ts create mode 100644 apps/web/src/app/api/afc/redeem/route.ts create mode 100644 apps/web/src/app/api/afc/redemptions/route.ts create mode 100644 apps/web/src/app/api/afc/verify-disk/route.ts create mode 100644 apps/web/src/app/api/afc/webhook/route.ts create mode 100644 apps/web/src/components/afc/AfcPackageCard.tsx create mode 100644 apps/web/src/components/afc/BalanceDisplay.tsx create mode 100644 apps/web/src/components/afc/DiskIdInput.tsx create mode 100644 apps/web/src/components/afc/PaymentHistoryTable.tsx create mode 100644 apps/web/src/components/afc/PrizeCard.tsx create mode 100644 apps/web/src/components/afc/RedeemForm.tsx create mode 100644 apps/web/src/components/afc/RedemptionHistoryTable.tsx create mode 100644 apps/web/src/components/afc/StatusBadge.tsx create mode 100644 apps/web/src/hooks/useDiskId.ts create mode 100644 apps/web/src/lib/afc.ts create mode 100644 services/afc-bridge/src/routes/payments.js create mode 100644 services/afc-bridge/src/routes/redemptions.js diff --git a/apps/web/package.json b/apps/web/package.json index ec4ca4c..935408a 100644 --- a/apps/web/package.json +++ b/apps/web/package.json @@ -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", diff --git a/apps/web/src/app/[locale]/afc/buy/failure/page.tsx b/apps/web/src/app/[locale]/afc/buy/failure/page.tsx new file mode 100644 index 0000000..b18eb43 --- /dev/null +++ b/apps/web/src/app/[locale]/afc/buy/failure/page.tsx @@ -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 ( +
+
+ +
+

{t("payment_failure_title")}

+

{t("payment_failure_description")}

+
+ + {t("try_again")} + + + {t("back_to_store")} + +
+
+ ); +} diff --git a/apps/web/src/app/[locale]/afc/buy/page.tsx b/apps/web/src/app/[locale]/afc/buy/page.tsx new file mode 100644 index 0000000..ee7b72a --- /dev/null +++ b/apps/web/src/app/[locale]/afc/buy/page.tsx @@ -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(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 ( +
+ {/* Back */} + + ← {t("back_to_store")} + + +

{t("buy_title")}

+

{t("buy_subtitle")}

+ + {/* Disk ID */} +
+ disk.verify(disk.diskId)} + loading={disk.loading} + verified={disk.verified} + playerName={disk.playerName} + error={disk.error} + onClear={disk.clear} + /> +
+ + {disk.verified && ( + <> + {/* Balance */} +
+ +
+ + {/* Packages */} +
+

{t("select_package")}

+ {PACKAGES.map((pkg) => ( + handleBuy(pkg.amount)} + /> + ))} +
+ + {/* Custom amount */} +
+

{t("custom_amount")}

+
+
+ 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 && ( + + = ${Number(customAmount) * PRICE_PER_AFC} MXN + + )} +
+ +
+
+ + {error && ( +
+ {error} +
+ )} + + {/* Payment info */} +

+ {t("payment_info")} +

+ + )} +
+ ); +} diff --git a/apps/web/src/app/[locale]/afc/buy/pending/page.tsx b/apps/web/src/app/[locale]/afc/buy/pending/page.tsx new file mode 100644 index 0000000..762de04 --- /dev/null +++ b/apps/web/src/app/[locale]/afc/buy/pending/page.tsx @@ -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 ( +
+
+ +
+

{t("payment_pending_title")}

+

{t("payment_pending_description")}

+ {paymentId && ( +

ID: {paymentId}

+ )} +
+ + {t("view_history")} + + + {t("back_to_store")} + +
+
+ ); +} diff --git a/apps/web/src/app/[locale]/afc/buy/success/page.tsx b/apps/web/src/app/[locale]/afc/buy/success/page.tsx new file mode 100644 index 0000000..c83cf89 --- /dev/null +++ b/apps/web/src/app/[locale]/afc/buy/success/page.tsx @@ -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 ( +
+
+ +
+

{t("payment_success_title")}

+

{t("payment_success_description")}

+ {paymentId && ( +

ID: {paymentId}

+ )} +
+ + {t("back_to_store")} + + + {t("view_history")} + +
+
+ ); +} diff --git a/apps/web/src/app/[locale]/afc/history/page.tsx b/apps/web/src/app/[locale]/afc/history/page.tsx new file mode 100644 index 0000000..87522fe --- /dev/null +++ b/apps/web/src/app/[locale]/afc/history/page.tsx @@ -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("payments"); + const [payments, setPayments] = useState([]); + const [redemptions, setRedemptions] = useState([]); + 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 ( +
+ + ← {t("back_to_store")} + + +

{t("history_title")}

+

{t("history_subtitle")}

+ + {/* Disk ID */} +
+ disk.verify(disk.diskId)} + loading={disk.loading} + verified={disk.verified} + playerName={disk.playerName} + error={disk.error} + onClear={disk.clear} + /> +
+ + {disk.verified && ( + <> +
+ +
+ + {/* Tabs */} +
+ + +
+ + {loadingData ? ( +
{t("loading")}
+ ) : tab === "payments" ? ( + + ) : ( + + )} + + )} +
+ ); +} diff --git a/apps/web/src/app/[locale]/afc/page.tsx b/apps/web/src/app/[locale]/afc/page.tsx new file mode 100644 index 0000000..1cd4aee --- /dev/null +++ b/apps/web/src/app/[locale]/afc/page.tsx @@ -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 ( +
+ {/* Header */} +
+
+ A +
+

{t("store_title")}

+

+ {t("store_subtitle")} +

+
+ + {/* Disk ID */} +
+ disk.verify(disk.diskId)} + loading={disk.loading} + verified={disk.verified} + playerName={disk.playerName} + error={disk.error} + onClear={disk.clear} + /> +
+ + {/* Balance */} + {disk.verified && ( +
+ +
+ )} + + {/* Action cards */} +
+ +
+ + +
+

{t("buy_title")}

+

{t("buy_description")}

+ + + +
+ +
+

{t("redeem_title")}

+

{t("redeem_description")}

+ + + +
+ +
+

{t("history_title")}

+

{t("history_description")}

+ +
+ + {/* Info */} +
+

{t("store_info")}

+
+
+ ); +} diff --git a/apps/web/src/app/[locale]/afc/redeem/page.tsx b/apps/web/src/app/[locale]/afc/redeem/page.tsx new file mode 100644 index 0000000..ca1d8e8 --- /dev/null +++ b/apps/web/src/app/[locale]/afc/redeem/page.tsx @@ -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(null); + const [loading, setLoading] = useState(false); + const [success, setSuccess] = useState(false); + const [error, setError] = useState(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 ( +
+
+ +
+

{t("redeem_success_title")}

+

{t("redeem_success_description")}

+
+ + {t("view_history")} + + + {t("back_to_store")} + +
+
+ ); + } + + return ( +
+ + ← {t("back_to_store")} + + +

{t("redeem_title")}

+

{t("redeem_subtitle")}

+ + {/* Disk ID */} +
+ disk.verify(disk.diskId)} + loading={disk.loading} + verified={disk.verified} + playerName={disk.playerName} + error={disk.error} + onClear={disk.clear} + /> +
+ + {disk.verified && ( + <> +
+ +
+ + {selected ? ( + setSelected(null)} + loading={loading} + /> + ) : ( + <> + {/* Gift Cards */} +

{t("gift_cards")}

+
+ {GIFT_CARDS.map((prize, i) => ( + setSelected(prize)} + /> + ))} +
+ + {/* Cash Out */} +

{t("cash_out")}

+
+ {CASH_OUT.map((prize, i) => ( + setSelected(prize)} + /> + ))} +
+ + )} + + {error && ( +
+ {error} +
+ )} + + )} +
+ ); +} diff --git a/apps/web/src/app/api/afc/balance/route.ts b/apps/web/src/app/api/afc/balance/route.ts new file mode 100644 index 0000000..ba5121b --- /dev/null +++ b/apps/web/src/app/api/afc/balance/route.ts @@ -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 }); + } +} diff --git a/apps/web/src/app/api/afc/create-preference/route.ts b/apps/web/src/app/api/afc/create-preference/route.ts new file mode 100644 index 0000000..21dcb6d --- /dev/null +++ b/apps/web/src/app/api/afc/create-preference/route.ts @@ -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 }); + } +} diff --git a/apps/web/src/app/api/afc/lib/bridge.ts b/apps/web/src/app/api/afc/lib/bridge.ts new file mode 100644 index 0000000..2b775d8 --- /dev/null +++ b/apps/web/src/app/api/afc/lib/bridge.ts @@ -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) { + 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 +) { + 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(); +} diff --git a/apps/web/src/app/api/afc/lib/mercadopago.ts b/apps/web/src/app/api/afc/lib/mercadopago.ts new file mode 100644 index 0000000..743278c --- /dev/null +++ b/apps/web/src/app/api/afc/lib/mercadopago.ts @@ -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 }; diff --git a/apps/web/src/app/api/afc/payments/route.ts b/apps/web/src/app/api/afc/payments/route.ts new file mode 100644 index 0000000..26c869b --- /dev/null +++ b/apps/web/src/app/api/afc/payments/route.ts @@ -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 }); + } +} diff --git a/apps/web/src/app/api/afc/redeem/route.ts b/apps/web/src/app/api/afc/redeem/route.ts new file mode 100644 index 0000000..ad09d48 --- /dev/null +++ b/apps/web/src/app/api/afc/redeem/route.ts @@ -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 }); + } +} diff --git a/apps/web/src/app/api/afc/redemptions/route.ts b/apps/web/src/app/api/afc/redemptions/route.ts new file mode 100644 index 0000000..f6413fe --- /dev/null +++ b/apps/web/src/app/api/afc/redemptions/route.ts @@ -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 }); + } +} diff --git a/apps/web/src/app/api/afc/verify-disk/route.ts b/apps/web/src/app/api/afc/verify-disk/route.ts new file mode 100644 index 0000000..9c822d1 --- /dev/null +++ b/apps/web/src/app/api/afc/verify-disk/route.ts @@ -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 }); + } +} diff --git a/apps/web/src/app/api/afc/webhook/route.ts b/apps/web/src/app/api/afc/webhook/route.ts new file mode 100644 index 0000000..02beb97 --- /dev/null +++ b/apps/web/src/app/api/afc/webhook/route.ts @@ -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 }); + } +} diff --git a/apps/web/src/components/afc/AfcPackageCard.tsx b/apps/web/src/components/afc/AfcPackageCard.tsx new file mode 100644 index 0000000..7b93f69 --- /dev/null +++ b/apps/web/src/components/afc/AfcPackageCard.tsx @@ -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 ( + + ); +} diff --git a/apps/web/src/components/afc/BalanceDisplay.tsx b/apps/web/src/components/afc/BalanceDisplay.tsx new file mode 100644 index 0000000..7f13cee --- /dev/null +++ b/apps/web/src/components/afc/BalanceDisplay.tsx @@ -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 ( + + + A + + {balance} AFC + + ); + } + + return ( +
+
+ A +
+

{t("your_balance")}

+

{balance}

+

AfterCoin

+
+ ); +} diff --git a/apps/web/src/components/afc/DiskIdInput.tsx b/apps/web/src/components/afc/DiskIdInput.tsx new file mode 100644 index 0000000..3c0edb4 --- /dev/null +++ b/apps/web/src/components/afc/DiskIdInput.tsx @@ -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 ( +
+
+ {playerName.charAt(0).toUpperCase()} +
+
+

{t("disk_id")}: {diskId}

+

{playerName}

+
+ {onClear && ( + + )} +
+ ); + } + + return ( +
+ +
+ 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" + /> + +
+ {error && ( +

{error}

+ )} +
+ ); +} diff --git a/apps/web/src/components/afc/PaymentHistoryTable.tsx b/apps/web/src/components/afc/PaymentHistoryTable.tsx new file mode 100644 index 0000000..81390e9 --- /dev/null +++ b/apps/web/src/components/afc/PaymentHistoryTable.tsx @@ -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 ( +

{t("no_payments")}

+ ); + } + + return ( +
+ + + + + + + + + + + {payments.map((p) => ( + + + + + + + ))} + +
{t("date")}AFCMXN{t("status")}
+ {new Date(p.created_at).toLocaleDateString()} + + +{p.amount_afc} + + ${p.amount_mxn} + + +
+
+ ); +} diff --git a/apps/web/src/components/afc/PrizeCard.tsx b/apps/web/src/components/afc/PrizeCard.tsx new file mode 100644 index 0000000..1ed5429 --- /dev/null +++ b/apps/web/src/components/afc/PrizeCard.tsx @@ -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 ( + + ); +} diff --git a/apps/web/src/components/afc/RedeemForm.tsx b/apps/web/src/components/afc/RedeemForm.tsx new file mode 100644 index 0000000..4911367 --- /dev/null +++ b/apps/web/src/components/afc/RedeemForm.tsx @@ -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 ( +
+
+
+

{prizeDetail}

+

{costAfc} AFC

+
+ +
+ +
+ + 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" + /> +
+ +
+ {t("redeem_warning")} +
+ + +
+ ); +} diff --git a/apps/web/src/components/afc/RedemptionHistoryTable.tsx b/apps/web/src/components/afc/RedemptionHistoryTable.tsx new file mode 100644 index 0000000..6276402 --- /dev/null +++ b/apps/web/src/components/afc/RedemptionHistoryTable.tsx @@ -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 ( +

{t("no_redemptions")}

+ ); + } + + return ( +
+ + + + + + + + + + + {redemptions.map((r) => ( + + + + + + + ))} + +
{t("date")}{t("prize")}AFC{t("status")}
+ {new Date(r.created_at).toLocaleDateString()} + + {r.prize_detail} + + -{r.amount_afc} + + +
+
+ ); +} diff --git a/apps/web/src/components/afc/StatusBadge.tsx b/apps/web/src/components/afc/StatusBadge.tsx new file mode 100644 index 0000000..4359107 --- /dev/null +++ b/apps/web/src/components/afc/StatusBadge.tsx @@ -0,0 +1,26 @@ +"use client"; + +interface StatusBadgeProps { + status: string; +} + +const STATUS_STYLES: Record = { + 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 ( + + {status} + + ); +} diff --git a/apps/web/src/components/layout/Navbar.tsx b/apps/web/src/components/layout/Navbar.tsx index b813b0e..9e65455 100644 --- a/apps/web/src/components/layout/Navbar.tsx +++ b/apps/web/src/components/layout/Navbar.tsx @@ -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") }, ]; diff --git a/apps/web/src/hooks/useDiskId.ts b/apps/web/src/hooks/useDiskId.ts new file mode 100644 index 0000000..48ba00e --- /dev/null +++ b/apps/web/src/hooks/useDiskId.ts @@ -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(null); + const [balance, setBalance] = useState(null); + const [verified, setVerified] = useState(false); + const [loading, setLoading] = useState(false); + const [error, setError] = useState(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, + }; +} diff --git a/apps/web/src/lib/afc.ts b/apps/web/src/lib/afc.ts new file mode 100644 index 0000000..1447d7f --- /dev/null +++ b/apps/web/src/lib/afc.ts @@ -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; +} diff --git a/apps/web/src/messages/en.json b/apps/web/src/messages/en.json index f883cd8..373701e 100644 --- a/apps/web/src/messages/en.json +++ b/apps/web/src/messages/en.json @@ -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" } } diff --git a/apps/web/src/messages/es.json b/apps/web/src/messages/es.json index 09a2c78..47c10f6 100644 --- a/apps/web/src/messages/es.json +++ b/apps/web/src/messages/es.json @@ -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" } } diff --git a/docker/.env.example b/docker/.env.example index 6599bf5..add24db 100644 --- a/docker/.env.example +++ b/docker/.env.example @@ -30,3 +30,10 @@ CF_API_TOKEN=your_cloudflare_api_token AFC_ADMIN_ADDRESS=0xYOUR_ADMIN_ADDRESS AFC_ADMIN_PRIVATE_KEY=your_private_key_without_0x_prefix AFC_BRIDGE_SECRET=change_me_in_production + +# AFC Store (MercadoPago integration) +MERCADOPAGO_ACCESS_TOKEN=your_mp_access_token +MERCADOPAGO_WEBHOOK_SECRET=your_mp_webhook_secret +MERCADOPAGO_WEBHOOK_URL=https://yourdomain.com/api/afc/webhook +AFC_PRICE_MXN=15 +NEXT_PUBLIC_SITE_URL=http://localhost:3000 diff --git a/docker/docker-compose.dev.yml b/docker/docker-compose.dev.yml index 3e34459..e4f402e 100644 --- a/docker/docker-compose.dev.yml +++ b/docker/docker-compose.dev.yml @@ -60,10 +60,18 @@ services: restart: unless-stopped depends_on: - cms + - afc-bridge environment: STRAPI_URL: http://cms:1337 STRAPI_API_TOKEN: ${STRAPI_API_TOKEN:-} NEXT_PUBLIC_STRAPI_URL: ${PUBLIC_STRAPI_URL:-http://localhost:1337} + AFC_BRIDGE_URL: http://afc-bridge:3001 + AFC_BRIDGE_SECRET: ${AFC_BRIDGE_SECRET} + MERCADOPAGO_ACCESS_TOKEN: ${MERCADOPAGO_ACCESS_TOKEN:-} + MERCADOPAGO_WEBHOOK_SECRET: ${MERCADOPAGO_WEBHOOK_SECRET:-} + MERCADOPAGO_WEBHOOK_URL: ${MERCADOPAGO_WEBHOOK_URL:-} + AFC_PRICE_MXN: ${AFC_PRICE_MXN:-15} + NEXT_PUBLIC_SITE_URL: ${NEXT_PUBLIC_SITE_URL:-http://localhost:3000} ports: - "3000:3000" diff --git a/docker/nginx/nginx.conf b/docker/nginx/nginx.conf index 19f6946..208ee70 100644 --- a/docker/nginx/nginx.conf +++ b/docker/nginx/nginx.conf @@ -28,6 +28,16 @@ http { proxy_set_header X-Forwarded-Proto $scheme; } + # AFC Store API — route to Next.js (before Strapi catch-all) + location /api/afc/ { + proxy_pass http://web; + proxy_http_version 1.1; + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-Proto $scheme; + } + location /api/ { proxy_pass http://cms; proxy_http_version 1.1; diff --git a/package-lock.json b/package-lock.json index 75f3866..f46766f 100644 --- a/package-lock.json +++ b/package-lock.json @@ -37,6 +37,40 @@ "npm": ">=6.0.0" } }, + "apps/cms/node_modules/react": { + "version": "18.3.1", + "resolved": "https://registry.npmjs.org/react/-/react-18.3.1.tgz", + "integrity": "sha512-wS+hAgJShR0KhEvPJArfuPVN1+Hz1t0Y6n5jLrGQbkb4urgPE/0Rve+1kMB1v/oWgHgm4WIcV+i7F2pTVj+2iQ==", + "license": "MIT", + "dependencies": { + "loose-envify": "^1.1.0" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "apps/cms/node_modules/react-dom": { + "version": "18.3.1", + "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-18.3.1.tgz", + "integrity": "sha512-5m4nQKp+rZRb09LNH59GM4BxTh9251/ylbKIbpe7TpGxfJ+9kv6BLkLBXIjjspbgbnIBNqlI23tRnTWT0snUIw==", + "license": "MIT", + "dependencies": { + "loose-envify": "^1.1.0", + "scheduler": "^0.23.2" + }, + "peerDependencies": { + "react": "^18.3.1" + } + }, + "apps/cms/node_modules/scheduler": { + "version": "0.23.2", + "resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.23.2.tgz", + "integrity": "sha512-UOShsPwz7NrMUqhR6t0hWjFduvOzbtv7toDH1/hIrfRNIDBnnBWd0CwJTGvTpngVlmwGCdP9/Zl/tVrDqcuYzQ==", + "license": "MIT", + "dependencies": { + "loose-envify": "^1.1.0" + } + }, "apps/web": { "name": "@afterlife/web", "version": "0.1.0", @@ -44,10 +78,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", @@ -61,6 +97,19 @@ "typescript": "^5" } }, + "apps/web/node_modules/uuid": { + "version": "13.0.0", + "resolved": "https://registry.npmjs.org/uuid/-/uuid-13.0.0.tgz", + "integrity": "sha512-XQegIaBTVUjSHliKqcnFqYypAd4S+WCYt5NIeRs6w/UAry7z8Y9j5ZwRRL4kzq9U3sD6v+85er9FvkEaBpji2w==", + "funding": [ + "https://github.com/sponsors/broofa", + "https://github.com/sponsors/ctavan" + ], + "license": "MIT", + "bin": { + "uuid": "dist-node/bin/uuid" + } + }, "node_modules/@afterlife/cms": { "resolved": "apps/cms", "link": true @@ -17641,6 +17690,28 @@ "integrity": "sha512-rkpe71W0N0c0Xz6QD0eJETuWAJGnJ9afsl1srmwPrI+yBCkge5EycXXbYRyvL29zZVUWQCY7InPRCv3GDXuZNw==", "license": "MIT" }, + "node_modules/mercadopago": { + "version": "2.12.0", + "resolved": "https://registry.npmjs.org/mercadopago/-/mercadopago-2.12.0.tgz", + "integrity": "sha512-9S+ZB/Fltd4BV9/U79r7U/+LrYJP844kxxvtAlVbbeVmhOE9rZt0YhPy1GXO3Yf4XyQaHwZ/SCyL2kebAicaLw==", + "dependencies": { + "node-fetch": "^2.7.0", + "uuid": "^9.0.0" + } + }, + "node_modules/mercadopago/node_modules/uuid": { + "version": "9.0.1", + "resolved": "https://registry.npmjs.org/uuid/-/uuid-9.0.1.tgz", + "integrity": "sha512-b+1eJOlsR9K8HJpow9Ok3fiWOWSIcIzXodvv0rQjVoOVNpWMpxf1wZNpt4y9h10odCNrqnYp1OBzRktckBe3sA==", + "funding": [ + "https://github.com/sponsors/broofa", + "https://github.com/sponsors/ctavan" + ], + "license": "MIT", + "bin": { + "uuid": "dist/bin/uuid" + } + }, "node_modules/merge-stream": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/merge-stream/-/merge-stream-2.0.0.tgz", @@ -19095,6 +19166,26 @@ "semver": "bin/semver.js" } }, + "node_modules/node-fetch": { + "version": "2.7.0", + "resolved": "https://registry.npmjs.org/node-fetch/-/node-fetch-2.7.0.tgz", + "integrity": "sha512-c4FRfUm/dbcWZ7U+1Wq0AwCyFL+3nt2bEw05wfxSz+DWpWsitgmSgYmy2dQdWyKC1694ELPqMs/YzUSNozLt8A==", + "license": "MIT", + "dependencies": { + "whatwg-url": "^5.0.0" + }, + "engines": { + "node": "4.x || >=6.0.0" + }, + "peerDependencies": { + "encoding": "^0.1.0" + }, + "peerDependenciesMeta": { + "encoding": { + "optional": true + } + } + }, "node_modules/node-machine-id": { "version": "1.1.12", "resolved": "https://registry.npmjs.org/node-machine-id/-/node-machine-id-1.1.12.tgz", @@ -24152,6 +24243,12 @@ "nodetouch": "bin/nodetouch.js" } }, + "node_modules/tr46": { + "version": "0.0.3", + "resolved": "https://registry.npmjs.org/tr46/-/tr46-0.0.3.tgz", + "integrity": "sha512-N3WMsuqV66lT30CrXNbEjx4GEwlow3v6rr4mCcv6prnfwhS01rkgyFdjPNBYd9br7LpXV1+Emh01fHnq2Gdgrw==", + "license": "MIT" + }, "node_modules/tree-kill": { "version": "1.2.2", "resolved": "https://registry.npmjs.org/tree-kill/-/tree-kill-1.2.2.tgz", @@ -25546,6 +25643,12 @@ "defaults": "^1.0.3" } }, + "node_modules/webidl-conversions": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-3.0.1.tgz", + "integrity": "sha512-2JAn3z8AR6rjK8Sm8orRC0h/bcl/DqL7tRPdGZ4I1CjdF+EaMLmYxBHyXuKL849eucPFhvBoxMsflfOb8kxaeQ==", + "license": "BSD-2-Clause" + }, "node_modules/webpack": { "version": "5.105.2", "resolved": "https://registry.npmjs.org/webpack/-/webpack-5.105.2.tgz", @@ -25739,6 +25842,16 @@ "node": ">=10.13.0" } }, + "node_modules/whatwg-url": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-5.0.0.tgz", + "integrity": "sha512-saE57nupxk6v3HY35+jzBwYa0rKSy0XR8JSxZPwgLr7ys0IBzhGviA1/TUGJLmSVqs8pb9AnvICXEuOHLprYTw==", + "license": "MIT", + "dependencies": { + "tr46": "~0.0.3", + "webidl-conversions": "^3.0.0" + } + }, "node_modules/which": { "version": "2.0.2", "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz", diff --git a/services/afc-bridge/src/db.js b/services/afc-bridge/src/db.js index bd3c8c2..5a00369 100644 --- a/services/afc-bridge/src/db.js +++ b/services/afc-bridge/src/db.js @@ -20,6 +20,33 @@ db.exec(` key TEXT PRIMARY KEY, value TEXT ); + + CREATE TABLE IF NOT EXISTS payments ( + id TEXT PRIMARY KEY, + disk_id TEXT NOT NULL, + amount_afc INTEGER NOT NULL, + amount_mxn REAL NOT NULL, + status TEXT NOT NULL DEFAULT 'pending', + mp_preference_id TEXT, + mp_payment_id TEXT, + tx_hash TEXT, + created_at TEXT DEFAULT CURRENT_TIMESTAMP, + updated_at TEXT DEFAULT CURRENT_TIMESTAMP + ); + + CREATE TABLE IF NOT EXISTS redemptions ( + id TEXT PRIMARY KEY, + disk_id TEXT NOT NULL, + amount_afc INTEGER NOT NULL, + prize_type TEXT NOT NULL, + prize_detail TEXT NOT NULL, + delivery_info TEXT, + status TEXT NOT NULL DEFAULT 'pending', + burn_tx_hash TEXT, + admin_notes TEXT, + created_at TEXT DEFAULT CURRENT_TIMESTAMP, + updated_at TEXT DEFAULT CURRENT_TIMESTAMP + ); `); function getWallet(diskId) { @@ -54,6 +81,86 @@ function getAllWallets() { return stmt.all(); } +// Payments +function createPayment(id, diskId, amountAfc, amountMxn) { + const stmt = db.prepare( + "INSERT INTO payments (id, disk_id, amount_afc, amount_mxn) VALUES (?, ?, ?, ?)" + ); + return stmt.run(id, diskId, amountAfc, amountMxn); +} + +function getPayment(id) { + const stmt = db.prepare("SELECT * FROM payments WHERE id = ?"); + return stmt.get(id) || null; +} + +function updatePayment(id, fields) { + const allowed = ["status", "mp_preference_id", "mp_payment_id", "tx_hash"]; + const sets = []; + const values = []; + for (const key of allowed) { + if (fields[key] !== undefined) { + sets.push(`${key} = ?`); + values.push(fields[key]); + } + } + if (sets.length === 0) return null; + sets.push("updated_at = CURRENT_TIMESTAMP"); + values.push(id); + const stmt = db.prepare(`UPDATE payments SET ${sets.join(", ")} WHERE id = ?`); + return stmt.run(...values); +} + +function getPaymentsByDiskId(diskId) { + const stmt = db.prepare("SELECT * FROM payments WHERE disk_id = ? ORDER BY created_at DESC"); + return stmt.all(diskId); +} + +function getPaymentByMpPaymentId(mpPaymentId) { + const stmt = db.prepare("SELECT * FROM payments WHERE mp_payment_id = ?"); + return stmt.get(mpPaymentId) || null; +} + +// Redemptions +function createRedemption(id, diskId, amountAfc, prizeType, prizeDetail, deliveryInfo, burnTxHash) { + const stmt = db.prepare( + "INSERT INTO redemptions (id, disk_id, amount_afc, prize_type, prize_detail, delivery_info, burn_tx_hash) VALUES (?, ?, ?, ?, ?, ?, ?)" + ); + return stmt.run(id, diskId, amountAfc, prizeType, prizeDetail, deliveryInfo, burnTxHash); +} + +function getRedemption(id) { + const stmt = db.prepare("SELECT * FROM redemptions WHERE id = ?"); + return stmt.get(id) || null; +} + +function getRedemptionsByDiskId(diskId) { + const stmt = db.prepare("SELECT * FROM redemptions WHERE disk_id = ? ORDER BY created_at DESC"); + return stmt.all(diskId); +} + +function getPendingRedemptions() { + const stmt = db.prepare("SELECT * FROM redemptions WHERE status = 'pending' ORDER BY created_at ASC"); + return stmt.all(); +} + +function updateRedemption(id, fields) { + const allowed = ["status", "admin_notes"]; + const sets = []; + const values = []; + for (const key of allowed) { + if (fields[key] !== undefined) { + sets.push(`${key} = ?`); + values.push(fields[key]); + } + } + if (sets.length === 0) return null; + sets.push("updated_at = CURRENT_TIMESTAMP"); + values.push(id); + const stmt = db.prepare(`UPDATE redemptions SET ${sets.join(", ")} WHERE id = ?`); + return stmt.run(...values); +} + module.exports = { db, getWallet, @@ -61,4 +168,14 @@ module.exports = { getContractAddress, setContractAddress, getAllWallets, + createPayment, + getPayment, + updatePayment, + getPaymentsByDiskId, + getPaymentByMpPaymentId, + createRedemption, + getRedemption, + getRedemptionsByDiskId, + getPendingRedemptions, + updateRedemption, }; diff --git a/services/afc-bridge/src/index.js b/services/afc-bridge/src/index.js index faa74a9..c1dfa69 100644 --- a/services/afc-bridge/src/index.js +++ b/services/afc-bridge/src/index.js @@ -9,6 +9,8 @@ const depositRouter = require("./routes/deposit"); const withdrawRouter = require("./routes/withdraw"); const balanceRouter = require("./routes/balance"); const walletRouter = require("./routes/wallet"); +const paymentsRouter = require("./routes/payments"); +const redemptionsRouter = require("./routes/redemptions"); const app = express(); @@ -20,7 +22,7 @@ app.use(express.json()); // Auth middleware — only applies to state-changing (POST) routes app.use((req, res, next) => { - if (req.method !== "POST") { + if (req.method !== "POST" && req.method !== "PATCH") { return next(); } @@ -40,6 +42,8 @@ app.use(depositRouter); app.use(withdrawRouter); app.use(balanceRouter); app.use(walletRouter); +app.use(paymentsRouter); +app.use(redemptionsRouter); // Global error handler app.use((err, req, res, next) => { diff --git a/services/afc-bridge/src/routes/payments.js b/services/afc-bridge/src/routes/payments.js new file mode 100644 index 0000000..f98ef0b --- /dev/null +++ b/services/afc-bridge/src/routes/payments.js @@ -0,0 +1,93 @@ +const { Router } = require("express"); +const db = require("../db"); + +const router = Router(); + +router.post("/api/payments", async (req, res, next) => { + try { + const { id, diskId, amountAfc, amountMxn } = req.body; + + if (!id || !diskId || amountAfc === undefined || amountMxn === undefined) { + return res + .status(400) + .json({ success: false, error: "id, diskId, amountAfc, and amountMxn are required" }); + } + + db.createPayment(id, diskId, amountAfc, amountMxn); + + const payment = db.getPayment(id); + + res.json({ + success: true, + payment, + }); + } catch (err) { + next(err); + } +}); + +router.get("/api/payments/history/:diskId", async (req, res, next) => { + try { + const { diskId } = req.params; + + const payments = db.getPaymentsByDiskId(diskId); + + res.json({ + success: true, + payments, + }); + } catch (err) { + next(err); + } +}); + +router.get("/api/payments/:id", async (req, res, next) => { + try { + const { id } = req.params; + + const payment = db.getPayment(id); + if (!payment) { + return res + .status(404) + .json({ success: false, error: "Payment not found" }); + } + + res.json({ + success: true, + payment, + }); + } catch (err) { + next(err); + } +}); + +router.patch("/api/payments/:id", async (req, res, next) => { + try { + const { id } = req.params; + + const payment = db.getPayment(id); + if (!payment) { + return res + .status(404) + .json({ success: false, error: "Payment not found" }); + } + + const result = db.updatePayment(id, req.body); + if (!result) { + return res + .status(400) + .json({ success: false, error: "No valid fields to update" }); + } + + const updated = db.getPayment(id); + + res.json({ + success: true, + payment: updated, + }); + } catch (err) { + next(err); + } +}); + +module.exports = router; diff --git a/services/afc-bridge/src/routes/redemptions.js b/services/afc-bridge/src/routes/redemptions.js new file mode 100644 index 0000000..a4de74f --- /dev/null +++ b/services/afc-bridge/src/routes/redemptions.js @@ -0,0 +1,106 @@ +const { Router } = require("express"); +const db = require("../db"); + +const router = Router(); + +router.post("/api/redemptions", async (req, res, next) => { + try { + const { id, diskId, amountAfc, prizeType, prizeDetail, deliveryInfo, burnTxHash } = req.body; + + if (!id || !diskId || amountAfc === undefined || !prizeType || !prizeDetail) { + return res + .status(400) + .json({ success: false, error: "id, diskId, amountAfc, prizeType, and prizeDetail are required" }); + } + + db.createRedemption(id, diskId, amountAfc, prizeType, prizeDetail, deliveryInfo, burnTxHash); + + const redemption = db.getRedemption(id); + + res.json({ + success: true, + redemption, + }); + } catch (err) { + next(err); + } +}); + +router.get("/api/redemptions/admin/pending", async (req, res, next) => { + try { + const redemptions = db.getPendingRedemptions(); + + res.json({ + success: true, + redemptions, + }); + } catch (err) { + next(err); + } +}); + +router.get("/api/redemptions/history/:diskId", async (req, res, next) => { + try { + const { diskId } = req.params; + + const redemptions = db.getRedemptionsByDiskId(diskId); + + res.json({ + success: true, + redemptions, + }); + } catch (err) { + next(err); + } +}); + +router.get("/api/redemptions/:id", async (req, res, next) => { + try { + const { id } = req.params; + + const redemption = db.getRedemption(id); + if (!redemption) { + return res + .status(404) + .json({ success: false, error: "Redemption not found" }); + } + + res.json({ + success: true, + redemption, + }); + } catch (err) { + next(err); + } +}); + +router.patch("/api/redemptions/:id", async (req, res, next) => { + try { + const { id } = req.params; + + const redemption = db.getRedemption(id); + if (!redemption) { + return res + .status(404) + .json({ success: false, error: "Redemption not found" }); + } + + const result = db.updateRedemption(id, req.body); + if (!result) { + return res + .status(400) + .json({ success: false, error: "No valid fields to update" }); + } + + const updated = db.getRedemption(id); + + res.json({ + success: true, + redemption: updated, + }); + } catch (err) { + next(err); + } +}); + +module.exports = router;