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}