From 4c85be0795d6f11f183669e63f22e56af7db2408 Mon Sep 17 00:00:00 2001 From: amaka Date: Sun, 18 Oct 2026 18:56:05 +0100 Subject: [PATCH 1/4] fix: show one fixed number of recent transactions on the wallet overview (#532) The first load, the poll and the retry now all use the same overview limit, and the panel links to the full Transactions page. Closes #532 Co-Authored-By: Claude Haiku 5.5 --- src/app/dashboard/wallets/[id]/page.tsx | 18 ++++++++++++++---- src/lib/wallets.ts | 7 +++++-- 2 files changed, 19 insertions(+), 6 deletions(-) diff --git a/src/app/dashboard/wallets/[id]/page.tsx b/src/app/dashboard/wallets/[id]/page.tsx index db54d97..594d920 100644 --- a/src/app/dashboard/wallets/[id]/page.tsx +++ b/src/app/dashboard/wallets/[id]/page.tsx @@ -1,6 +1,7 @@ "use client"; import { use, useCallback, useEffect, useState } from "react"; +import Link from "next/link"; import { toast } from "sonner"; import { TrustlineModal, DepositModal, WithdrawModal } from "@/components/wallets/WalletModals"; import { RecoverWalletModal } from "@/components/wallets/RecoverWalletModal"; @@ -11,7 +12,6 @@ import { errorMessage } from "@/lib/api"; import { getBalances, listAddresses, - listTransactions, listRecentTransactions, createAddress, type Balance, @@ -83,7 +83,7 @@ export default function WalletOverview({ if (e instanceof DOMException && e.name === "AbortError") return; setAddresses([]); }), - listTransactions(token, id, { signal: controller.signal }).then(setTxns).catch((e) => { + listRecentTransactions(token, id, { signal: controller.signal }).then(setTxns).catch((e) => { if (e instanceof DOMException && e.name === "AbortError") return; setTxns([]); }), @@ -170,7 +170,7 @@ export default function WalletOverview({ Promise.all([ getBalances(token, id).then(setBalances).catch((e) => setLoadError(errorMessage(e, "Could not load wallet data."))), listAddresses(token, id).then(setAddresses).catch(() => {}), - listTransactions(token, id).then(setTxns).catch(() => {}), + listRecentTransactions(token, id).then(setTxns).catch(() => {}), ]).finally(() => setStatsLoading(false)); } }} /> @@ -319,7 +319,17 @@ export default function WalletOverview({ {/* recent transactions */} - + + View all + + } + > {statsLoading ? (
diff --git a/src/lib/wallets.ts b/src/lib/wallets.ts index 284299b..509b7b1 100644 --- a/src/lib/wallets.ts +++ b/src/lib/wallets.ts @@ -198,13 +198,16 @@ export function createAddress( }); } +/** Rows shown on the wallet overview. Every overview fetch uses this one limit. */ +export const RECENT_TRANSACTIONS_LIMIT = 10; + /** First page of transactions up to `limit` rows — name reflects the partial view returned. */ export async function listRecentTransactions( token: string, id: string, - limitOrOptions: number | RequestOptions = 20, + limitOrOptions: number | RequestOptions = RECENT_TRANSACTIONS_LIMIT, ): Promise { - const limit = typeof limitOrOptions === "number" ? limitOrOptions : 20; + const limit = typeof limitOrOptions === "number" ? limitOrOptions : RECENT_TRANSACTIONS_LIMIT; const options = typeof limitOrOptions === "number" ? {} : limitOrOptions; const page = await apiFetch>( path`/v1/wallets/${id}/transactions` + pageQuery({ limit }), From b84b453c5c0b988a808464cddbf191fcd996f0e3 Mon Sep 17 00:00:00 2001 From: amaka Date: Sun, 18 Oct 2026 18:56:06 +0100 Subject: [PATCH 2/4] fix: enforce wallet name and description limits on the create form (#533) Closes #533 Co-Authored-By: Claude Haiku 5.5 --- src/app/dashboard/wallets/new/NewWalletClient.tsx | 12 ++++++++++++ 1 file changed, 12 insertions(+) diff --git a/src/app/dashboard/wallets/new/NewWalletClient.tsx b/src/app/dashboard/wallets/new/NewWalletClient.tsx index 236068d..4a3793b 100644 --- a/src/app/dashboard/wallets/new/NewWalletClient.tsx +++ b/src/app/dashboard/wallets/new/NewWalletClient.tsx @@ -28,6 +28,7 @@ import { type PasswordAssessment, } from "@/lib/passwordStrength"; import { DashboardShell } from "@/components/dashboard/DashboardShell"; +import { MAX_WALLET_DESCRIPTION, MAX_WALLET_LABEL } from "@/lib/walletDetailsApi"; import { ConfirmModal } from "@/components/dashboard/Modal"; import { PageSpinner } from "@/components/OctoSpinner"; @@ -100,6 +101,15 @@ export function NewWalletClient() { if (!token) return; setError(null); + if (name.trim().length > MAX_WALLET_LABEL) { + setError(`Wallet name must be ${MAX_WALLET_LABEL} characters or fewer.`); + return; + } + if (description.trim().length > MAX_WALLET_DESCRIPTION) { + setError(`Wallet description must be ${MAX_WALLET_DESCRIPTION} characters or fewer.`); + return; + } + if (!pendingCreation.current) { // Re-assess on submit so the decision never depends on a stale live estimate. let assessment: PasswordAssessment; @@ -272,6 +282,7 @@ export function NewWalletClient() { Date: Sun, 18 Oct 2026 18:56:32 +0100 Subject: [PATCH 3/4] fix: derive the image picker accept list and hint from one allowed-types list (#534) Closes #534 Co-Authored-By: Claude Haiku 5.5 --- .../payment-links/CreatePaymentLinkForm.tsx | 6 +++--- src/components/payment-links/EditPaymentLinkForm.tsx | 4 ++-- src/lib/uploads.ts | 12 +++++++++++- 3 files changed, 16 insertions(+), 6 deletions(-) diff --git a/src/components/payment-links/CreatePaymentLinkForm.tsx b/src/components/payment-links/CreatePaymentLinkForm.tsx index b29acf3..458cfc7 100644 --- a/src/components/payment-links/CreatePaymentLinkForm.tsx +++ b/src/components/payment-links/CreatePaymentLinkForm.tsx @@ -3,7 +3,7 @@ import { useState, type FormEvent } from "react"; import { Modal } from "@/components/dashboard/Modal"; import { ActionButton } from "@/components/dashboard/WalletUI"; -import { uploadImage, validateImage } from "@/lib/uploads"; +import { ALLOWED_IMAGE_HINT, ALLOWED_IMAGE_TYPES, uploadImage, validateImage } from "@/lib/uploads"; import { isTrustedImageUrl } from "@/lib/isTrustedImageUrl"; import { validateRedirectUrl } from "@/lib/redirectUrl"; import { asAuthToken, asWalletId } from "@/lib/brands"; @@ -99,7 +99,7 @@ export function CreatePaymentLinkForm({
diff --git a/src/components/payment-links/EditPaymentLinkForm.tsx b/src/components/payment-links/EditPaymentLinkForm.tsx index ffd1453..f661cd5 100644 --- a/src/components/payment-links/EditPaymentLinkForm.tsx +++ b/src/components/payment-links/EditPaymentLinkForm.tsx @@ -7,7 +7,7 @@ import { formatStroops } from "@/lib/amount"; import { isTrustedImageUrl } from "@/lib/isTrustedImageUrl"; import type { PaymentLink } from "@/lib/payment-links"; import { updatePaymentLink, validateRedirectUrl } from "@/lib/paymentLinkEditApi"; -import { uploadImage, validateImage } from "@/lib/uploads"; +import { ALLOWED_IMAGE_TYPES, uploadImage, validateImage } from "@/lib/uploads"; const inputCls = "mt-1 w-full rounded-lg border border-border bg-surface-sunken px-3 py-2 text-sm text-foreground outline-none focus:border-burgundy/50"; @@ -91,7 +91,7 @@ export function EditPaymentLinkForm({
{ return body.secure_url as string; } -const ALLOWED_IMAGE_TYPES = ['image/png', 'image/jpeg', 'image/webp', 'image/gif'] as const; +export const ALLOWED_IMAGE_TYPES = ['image/png', 'image/jpeg', 'image/webp', 'image/gif'] as const; type AllowedImageType = (typeof ALLOWED_IMAGE_TYPES)[number]; +const FORMAT_NAMES: Record = { + 'image/png': 'PNG', + 'image/jpeg': 'JPEG', + 'image/webp': 'WebP', + 'image/gif': 'GIF', +}; + +/** Hint text for image pickers, generated from ALLOWED_IMAGE_TYPES. */ +export const ALLOWED_IMAGE_HINT = `${ALLOWED_IMAGE_TYPES.map((type) => FORMAT_NAMES[type]).join(', ')}, up to 2MB.`; + const MAGIC_NUMBERS: Record = { 'image/png': [[0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]], 'image/jpeg': [[0xff, 0xd8, 0xff]], From be677b73ef1939b93e8bb427171e0d9603e18c01 Mon Sep 17 00:00:00 2001 From: amaka Date: Sun, 18 Oct 2026 18:56:50 +0100 Subject: [PATCH 4/4] fix: upload payment-link images on save instead of on pick (#535) Picked files are validated when chosen and uploaded only when the link is created or saved, so cancelled forms leave nothing behind in storage. Closes #535 Co-Authored-By: Claude Haiku 5.5 --- .../payment-links/CreatePaymentLinkForm.tsx | 49 +++++++++---------- .../payment-links/EditPaymentLinkForm.tsx | 36 ++++++++------ 2 files changed, 43 insertions(+), 42 deletions(-) diff --git a/src/components/payment-links/CreatePaymentLinkForm.tsx b/src/components/payment-links/CreatePaymentLinkForm.tsx index 458cfc7..f732cca 100644 --- a/src/components/payment-links/CreatePaymentLinkForm.tsx +++ b/src/components/payment-links/CreatePaymentLinkForm.tsx @@ -4,7 +4,6 @@ import { useState, type FormEvent } from "react"; import { Modal } from "@/components/dashboard/Modal"; import { ActionButton } from "@/components/dashboard/WalletUI"; import { ALLOWED_IMAGE_HINT, ALLOWED_IMAGE_TYPES, uploadImage, validateImage } from "@/lib/uploads"; -import { isTrustedImageUrl } from "@/lib/isTrustedImageUrl"; import { validateRedirectUrl } from "@/lib/redirectUrl"; import { asAuthToken, asWalletId } from "@/lib/brands"; import { usdAmountToStroops, createPaymentLink, type PaymentLink } from "@/lib/payment-links"; @@ -28,23 +27,24 @@ export function CreatePaymentLinkForm({ const [description, setDescription] = useState(""); const [amount, setAmount] = useState(""); const [redirectUrl, setRedirectUrl] = useState(""); - const [imageUrl, setImageUrl] = useState(null); - const [uploading, setUploading] = useState(false); + const [imageFile, setImageFile] = useState(null); const [error, setError] = useState(null); + // Only checks the picked file. Nothing is uploaded until the link is created. async function handleImageChange(e: React.ChangeEvent) { - const file = e.target.files?.[0]; - if (!file || !token) return; - setUploading(true); + const file = e.target.files?.[0] ?? null; setError(null); - try { - await validateImage(file); - setImageUrl(await uploadImage(token, file)); - } catch (err) { - setError(err instanceof Error ? err.message : "Image upload failed."); - } finally { - setUploading(false); + if (file) { + try { + await validateImage(file); + } catch (err) { + e.target.value = ""; + setImageFile(null); + setError(err instanceof Error ? err.message : "Image upload failed."); + return; + } } + setImageFile(file); } async function handleSubmit(e: FormEvent) { @@ -65,10 +65,11 @@ export function CreatePaymentLinkForm({ setCreating(false); return; } + const uploadedImageUrl = imageFile ? await uploadImage(token, imageFile) : undefined; const link = await createPaymentLink(asAuthToken(token), asWalletId(walletId), { name: name.trim(), description: description.trim() || undefined, - imageUrl: imageUrl ?? undefined, + imageUrl: uploadedImageUrl, redirectUrl: redirectUrl.trim() || undefined, amountUsdcStroops: amountUsdcStroops ?? undefined, }); @@ -89,27 +90,21 @@ export function CreatePaymentLinkForm({
- {isTrustedImageUrl(imageUrl) ? ( - // eslint-disable-next-line @next/next/no-img-element - - ) : ( - 🖼 - )} + + {imageFile ? imageFile.name : "🖼"} +

- {uploading - ? "Uploading…" - : imageUrl - ? "Uploaded. Choose another file to replace it." - : ALLOWED_IMAGE_HINT} + {imageFile + ? "Uploads when you create the link. Choose another file to replace it." + : ALLOWED_IMAGE_HINT}

@@ -178,7 +173,7 @@ export function CreatePaymentLinkForm({
diff --git a/src/components/payment-links/EditPaymentLinkForm.tsx b/src/components/payment-links/EditPaymentLinkForm.tsx index f661cd5..c6769d8 100644 --- a/src/components/payment-links/EditPaymentLinkForm.tsx +++ b/src/components/payment-links/EditPaymentLinkForm.tsx @@ -29,24 +29,27 @@ export function EditPaymentLinkForm({ const [name, setName] = useState(link.name); const [description, setDescription] = useState(link.description ?? ""); const [redirectUrl, setRedirectUrl] = useState(link.redirect_url ?? ""); + // imageUrl is the stored image; a picked file replaces it only when the form is saved. const [imageUrl, setImageUrl] = useState(link.image_url); - const [uploading, setUploading] = useState(false); + const [imageFile, setImageFile] = useState(null); const [saving, setSaving] = useState(false); const [error, setError] = useState(null); + // Only checks the picked file. It uploads when the form is saved. async function handleImageChange(e: React.ChangeEvent) { - const file = e.target.files?.[0]; - if (!file) return; - setUploading(true); + const file = e.target.files?.[0] ?? null; setError(null); - try { - await validateImage(file); - setImageUrl(await uploadImage(token, file)); - } catch (err) { - setError(err instanceof Error ? err.message : "Image upload failed."); - } finally { - setUploading(false); + if (file) { + try { + await validateImage(file); + } catch (err) { + e.target.value = ""; + setImageFile(null); + setError(err instanceof Error ? err.message : "Image upload failed."); + return; + } } + setImageFile(file); } async function handleSubmit(e: FormEvent) { @@ -60,11 +63,12 @@ export function EditPaymentLinkForm({ setSaving(true); setError(null); try { + const nextImageUrl = imageFile ? await uploadImage(token, imageFile) : imageUrl; onSaved( await updatePaymentLink(asAuthToken(token), asWalletId(walletId), link.id, { name: name.trim(), description: description.trim() || undefined, - imageUrl, + imageUrl: nextImageUrl, redirectUrl: redirectUrl.trim() || undefined, }), ); @@ -93,13 +97,15 @@ export function EditPaymentLinkForm({ type="file" accept={ALLOWED_IMAGE_TYPES.join(",")} onChange={handleImageChange} - disabled={uploading} className="block w-full text-xs text-muted file:mr-3 file:rounded-lg file:border file:border-border file:bg-surface-raised file:px-3 file:py-1.5 file:text-xs file:text-foreground hover:file:border-burgundy/50" /> {imageUrl && (