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
18 changes: 14 additions & 4 deletions src/app/dashboard/wallets/[id]/page.tsx
Original file line number Diff line number Diff line change
@@ -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";
Expand All @@ -11,7 +12,6 @@ import { errorMessage } from "@/lib/api";
import {
getBalances,
listAddresses,
listTransactions,
listRecentTransactions,
createAddress,
type Balance,
Expand Down Expand Up @@ -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([]);
}),
Expand Down Expand Up @@ -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));
}
}} />
Expand Down Expand Up @@ -319,7 +319,17 @@ export default function WalletOverview({
</div>

{/* recent transactions */}
<Panel title="Most recent transactions">
<Panel
title="Most recent transactions"
action={
<Link
href={`/dashboard/wallets/${id}/transactions`}
className="text-xs font-medium text-burgundy-bright hover:underline"
>
View all
</Link>
}
>
{statsLoading ? (
<div className="overflow-x-auto">
<table className="w-full text-left text-sm">
Expand Down
12 changes: 12 additions & 0 deletions src/app/dashboard/wallets/new/NewWalletClient.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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";

Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -272,6 +282,7 @@ export function NewWalletClient() {
<input
id="wallet-name"
value={name}
maxLength={MAX_WALLET_LABEL}
onChange={editField(setName)}
placeholder="e.g. Acme master wallet"
className="mt-2 w-full rounded-xl border border-border bg-surface-raised px-4 py-3 text-sm text-foreground placeholder:text-muted/60 focus:border-burgundy-bright focus:outline-none"
Expand All @@ -288,6 +299,7 @@ export function NewWalletClient() {
<textarea
id="wallet-description"
value={description}
maxLength={MAX_WALLET_DESCRIPTION}
onChange={editField(setDescription)}
placeholder="What is this wallet for?"
rows={2}
Expand Down
53 changes: 24 additions & 29 deletions src/components/payment-links/CreatePaymentLinkForm.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -3,8 +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 { isTrustedImageUrl } from "@/lib/isTrustedImageUrl";
import { ALLOWED_IMAGE_HINT, ALLOWED_IMAGE_TYPES, uploadImage, validateImage } from "@/lib/uploads";
import { validateRedirectUrl } from "@/lib/redirectUrl";
import { asAuthToken, asWalletId } from "@/lib/brands";
import { usdAmountToStroops, createPaymentLink, type PaymentLink } from "@/lib/payment-links";
Expand All @@ -28,23 +27,24 @@ export function CreatePaymentLinkForm({
const [description, setDescription] = useState("");
const [amount, setAmount] = useState("");
const [redirectUrl, setRedirectUrl] = useState("");
const [imageUrl, setImageUrl] = useState<string | null>(null);
const [uploading, setUploading] = useState(false);
const [imageFile, setImageFile] = useState<File | null>(null);
const [error, setError] = useState<string | null>(null);

// Only checks the picked file. Nothing is uploaded until the link is created.
async function handleImageChange(e: React.ChangeEvent<HTMLInputElement>) {
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) {
Expand All @@ -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,
});
Expand All @@ -89,27 +90,21 @@ export function CreatePaymentLinkForm({
</label>
<div className="mt-1 flex items-center gap-3">
<div className="flex h-16 w-16 shrink-0 items-center justify-center overflow-hidden rounded-lg border border-border bg-surface-sunken">
{isTrustedImageUrl(imageUrl) ? (
// eslint-disable-next-line @next/next/no-img-element
<img src={imageUrl!} alt="" className="h-full w-full object-cover" />
) : (
<span className="text-lg text-muted">🖼</span>
)}
<span className="px-1 text-center text-[10px] text-muted">
{imageFile ? imageFile.name : "🖼"}
</span>
</div>
<div className="flex-1">
<input
type="file"
accept="image/*"
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"
/>
<p className="mt-1 text-[11px] text-muted">
{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}
</p>
</div>
</div>
Expand Down Expand Up @@ -178,7 +173,7 @@ export function CreatePaymentLinkForm({
</button>
<ActionButton
label={creating ? "Creating…" : "Create Link"}
disabled={!name.trim() || creating || uploading}
disabled={!name.trim() || creating}
loading={creating}
/>
</div>
Expand Down
40 changes: 23 additions & 17 deletions src/components/payment-links/EditPaymentLinkForm.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand All @@ -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<string | null>(link.image_url);
const [uploading, setUploading] = useState(false);
const [imageFile, setImageFile] = useState<File | null>(null);
const [saving, setSaving] = useState(false);
const [error, setError] = useState<string | null>(null);

// Only checks the picked file. It uploads when the form is saved.
async function handleImageChange(e: React.ChangeEvent<HTMLInputElement>) {
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) {
Expand All @@ -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,
}),
);
Expand All @@ -91,15 +95,17 @@ export function EditPaymentLinkForm({
<div className="flex-1">
<input
type="file"
accept="image/*"
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 && (
<button
type="button"
onClick={() => setImageUrl(null)}
onClick={() => {
setImageUrl(null);
setImageFile(null);
}}
className="mt-1 text-[11px] text-muted hover:text-foreground"
>
Remove image
Expand Down Expand Up @@ -159,7 +165,7 @@ export function EditPaymentLinkForm({
<ActionButton
type="submit"
label={saving ? "Saving…" : "Save changes"}
disabled={!name.trim() || saving || uploading}
disabled={!name.trim() || saving}
loading={saving}
/>
</div>
Expand Down
12 changes: 11 additions & 1 deletion src/lib/uploads.ts
Original file line number Diff line number Diff line change
Expand Up @@ -47,10 +47,20 @@ export async function uploadImage(token: string, file: File): Promise<string> {
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<AllowedImageType, string> = {
'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<AllowedImageType, number[][]> = {
'image/png': [[0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]],
'image/jpeg': [[0xff, 0xd8, 0xff]],
Expand Down
7 changes: 5 additions & 2 deletions src/lib/wallets.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<Transaction[]> {
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<Paginated<Transaction>>(
path`/v1/wallets/${id}/transactions` + pageQuery({ limit }),
Expand Down