diff --git a/.gitignore b/.gitignore index 94b396d..359f7f5 100644 --- a/.gitignore +++ b/.gitignore @@ -28,3 +28,4 @@ next-env.d.ts .pnpm-store/ .history/* +.env diff --git a/app/burn/page.tsx b/app/burn/page.tsx index 31e5c9c..452162b 100644 --- a/app/burn/page.tsx +++ b/app/burn/page.tsx @@ -6,8 +6,10 @@ import { PageContainer } from "@/components/layout/page-container"; import { Card } from "@/components/ui/card"; import { Button } from "@/components/ui/button"; import { Input } from "@/components/ui/input"; -import { ArrowLeft } from "lucide-react"; +import { ArrowLeft, AlertCircle, CheckCircle } from "lucide-react"; import { useApiOpts } from "@/hooks/use-api"; +import { useApiError } from "@/hooks/use-api-error"; +import { ApiErrorDisplay } from "@/components/ui/api-error-display"; import * as burnApi from "@/lib/api/burn"; import type { BurnRecipientAccount } from "@/types/api"; import { useAuth } from "@/contexts/auth-context"; @@ -15,13 +17,46 @@ import { useStellarWalletsKit } from "@/lib/stellar-wallets-kit"; import { getWalletSecretAnyLocal } from "@/lib/wallet-storage"; import { Keypair } from "@stellar/stellar-sdk"; import { submitBurnRedeemSingleClient } from "@/lib/stellar/burning"; +import { useForm } from "react-hook-form"; +import { zodResolver } from "@hookform/resolvers/zod"; +import * as z from "zod"; +import { + Form, + FormControl, + FormDescription, + FormField, + FormItem, + FormLabel, + FormMessage, +} from "@/components/ui/form"; + +const burnSchema = z.object({ + acbuAmount: z.string().refine((val: string) => !isNaN(parseFloat(val)) && parseFloat(val) > 0, { + message: "Amount must be greater than 0", + }), + currency: z.string().length(3, "Currency must be exactly 3 uppercase letters"), + accountNumber: z.string() + .min(5, "Account number is too short") + .max(20, "Account number is too long") + .regex(/^\d+$/, "Account number must contain only digits"), + bankCode: z.string() + .min(3, "Bank code is too short") + .max(10, "Bank code is too long") + .regex(/^[A-Za-z0-9]+$/, "Bank code must be alphanumeric"), + accountName: z.string() + .min(3, "Account name is too short") + .max(100, "Account name is too long"), +}); + +type BurnFormValues = z.infer; const formatCurrency = (amount: string, currency: string) => { const value = parseFloat(amount); if (isNaN(value)) return ""; try { - return new Intl.NumberFormat(undefined, { + // Use current browser locale for proper grouping separators + return new Intl.NumberFormat(navigator.language || 'en-US', { style: "currency", currency, }).format(value); @@ -34,43 +69,45 @@ export default function BurnPage() { const opts = useApiOpts(); const { userId, stellarAddress } = useAuth(); const kit = useStellarWalletsKit(); - const [acbuAmount, setAcbuAmount] = useState(""); - const [currency, setCurrency] = useState("NGN"); - const [accountNumber, setAccountNumber] = useState(""); - const [bankCode, setBankCode] = useState(""); - const [accountName, setAccountName] = useState(""); - const [error, setError] = useState(""); + const { uiError, setApiError, clearError, isSubmitDisabled } = useApiError(); const [loading, setLoading] = useState(false); const [txId, setTxId] = useState(null); - const recipientAccount: BurnRecipientAccount = { - account_number: accountNumber.trim(), - bank_code: bankCode.trim(), - account_name: accountName.trim(), - type: "bank", - }; + const form = useForm({ + resolver: zodResolver(burnSchema), + defaultValues: { + acbuAmount: "", + currency: "NGN", + accountNumber: "", + bankCode: "", + accountName: "", + }, + mode: "onChange", + }); - const isValid = - acbuAmount && - parseFloat(acbuAmount) > 0 && - currency.length === 3 && - accountNumber.trim().length >= 5 && - accountNumber.trim().length <= 20 && - bankCode.trim().length >= 3 && - bankCode.trim().length <= 10 && - accountName.trim().length >= 3 && - accountName.trim().length <= 100; + const values = form.watch(); + const currency = values.currency || "NGN"; + const isValid = form.formState.isValid; - const handleSubmit = async (e: React.FormEvent) => { - e.preventDefault(); - if (!isValid) return; - setError(""); + const handleSubmit = async (data: BurnFormValues) => { + clearError(); setLoading(true); + setTxId(null); + try { if (!userId) throw new Error("Not signed in"); if (!stellarAddress) throw new Error("No linked Stellar wallet address."); + + const recipientAccount: BurnRecipientAccount = { + account_number: data.accountNumber.trim(), + bank_code: data.bankCode.trim(), + account_name: data.accountName.trim(), + type: "bank", + }; + const secret = await getWalletSecretAnyLocal(userId, stellarAddress); let burnTxHash: string; + if (secret) { const localPubKey = Keypair.fromSecret(secret).publicKey(); if (stellarAddress && localPubKey !== stellarAddress) { @@ -80,8 +117,8 @@ export default function BurnPage() { } const submit = await submitBurnRedeemSingleClient({ userAddress: stellarAddress, - amountAcbu: acbuAmount, - currency, + amountAcbu: data.acbuAmount, + currency: data.currency, userSecret: secret, }); burnTxHash = submit.transactionHash; @@ -113,22 +150,24 @@ export default function BurnPage() { } const submit = await submitBurnRedeemSingleClient({ userAddress: stellarAddress, - amountAcbu: acbuAmount, - currency, + amountAcbu: data.acbuAmount, + currency: data.currency, external: { kit, address }, }); burnTxHash = submit.transactionHash; } + const res = await burnApi.burnAcbu( - acbuAmount, - currency, + data.acbuAmount, + data.currency, recipientAccount, opts, burnTxHash, ); setTxId(res.transaction_id); + form.reset({ ...data, acbuAmount: "" }); } catch (e) { - setError(e instanceof Error ? e.message : "Burn failed"); + setApiError(e); } finally { setLoading(false); } @@ -143,7 +182,7 @@ export default function BurnPage() { aria-label="Go back to Mint page" className="flex items-center justify-center min-w-[44px] min-h-[44px] -m-2" > -