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
2 changes: 1 addition & 1 deletion frontend/messages/en.json
Original file line number Diff line number Diff line change
Expand Up @@ -169,7 +169,7 @@
"filteredSuffix": "(filters applied)",
"tableStatus": "Status",
"tableAmount": "Amount",
"tableDescription": "Description",
"tableRecipient": "Recipient",
"tableDate": "Date",
"tableLink": "Link",
"view": "View",
Expand Down
2 changes: 1 addition & 1 deletion frontend/messages/es.json
Original file line number Diff line number Diff line change
Expand Up @@ -169,7 +169,7 @@
"filteredSuffix": "(con filtros)",
"tableStatus": "Estado",
"tableAmount": "Monto",
"tableDescription": "Descripcion",
"tableRecipient": "Destinatario",
"tableDate": "Fecha",
"tableLink": "Enlace",
"view": "Ver",
Expand Down
2 changes: 1 addition & 1 deletion frontend/messages/pt.json
Original file line number Diff line number Diff line change
Expand Up @@ -169,7 +169,7 @@
"filteredSuffix": "(com filtros)",
"tableStatus": "Status",
"tableAmount": "Valor",
"tableDescription": "Descricao",
"tableRecipient": "Destinatario",
"tableDate": "Data",
"tableLink": "Link",
"view": "Ver",
Expand Down
240 changes: 218 additions & 22 deletions frontend/src/components/RecentPayments.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@ interface Payment {
id: string;
amount: string;
asset: string;
recipient: string;
status: string;
description: string | null;
created_at: string;
Expand All @@ -38,6 +39,9 @@ interface FilterState {
dateTo: string;
}

type SortColumn = "status" | "amount" | "recipient" | "created_at";
type SortDirection = "asc" | "desc";

const LIMIT = 100;
const STATUS_OPTIONS = ["all", "pending", "confirmed", "failed", "refunded"] as const;
const ASSET_OPTIONS = ["all", "XLM", "USDC"] as const;
Expand All @@ -48,6 +52,8 @@ const DEFAULT_FILTERS: FilterState = {
dateFrom: "",
dateTo: "",
};
const DEFAULT_SORT_COLUMN: SortColumn = "created_at";
const DEFAULT_SORT_DIRECTION: SortDirection = "desc";

function toStatusLabel(
t: ReturnType<typeof useTranslations>,
Expand All @@ -66,18 +72,68 @@ function filtersFromSearchParams(searchParams: URLSearchParams): FilterState {
};
}

function buildSearchParams(filters: FilterState): URLSearchParams {
function isSortColumn(value: string | null): value is SortColumn {
return (
value === "status" ||
value === "amount" ||
value === "recipient" ||
value === "created_at"
);
}

function isSortDirection(value: string | null): value is SortDirection {
return value === "asc" || value === "desc";
}

function sortFromSearchParams(searchParams: URLSearchParams) {
const sortColumn = searchParams.get("sortColumn");
const sortDirection = searchParams.get("sortDirection");

return {
sortColumn: isSortColumn(sortColumn) ? sortColumn : DEFAULT_SORT_COLUMN,
sortDirection: isSortDirection(sortDirection)
? sortDirection
: DEFAULT_SORT_DIRECTION,
};
}

function buildSearchParams(
filters: FilterState,
sortColumn: SortColumn,
sortDirection: SortDirection,
): URLSearchParams {
const params = new URLSearchParams();

if (filters.search) params.set("search", filters.search);
if (filters.status !== "all") params.set("status", filters.status);
if (filters.asset !== "all") params.set("asset", filters.asset);
if (filters.dateFrom) params.set("date_from", filters.dateFrom);
if (filters.dateTo) params.set("date_to", filters.dateTo);
if (sortColumn !== DEFAULT_SORT_COLUMN) params.set("sortColumn", sortColumn);
if (sortDirection !== DEFAULT_SORT_DIRECTION) {
params.set("sortDirection", sortDirection);
}

return params;
}

function SortArrow({
active,
direction,
}: {
active: boolean;
direction: SortDirection;
}) {
return (
<span
aria-hidden="true"
className={`inline-flex transition-opacity ${active ? "opacity-100" : "opacity-35"}`}
>
{direction === "asc" ? "\u2191" : "\u2193"}
</span>
);
}

export default function RecentPayments({
showSkeleton = false,
}: {
Expand All @@ -97,6 +153,10 @@ export default function RecentPayments({
() => filtersFromSearchParams(searchParams),
[searchParams],
);
const { sortColumn, sortDirection } = useMemo(
() => sortFromSearchParams(searchParams),
[searchParams],
);
const hasActiveFilters =
filters.search ||
filters.status !== "all" ||
Expand All @@ -114,12 +174,20 @@ export default function RecentPayments({
const [flashedIds, setFlashedIds] = useState<Set<string>>(new Set());

const updateFilters = useCallback(
(nextFilters: FilterState) => {
const params = buildSearchParams(nextFilters);
(
nextFilters: FilterState,
nextSortColumn: SortColumn = sortColumn,
nextSortDirection: SortDirection = sortDirection,
) => {
const params = buildSearchParams(
nextFilters,
nextSortColumn,
nextSortDirection,
);
const query = params.toString();
router.replace(query ? `${pathname}?${query}` : pathname, { scroll: false });
},
[pathname, router],
[pathname, router, sortColumn, sortDirection],
);

const handleFilterChange = useCallback(
Expand All @@ -143,6 +211,15 @@ export default function RecentPayments({
updateFilters(DEFAULT_FILTERS);
}, [updateFilters]);

const handleSort = useCallback(
(column: SortColumn) => {
const nextDirection =
sortColumn === column && sortDirection === "asc" ? "desc" : "asc";
updateFilters(filters, column, nextDirection);
},
[filters, sortColumn, sortDirection, updateFilters],
);

const handleConfirmed = useCallback(
(event: {
id: string;
Expand Down Expand Up @@ -190,7 +267,7 @@ export default function RecentPayments({
}

const apiUrl = process.env.NEXT_PUBLIC_API_URL || "http://localhost:4000";
const params = buildSearchParams(filters);
const params = buildSearchParams(filters, sortColumn, sortDirection);
params.set("page", page.toString());
params.set("limit", LIMIT.toString());

Expand Down Expand Up @@ -221,20 +298,61 @@ export default function RecentPayments({
fetchPayments();

return () => controller.abort();
}, [apiKey, filters, t]);
}, [apiKey, filters, sortColumn, sortDirection, t]);

const sortedPayments = useMemo(() => {
const statusOrder: Record<string, number> = {
pending: 0,
confirmed: 1,
completed: 2,
failed: 3,
refunded: 4,
};

return [...payments].sort((left, right) => {
let result = 0;

switch (sortColumn) {
case "amount":
result = Number(left.amount) - Number(right.amount);
break;
case "recipient":
result = left.recipient.localeCompare(right.recipient);
break;
case "status":
result =
(statusOrder[left.status] ?? Number.MAX_SAFE_INTEGER) -
(statusOrder[right.status] ?? Number.MAX_SAFE_INTEGER);
break;
case "created_at":
default:
result =
new Date(left.created_at).getTime() -
new Date(right.created_at).getTime();
break;
}

if (result === 0) {
result = left.id.localeCompare(right.id);
}

return sortDirection === "asc" ? result : -result;
});
}, [payments, sortColumn, sortDirection]);

const handlePaymentClick = (paymentId: string) => {
setSelectedPayment(paymentId);
setIsModalOpen(true);
};

const handleDownloadCSV = () => {
if (!payments.length) return;
if (!sortedPayments.length) return;

const mapped = payments.map((p) => ({
const mapped = sortedPayments.map((p) => ({
ID: p.id,
Amount: `${p.amount.toLocaleString()} ${p.asset}`,
Status: p.status.charAt(0).toUpperCase() + p.status.slice(1),
Recipient: p.recipient,
Description: p.description ?? "",
Date: new Date(p.created_at).toLocaleString(),
}));
Expand Down Expand Up @@ -715,12 +833,12 @@ export default function RecentPayments({

<div className="flex items-center justify-between gap-4">
<p className="text-xs text-slate-400">
{t("showingResults", { shown: payments.length, total: totalCount })}
{t("showingResults", { shown: sortedPayments.length, total: totalCount })}
{hasActiveFilters ? ` ${t("filteredSuffix")}` : ""}
</p>

<ExportCsvButton
transactions={payments.map((payment) => ({
transactions={sortedPayments.map((payment) => ({
id: payment.id,
createdAt: payment.created_at,
type: "payment",
Expand All @@ -742,7 +860,7 @@ export default function RecentPayments({

<button
onClick={handleDownloadCSV}
disabled={!payments.length}
disabled={!sortedPayments.length}
className="rounded-lg bg-mint px-4 py-2 text-sm font-medium text-black hover:bg-glow disabled:opacity-50"
>
Download CSV
Expand All @@ -753,26 +871,102 @@ export default function RecentPayments({
<table className="w-full text-left text-sm">
<thead>
<tr className="border-b border-white/10 bg-white/5">
<th className="px-4 py-3 font-mono text-xs uppercase tracking-wider text-slate-400">
Status
<th
aria-sort={
sortColumn === "status"
? sortDirection === "asc"
? "ascending"
: "descending"
: "none"
}
className="px-4 py-3 font-mono text-xs uppercase tracking-wider text-slate-400"
>
<button
type="button"
onClick={() => handleSort("status")}
className="inline-flex items-center gap-2 transition-colors hover:text-white"
>
{t("tableStatus")}
<SortArrow
active={sortColumn === "status"}
direction={sortDirection}
/>
</button>
</th>
<th className="px-4 py-3 font-mono text-xs uppercase tracking-wider text-slate-400">
Amount
<th
aria-sort={
sortColumn === "amount"
? sortDirection === "asc"
? "ascending"
: "descending"
: "none"
}
className="px-4 py-3 font-mono text-xs uppercase tracking-wider text-slate-400"
>
<button
type="button"
onClick={() => handleSort("amount")}
className="inline-flex items-center gap-2 transition-colors hover:text-white"
>
{t("tableAmount")}
<SortArrow
active={sortColumn === "amount"}
direction={sortDirection}
/>
</button>
</th>
<th className="hidden px-4 py-3 font-mono text-xs uppercase tracking-wider text-slate-400 sm:table-cell">
Description
<th
aria-sort={
sortColumn === "recipient"
? sortDirection === "asc"
? "ascending"
: "descending"
: "none"
}
className="hidden px-4 py-3 font-mono text-xs uppercase tracking-wider text-slate-400 sm:table-cell"
>
<button
type="button"
onClick={() => handleSort("recipient")}
className="inline-flex items-center gap-2 transition-colors hover:text-white"
>
{t("tableRecipient")}
<SortArrow
active={sortColumn === "recipient"}
direction={sortDirection}
/>
</button>
</th>
<th className="hidden px-4 py-3 font-mono text-xs uppercase tracking-wider text-slate-400 md:table-cell">
Date
<th
aria-sort={
sortColumn === "created_at"
? sortDirection === "asc"
? "ascending"
: "descending"
: "none"
}
className="hidden px-4 py-3 font-mono text-xs uppercase tracking-wider text-slate-400 md:table-cell"
>
<button
type="button"
onClick={() => handleSort("created_at")}
className="inline-flex items-center gap-2 transition-colors hover:text-white"
>
{t("tableDate")}
<SortArrow
active={sortColumn === "created_at"}
direction={sortDirection}
/>
</button>
</th>
<th className="px-4 py-3 font-mono text-xs uppercase tracking-wider text-slate-400">
Link
{t("tableLink")}
</th>
</tr>
</thead>

<tbody className="divide-y divide-white/5">
{payments.map((payment) => (
{sortedPayments.map((payment) => (
<tr
key={payment.id}
className={`cursor-pointer transition-colors hover:bg-white/5 ${
Expand All @@ -799,7 +993,9 @@ export default function RecentPayments({
{payment.amount} {payment.asset}
</td>
<td className="hidden px-4 py-3 text-slate-400 sm:table-cell">
{payment.description || t("emptyDescriptionValue")}
<code className="font-mono text-xs text-slate-300">
{payment.recipient}
</code>
</td>
<td className="hidden px-4 py-3 text-slate-400 md:table-cell">
{new Date(payment.created_at).toLocaleDateString(locale)}
Expand Down
Loading
Loading