diff --git a/app/mint/page.tsx b/app/mint/page.tsx index a2a3262..6ecd662 100644 --- a/app/mint/page.tsx +++ b/app/mint/page.tsx @@ -22,8 +22,7 @@ import { AlertDialogHeader, AlertDialogTitle, } from '@/components/ui/alert-dialog'; -import { SkeletonList } from '@/components/ui/skeleton-list'; -import { BalanceSkeleton } from '@/components/ui/balance-skeleton'; +import { Skeleton } from '@/components/ui/skeleton'; import { ArrowDown, ArrowUp, ArrowLeft } from 'lucide-react'; import { useApiOpts } from '@/hooks/use-api'; import { useApiError } from '@/hooks/use-api-error'; @@ -37,7 +36,7 @@ import { ensureAcbuTrustlineClient } from '@/lib/stellar/trustlines'; import { useStellarWalletsKit } from '@/lib/stellar-wallets-kit'; import { submitBurnRedeemSingleClient } from '@/lib/stellar/burning'; import { Keypair } from '@stellar/stellar-sdk'; -import { useRates } from '@/lib/api/rates'; +import * as ratesApi from '@/lib/api/rates'; import * as fiatApi from '@/lib/api/fiat'; import type { RatesResponse } from '@/types/api'; import { formatAmount } from '@/lib/utils'; @@ -307,14 +306,7 @@ export default function MintPage() { const opts = useApiOpts(); const router = useRouter(); const { userId, stellarAddress } = useAuth(); - const { t } = useI18n(); - const { - balance, - balanceSource, - loading: balanceLoading, - error: balanceError, - refetch: refetchBalance, - } = useBalance(); + const { balance, balanceSource, loading: balanceLoading, refresh: refreshBalance } = useBalance(); const kit = useStellarWalletsKit(); const { uiError: mintUiError, setApiError: setMintApiError, clearError: clearMintError, isSubmitDisabled: isMintDisabled } = useApiError(); const { uiError: burnUiError, setApiError: setBurnApiError, clearError: clearBurnError, isSubmitDisabled: isBurnDisabled } = useApiError(); @@ -323,6 +315,8 @@ export default function MintPage() { const [burnAmount, setBurnAmount] = useState(''); const [burnError, setBurnError] = useState(''); const [rates, setRates] = useState(null); + const { error: mintError, clearError: clearMintError, handleError: handleMintError } = useApiError(); + const { error: burnError, clearError: clearBurnError, handleError: handleBurnError } = useApiError(); const [ratesLoading, setRatesLoading] = useState(false); const [mintError, setMintError] = useState(''); const [txId, setTxId] = useState(null); @@ -339,26 +333,12 @@ export default function MintPage() { return () => setHasUnsavedChanges(false); }, [hasUnsavedChanges, setHasUnsavedChanges]); const [fiatAccounts, setFiatAccounts] = useState([]); - const [fiatAccountsLoading, setFiatAccountsLoading] = useState(true); const [selectedFiatCurrency, setSelectedFiatCurrency] = useState(''); const [fiatAmount, setFiatAmount] = useState(''); const debouncedFiatAmount = useDebounce(fiatAmount, 300); const debouncedBurnAmount = useDebounce(burnAmount, 300); const [mintQuoteRates, setMintQuoteRates] = useState(null); const [mintAcbuReceived, setMintAcbuReceived] = useState(null); - - const { - data: mintQuoteRates, - error: mintQuoteRatesError, - refetch: refetchQuoteRates, - } = useRates(opts); - - const { - data: rates, - loading: ratesLoading, - error: ratesError, - refetch: refetchRates, - } = useRates(activeTab === 'rates' ? opts : undefined); const rateRows = Array.isArray((rates as { rates?: Array<{ currency?: string; rate?: number }> } | null)?.rates) ? ((rates as { rates?: Array<{ currency?: string; rate?: number }> }).rates ?? []) : []; @@ -368,6 +348,21 @@ export default function MintPage() { [fiatAmount, selectedFiatCurrency, mintQuoteRates], ); + useEffect(() => { + let cancelled = false; + ratesApi + .getRates(opts) + .then((data) => { + if (!cancelled) setMintQuoteRates(data); + }) + .catch(() => { + if (!cancelled) setMintQuoteRates(null); + }); + return () => { + cancelled = true; + }; + }, [opts.token]); + useEffect(() => { fiatApi .getFiatAccounts(opts) @@ -377,8 +372,7 @@ export default function MintPage() { setSelectedFiatCurrency(res.accounts[0].currency); } }) - .catch((e) => logger.error('Failed to get fiat accounts', e)) - .finally(() => setFiatAccountsLoading(false)); + .catch((e) => logger.error('Failed to get fiat accounts', e)); }, [opts.token]); useEffect(() => { @@ -509,7 +503,159 @@ export default function MintPage() { setMintAcbuReceived( typeof acbu === "number" && Number.isFinite(acbu) ? acbu : null, ); - refetchBalance(); + refreshBalance(); + setStep("success"); + } catch (e) { + setMintApiError(e); + } finally { + setExecuting(false); + } + }; + const handleExecuteBurn = async () => { + if (!burnAmount || parseFloat(burnAmount) <= 0 || !selectedFiatCurrency) + return; + clearBurnError(); + setExecuting(true); + try { + if (!userId) { + throw new Error("Not signed in — refresh and try again."); + } + if (!stellarAddress) { + throw new Error("No linked Stellar wallet address."); + } + const secret = await getWalletSecretAnyLocal(userId, stellarAddress); + let burnTxHash: string; + if (secret) { + const localPubKey = Keypair.fromSecret(secret).publicKey(); + if (stellarAddress && localPubKey !== stellarAddress) { + throw new Error( + `Local wallet (${localPubKey.slice(0, 6)}…${localPubKey.slice(-4)}) doesn't match the account on record (${stellarAddress.slice(0, 6)}…${stellarAddress.slice(-4)}). Re-import the correct seed from Settings, or update the wallet address, then retry.`, + ); + } + const submit = await submitBurnRedeemSingleClient({ + userAddress: stellarAddress, + amountAcbu: burnAmount, + currency: selectedFiatCurrency, + userSecret: secret, + }); + burnTxHash = submit.transactionHash; + } else { + if (!kit) { + throw new Error( + "Your wallet secret isn't available on this device and the wallet connector isn't ready yet. Please wait a moment and retry.", + ); + } + const address = await new Promise((resolve, reject) => { + kit + .openModal({ + onWalletSelected: async (selectedOption: { id: string }) => { + try { + kit.setWallet(selectedOption.id); + const { address } = await kit.getAddress(); + resolve(address); + } catch (err) { + reject(err); + } + }, + }) + .catch(reject); + }); + if (stellarAddress && address !== stellarAddress) { + throw new Error( + `Connected wallet (${address.slice(0, 6)}…${address.slice(-4)}) doesn't match the account on record (${stellarAddress.slice(0, 6)}…${stellarAddress.slice(-4)}). Connect the correct wallet (or update your linked wallet), then retry.`, + ); + } + const submit = await submitBurnRedeemSingleClient({ + userAddress: stellarAddress, + amountAcbu: burnAmount, + currency: selectedFiatCurrency, + external: { kit, address }, + }); + burnTxHash = submit.transactionHash; + } + const res = await fiatApi.postOffRamp( + burnAmount, + selectedFiatCurrency, + burnTxHash, + opts, + ); + setTxId(res.transaction_id || res.transactionId || null); + setStep("success"); + } catch (e) { + setBurnApiError(e); + } finally { + setExecuting(false); + } + }; + const handleExecuteBurn = async () => { + if (!burnAmount || parseFloat(burnAmount) <= 0 || !selectedFiatCurrency) + return; + setBurnError(""); + setExecuting(true); + try { + if (!userId) { + throw new Error("Not signed in — refresh and try again."); + } + if (!stellarAddress) { + throw new Error("No linked Stellar wallet address."); + } + const secret = await getWalletSecretAnyLocal(userId, stellarAddress); + let burnTxHash: string; + if (secret) { + const localPubKey = Keypair.fromSecret(secret).publicKey(); + if (stellarAddress && localPubKey !== stellarAddress) { + throw new Error( + `Local wallet (${localPubKey.slice(0, 6)}…${localPubKey.slice(-4)}) doesn't match the account on record (${stellarAddress.slice(0, 6)}…${stellarAddress.slice(-4)}). Re-import the correct seed from Settings, or update the wallet address, then retry.`, + ); + } + const submit = await submitBurnRedeemSingleClient({ + userAddress: stellarAddress, + amountAcbu: burnAmount, + currency: selectedFiatCurrency, + userSecret: secret, + }); + burnTxHash = submit.transactionHash; + } else { + if (!kit) { + throw new Error( + "Your wallet secret isn't available on this device and the wallet connector isn't ready yet. Please wait a moment and retry.", + ); + } + const address = await new Promise((resolve, reject) => { + kit + .openModal({ + onWalletSelected: async (selectedOption: { id: string }) => { + try { + kit.setWallet(selectedOption.id); + const { address } = await kit.getAddress(); + resolve(address); + } catch (err) { + reject(err); + } + }, + }) + .catch(reject); + }); + if (stellarAddress && address !== stellarAddress) { + throw new Error( + `Connected wallet (${address.slice(0, 6)}…${address.slice(-4)}) doesn't match the account on record (${stellarAddress.slice(0, 6)}…${stellarAddress.slice(-4)}). Connect the correct wallet (or update your linked wallet), then retry.`, + ); + } + const submit = await submitBurnRedeemSingleClient({ + userAddress: stellarAddress, + amountAcbu: burnAmount, + currency: selectedFiatCurrency, + external: { kit, address }, + }); + burnTxHash = submit.transactionHash; + } + const res = await fiatApi.postOffRamp( + burnAmount, + selectedFiatCurrency, + burnTxHash, + opts, + ); + setTxId(res.transaction_id || res.transactionId || null); setStep("success"); } catch (e) { setMintApiError(e); @@ -617,8 +763,8 @@ export default function MintPage() {
-

{t('mint.title')}

-

{t('mint.subtitle')}

+

Mint & Burn

+

Create and redeem ACBU

@@ -627,34 +773,16 @@ export default function MintPage() {

- {t('mint.acbuBalance')} + ACBU Balance +

+

+ {balanceLoading ? '...' : `ACBU ${formatAmount(balance)}`}

- {balanceLoading ? ( - - ) : ( -

- ACBU {formatAmount(balance)} -

- )}

{balanceSource === "stellar" - ? t('mint.balanceFromHorizon') - : t('mint.linkWallet')} + ? "ACBU balance from Stellar Horizon." + : "Link a wallet to see your on-chain ACBU balance."}

- {balanceError && ( -
-

{balanceError}

- -
- )}
@@ -670,26 +798,27 @@ export default function MintPage() { value="mint" className="rounded-none border-b-2 border-transparent data-[state=active]:border-primary" > - {t('mint.mint')} + Mint - {t('mint.burn')} + Burn - {t('mint.rates')} + Rates

- {t('mint.mintDescription')} + Mint ACBU via custodial on-ramp (demo basket fiat held on the minting + contract).

{mintUiError && ( @@ -699,11 +828,8 @@ export default function MintPage() { htmlFor="fiat-account" className="form-label" > - {t('mint.basketCurrency')} + Basket currency (demo fiat path) - {fiatAccountsLoading ? ( - - ) : ( - )}
@@ -762,29 +887,13 @@ export default function MintPage() {

)} - {mintQuoteRatesError && ( -
-

{mintQuoteRatesError}

-
- -
-
- )}
- {t('mint.networkFee')} + Network Fee - {t('mint.estimatedAtConfirmation')} + {MINT_NETWORK_FEE_TEXT}
@@ -798,7 +907,7 @@ export default function MintPage() { className="w-full bg-primary text-primary-foreground hover:bg-primary/90 mt-6" > - {t('mint.mintAcbu')} + Mint ACBU
@@ -806,7 +915,8 @@ export default function MintPage() {

- {t('mint.burnDescription')} + Burn ACBU on-chain for the selected basket slice (no simulated bank + credit).

{burnUiError && ( @@ -816,11 +926,8 @@ export default function MintPage() { htmlFor="burn-fiat-account" className="form-label" > - {t('mint.basketCurrency')} + Basket currency (burn slice) - {fiatAccountsLoading ? ( - - ) : ( - )}
@@ -864,14 +970,14 @@ export default function MintPage() { />

- {t('mint.available')}: ACBU{" "} - {balanceLoading ? : formatAmount(balance)} + Available: ACBU{" "} + {balanceLoading ? '...' : formatAmount(balance)}

- {t('mint.youWillReceive')} + You'll receive {burnAmount && selectedFiatCurrency @@ -881,10 +987,10 @@ export default function MintPage() {
- {t('mint.processingFee')} + Processing Fee - {t('mint.estimatedAtConfirmation')} + {BURN_PROCESSING_FEE_TEXT}
@@ -906,24 +1012,8 @@ export default function MintPage() {
- {ratesError && ( -
-

{ratesError}

-
- -
-
- )} {ratesLoading ? ( - + ) : rateRows.length ? ( rateRows.map((r) => ( @@ -934,9 +1024,9 @@ export default function MintPage() { )) ) : ( -

No rates available.

+

No rates available. Use the API to load rates.

)} -

{t('mint.howItWorks')}

  • • {t('mint.howItWorks1')}
  • • {t('mint.howItWorks2')}
  • • {t('mint.howItWorks3')}
+

How it works

  • • Mint converts local fiat to native ACBU
  • • Burn redeems ACBU for fiat
  • • Rates from backend
@@ -947,26 +1037,24 @@ export default function MintPage() { {activeTab === "mint" - ? t('mint.confirmMint') - : t('mint.confirmBurn')} + ? "Confirm Mint" + : "Confirm Burn"} {activeTab === "mint" && - t('mint.mintConfirmDescription', { - currency: selectedFiatCurrency, - amount: formatAmount(fiatAmount), - estimate: estimatedMintAcbu != null + `Mint ACBU by exchanging ${selectedFiatCurrency} ${formatAmount(fiatAmount)}${ + estimatedMintAcbu != null ? ` (≈ ${formatAmount(estimatedMintAcbu)} ACBU at current rates)` - : '', - })} + : "" + }`} {activeTab === "burn" && - t('mint.burnConfirmDescription', { amount: formatAmount(burnAmount) })} + `Burn ACBU ${formatAmount(burnAmount)} and withdraw to bank account`}
- {t('mint.amount')}: + Amount: {activeTab === "mint" @@ -980,14 +1068,14 @@ export default function MintPage() { onClick={() => setStep("input")} disabled={executing} > - {t('mint.cancel')} + Cancel - {executing ? t('mint.processing') : t('mint.confirm')} + {executing ? "Processing..." : "Confirm"}
@@ -996,23 +1084,27 @@ export default function MintPage() { - {t('mint.operationComplete')} + Operation Complete - {t('mint.operationSubmitted', { operation: activeTab })} + Your {activeTab} operation has been submitted + successfully.

- {t('mint.transactionId')}: {txId ?? "—"} + Transaction ID: {txId ?? "—"}

{activeTab === "mint" && mintAcbuReceived != null && (

- {t('mint.acbuCredited', { amount: formatAmount(mintAcbuReceived) })} + ACBU credited (app ledger):{" "} + {formatAmount(mintAcbuReceived)} ACBU

)} {activeTab === "mint" && (

- {t('mint.mintDisclaimer')} + If the Stellar mint contract errors, tokens are still + recorded in the app; your balance may show + "app ledger" until on-chain mint works.

)}
@@ -1020,7 +1112,7 @@ export default function MintPage() { onClick={resetForm} className="bg-primary text-primary-foreground hover:bg-primary/90" > - {t('mint.done')} + Done
diff --git a/app/send/page.tsx b/app/send/page.tsx index 668f3e9..db472ed 100644 --- a/app/send/page.tsx +++ b/app/send/page.tsx @@ -25,13 +25,11 @@ import { import { Badge } from "@/components/ui/badge"; import { Tabs, TabsContent, TabsTrigger, TabsList } from "@/components/ui/tabs"; import { SkeletonList } from "@/components/ui/skeleton-list"; -import { ApiErrorDisplay } from "@/components/ui/api-error-display"; import { Plus, Check, AlertCircle, ArrowRight } from "lucide-react"; import { useApiOpts } from "@/hooks/use-api"; import { useApiError } from "@/hooks/use-api-error"; import { useI18n } from "@/contexts/i18n-context"; import { useBalance } from "@/hooks/use-balance"; -import { RetryErrorBlock } from "@/components/ui/retry-error-block"; import { useAuth } from "@/contexts/auth-context"; import * as transfersApi from "@/lib/api/transfers"; import * as userApi from "@/lib/api/user"; @@ -57,7 +55,7 @@ import { useScrollRestoration } from "@/hooks/use-scroll-restoration"; import { useNavigationGuard } from "@/contexts/navigation-guard-context"; function formatDate(iso: string) { - const d = parseUtcDate(iso); + const d = new Date(iso); const today = new Date(); if (d.toDateString() === today.toDateString()) return "Today"; const yesterday = new Date(today); @@ -91,7 +89,6 @@ function getStatusBadgeClassName(status: string): string { export default function SendPage() { const opts = useApiOpts(); const { userId, stellarAddress } = useAuth(); - const { ensureSession } = useSessionGuard(); const kit = useStellarWalletsKit(); const { balance, @@ -333,7 +330,7 @@ export default function SendPage() { ); loadTransfers(); - refetchBalance(); + refreshBalance(); setShowConfirmDialog(false); setShowSendDialog(false); setLastSentAmount(confirmedAmount); @@ -570,7 +567,7 @@ export default function SendPage() { id="send-recipient-address" placeholder={t("send.walletAddressOrEmail")} value={customRecipient} - onChange={handleCustomRecipientChange} + onChange={handleCustomRecipientChange} className="border-border" autoComplete="off" /> @@ -653,7 +650,8 @@ export default function SendPage() { - + {/* Confirm Dialog */} + {t("send.confirmTransfer")} @@ -703,7 +701,7 @@ export default function SendPage() { - +
@@ -719,6 +717,6 @@ export default function SendPage() {
- + ); } diff --git a/hooks/use-balance.ts b/hooks/use-balance.ts index 313b9b5..a3d4b3c 100644 --- a/hooks/use-balance.ts +++ b/hooks/use-balance.ts @@ -34,7 +34,10 @@ export function useBalance(): UseBalanceReturn { const refetch = useCallback(() => setTick((t) => t + 1), []); const refresh = refetch; - // Auto-refresh balance every 30 seconds to catch external transactions + // Auto-refresh balance every 30 seconds to catch external transactions. + // No stale closure risk: `interval` is captured in the same effect scope, so + // the cleanup always clears the correct interval ID. `refresh` is stable + // (useCallback with no deps) because setTick uses a functional updater. useEffect(() => { const interval = setInterval(() => { refetch();