Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
22 changes: 19 additions & 3 deletions src/app/dashboard/wallets/[id]/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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();
Expand Down Expand Up @@ -230,8 +245,9 @@ export default function WalletOverview({
disabled={hasUsdc}
/>
<ActionButton
label="Refresh balances"
onClick={() => token && getBalances(token, id).then(setBalances)}
label={refreshingBalances ? "Refreshing…" : "Refresh balances"}
onClick={refreshBalances}
disabled={refreshingBalances}
/>
<ActionButton label="Recover wallet" onClick={() => setShowRecover(true)} />
<ActionButton label="Change wallet password" onClick={() => setShowChangePassword(true)} />
Expand Down
38 changes: 34 additions & 4 deletions src/app/pay/[slug]/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -121,8 +122,11 @@ export default function PayPage({
const [networkPassphrase, setNetworkPassphrase] = useState<string>("");
// Ticking clock driving the expiry countdown; null until an intent with an expiry exists.
const [now, setNow] = useState<number | null>(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);
Expand All @@ -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 = "") {
Expand Down Expand Up @@ -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}`
: "$—"}
</p>
Expand Down Expand Up @@ -449,6 +456,22 @@ export default function PayPage({
</div>
)}

{step === "error" && (
<div className="flex h-full items-center justify-center text-center">
<div>
<p className="text-lg font-semibold text-gray-900">Couldn&apos;t load this payment link</p>
<p className="mt-2 text-sm text-gray-500">Check your connection and try again.</p>
<button
type="button"
onClick={() => setAttempt((n) => n + 1)}
className="mt-4 rounded-lg bg-black px-4 py-2 text-sm font-medium text-white hover:opacity-90"
>
Try again
</button>
</div>
</div>
)}

{step === "not-found" && (
<div className="flex h-full items-center justify-center text-center">
<div>
Expand All @@ -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 && (
<p role="alert" className="mt-1 text-xs text-red-600">
Enter an amount in USD, up to 7 decimal places.
</p>
)}
</Field>
)}

Expand Down
28 changes: 27 additions & 1 deletion src/components/auth/ForgotPasswordForm.tsx
Original file line number Diff line number Diff line change
@@ -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";
Expand All @@ -23,6 +23,13 @@ export function ForgotPasswordForm() {
const [confirm, setConfirm] = useState("");
const [error, setError] = useState<string | null>(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();
Expand Down Expand Up @@ -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) {
Expand Down Expand Up @@ -135,6 +153,14 @@ export function ForgotPasswordForm() {
If an account exists for that email, we sent a 6-digit code to it.
</p>
<OtpInput value={code} onChange={setCode} />
<button
type="button"
onClick={onResend}
disabled={cooldown > 0}
className="text-sm font-medium text-burgundy-bright hover:underline disabled:cursor-not-allowed disabled:text-muted disabled:no-underline"
>
{cooldown > 0 ? `Resend code in ${cooldown}s` : "Resend code"}
</button>
{errorBox}
<button type="submit" className={buttonClass}>
Continue
Expand Down