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/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() { (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." - : "PNG or JPG, up to 2MB."} + {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 ffd1453..c6769d8 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"; @@ -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, }), ); @@ -91,15 +95,17 @@ export function EditPaymentLinkForm({
{imageUrl && (
diff --git a/src/lib/uploads.ts b/src/lib/uploads.ts index 776e71f..8fbc7eb 100644 --- a/src/lib/uploads.ts +++ b/src/lib/uploads.ts @@ -47,10 +47,20 @@ export async function uploadImage(token: string, file: File): Promise { 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]], 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 }),