diff --git a/src/app/dashboard/wallets/[id]/page.tsx b/src/app/dashboard/wallets/[id]/page.tsx index db54d97..bc61fe2 100644 --- a/src/app/dashboard/wallets/[id]/page.tsx +++ b/src/app/dashboard/wallets/[id]/page.tsx @@ -62,13 +62,28 @@ export default function WalletOverview({ const [showTrustline, setShowTrustline] = useState(false); const [showRecover, setShowRecover] = useState(false); const [showChangePassword, setShowChangePassword] = useState(false); + const [refreshingBalances, setRefreshingBalances] = useState(false); function refresh() { if (!token) return; - getBalances(token, id).then(setBalances).catch(() => {}); + getBalances(token, id) + .then(setBalances) + .catch((e) => toast.error(errorMessage(e, "Could not refresh balances."))); listRecentTransactions(token, id).then(setTxns).catch(() => {}); } + async function refreshBalances() { + if (!token) return; + setRefreshingBalances(true); + try { + setBalances(await getBalances(token, id)); + } catch (e) { + toast.error(errorMessage(e, "Could not refresh balances.")); + } finally { + setRefreshingBalances(false); + } + } + useEffect(() => { if (!token) return; const controller = new AbortController(); @@ -230,8 +245,9 @@ export default function WalletOverview({ disabled={hasUsdc} /> token && getBalances(token, id).then(setBalances)} + label={refreshingBalances ? "Refreshing…" : "Refresh balances"} + onClick={refreshBalances} + disabled={refreshingBalances} /> setShowRecover(true)} /> setShowChangePassword(true)} /> diff --git a/src/app/pay/[slug]/page.tsx b/src/app/pay/[slug]/page.tsx index aa9711f..634a7c6 100644 --- a/src/app/pay/[slug]/page.tsx +++ b/src/app/pay/[slug]/page.tsx @@ -24,11 +24,12 @@ import { OctoSpinner } from "@/components/OctoSpinner"; import { PayerPrivacyNotice } from "@/components/checkout/PayerPrivacyNotice"; import confetti from "canvas-confetti"; import { formatStroops } from "@/lib/amount"; +import { ApiError } from "@/lib/api"; import { friendlyResultMessage } from "@/lib/stellar/resultCodes"; import { isTrustedImageUrl } from "@/lib/isTrustedImageUrl"; import { validateRedirectUrl } from "@/lib/redirectUrl"; -type Step = "loading" | "not-found" | "form" | "pay" | "confirmed"; +type Step = "loading" | "not-found" | "error" | "form" | "pay" | "confirmed"; /** Keep the loading spinner up for at least this long so it doesn't just flash on fast loads. */ const MIN_LOADING_MS = 700; @@ -121,8 +122,11 @@ export default function PayPage({ const [networkPassphrase, setNetworkPassphrase] = useState(""); // Ticking clock driving the expiry countdown; null until an intent with an expiry exists. const [now, setNow] = useState(null); + // Bumped by the retry button to load the link again. + const [attempt, setAttempt] = useState(0); useEffect(() => { + setStep("loading"); const startedAt = Date.now(); const finish = (next: Step) => { const remaining = MIN_LOADING_MS - (Date.now() - startedAt); @@ -140,8 +144,11 @@ export default function PayPage({ } finish("form"); }) - .catch(() => finish("not-found")); - }, [slug]); + .catch((err: unknown) => { + const gone = err instanceof ApiError && (err.status === 404 || err.status === 410); + finish(gone ? "not-found" : "error"); + }); + }, [slug, attempt]); // Swaps the active intent, resetting everything that belonged to the previous one. function replaceIntent(next: PaymentIntent | null, key = "") { @@ -419,7 +426,7 @@ export default function PayPage({ ? `$${formatStroops(link.amount_usdc_stroops)}` : step !== "form" && intent ? `$${formatStroops(intent.amount_usdc_stroops)}` - : amount + : amount && usdAmountToStroops(amount) !== null ? `$${amount}` : "$—"}

@@ -449,6 +456,22 @@ export default function PayPage({ )} + {step === "error" && ( +
+
+

Couldn't load this payment link

+

Check your connection and try again.

+ +
+
+ )} + {step === "not-found" && (
@@ -470,9 +493,16 @@ export default function PayPage({ id="payer-amount" value={amount} onChange={(e) => setAmount(e.target.value)} + inputMode="decimal" + aria-invalid={amount.trim() !== "" && usdAmountToStroops(amount) === null} placeholder="0.00" className="w-full rounded-lg border border-gray-300 px-3 py-2.5 text-sm text-gray-900 outline-none focus:border-black" /> + {amount.trim() !== "" && usdAmountToStroops(amount) === null && ( +

+ Enter an amount in USD, up to 7 decimal places. +

+ )} )} diff --git a/src/components/auth/ForgotPasswordForm.tsx b/src/components/auth/ForgotPasswordForm.tsx index 1d69a9a..2f2a01d 100644 --- a/src/components/auth/ForgotPasswordForm.tsx +++ b/src/components/auth/ForgotPasswordForm.tsx @@ -1,6 +1,6 @@ "use client"; -import { useState } from "react"; +import { useEffect, useState } from "react"; import Link from "next/link"; import { ApiError } from "@/lib/api"; import { requestPasswordReset, resetPassword, MIN_ACCOUNT_PASSWORD_LENGTH } from "@/lib/passwordApi"; @@ -23,6 +23,13 @@ export function ForgotPasswordForm() { const [confirm, setConfirm] = useState(""); const [error, setError] = useState(null); const [busy, setBusy] = useState(false); + const [cooldown, setCooldown] = useState(0); + + useEffect(() => { + if (cooldown <= 0) return; + const timer = setTimeout(() => setCooldown((s) => s - 1), 1000); + return () => clearTimeout(timer); + }, [cooldown]); async function onEmail(e: React.FormEvent) { e.preventDefault(); @@ -52,6 +59,17 @@ export function ForgotPasswordForm() { setStep("password"); } + // Sends a fresh code. The 30 s cooldown matches the signup resend button. + async function onResend() { + setError(null); + try { + await requestPasswordReset(email.trim()); + setCooldown(30); + } catch (err) { + setError(err instanceof Error ? err.message : "Could not resend the code."); + } + } + async function onPassword(e: React.FormEvent) { e.preventDefault(); if (password.length < MIN_ACCOUNT_PASSWORD_LENGTH) { @@ -135,6 +153,14 @@ export function ForgotPasswordForm() { If an account exists for that email, we sent a 6-digit code to it.

+ {errorBox}