diff --git a/frontend/src/app/(authenticated)/payment-history/page.tsx b/frontend/src/app/(authenticated)/payment-history/page.tsx new file mode 100644 index 0000000..f587487 --- /dev/null +++ b/frontend/src/app/(authenticated)/payment-history/page.tsx @@ -0,0 +1,955 @@ +"use client"; + +import { useCallback, useEffect, useMemo, useState } from "react"; +import { usePathname, useRouter, useSearchParams } from "next/navigation"; +import { useLocale, useTranslations } from "next-intl"; +import Skeleton from "react-loading-skeleton"; +import "react-loading-skeleton/dist/skeleton.css"; +import PaymentDetailModal from "@/components/PaymentDetailModal"; +import ExportCsvButton from "@/components/ExportCsvButton"; +import { localeToLanguageTag } from "@/i18n/config"; +import { + useHydrateMerchantStore, + useMerchantApiKey, + useMerchantId, +} from "@/lib/merchant-store"; +import { usePaymentSocket } from "@/lib/usePaymentSocket"; +import { convertToCSV, downloadCSV } from "@/utils/csv"; + +interface Payment { + id: string; + amount: string; + asset: string; + status: string; + description: string | null; + created_at: string; +} + +interface PaginatedResponse { + payments: Payment[]; + total_count: number; +} + +interface FilterState { + search: string; + status: string; + asset: string; + dateFrom: string; + dateTo: string; +} + +const LIMIT = 50; +const STATUS_OPTIONS = [ + "all", + "pending", + "confirmed", + "failed", + "refunded", +] as const; +const ASSET_OPTIONS = ["all", "XLM", "USDC"] as const; +const DEFAULT_FILTERS: FilterState = { + search: "", + status: "all", + asset: "all", + dateFrom: "", + dateTo: "", +}; + +function toStatusLabel(t: ReturnType, status: string) { + return t.has(`statuses.${status}`) ? t(`statuses.${status}`) : status; +} + +function filtersFromSearchParams(searchParams: URLSearchParams): FilterState { + return { + search: searchParams.get("search") ?? "", + status: searchParams.get("status") ?? "all", + asset: searchParams.get("asset") ?? "all", + dateFrom: searchParams.get("date_from") ?? "", + dateTo: searchParams.get("date_to") ?? "", + }; +} + +function buildSearchParams(filters: FilterState): 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); + + return params; +} + +export default function PaymentHistoryPage() { + const t = useTranslations("recentPayments"); + const locale = localeToLanguageTag(useLocale()); + const pathname = usePathname(); + const router = useRouter(); + const searchParams = useSearchParams(); + const apiKey = useMerchantApiKey(); + const merchantId = useMerchantId(); + + useHydrateMerchantStore(); + + const filters = useMemo( + () => filtersFromSearchParams(searchParams), + [searchParams], + ); + const hasActiveFilters = + filters.search || + filters.status !== "all" || + filters.asset !== "all" || + filters.dateFrom || + filters.dateTo; + + const [payments, setPayments] = useState([]); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(null); + const page = 1; + const [totalCount, setTotalCount] = useState(0); + const [selectedPayment, setSelectedPayment] = useState(null); + const [isModalOpen, setIsModalOpen] = useState(false); + const [flashedIds, setFlashedIds] = useState>(new Set()); + + const updateFilters = useCallback( + (nextFilters: FilterState) => { + const params = buildSearchParams(nextFilters); + const query = params.toString(); + router.replace(query ? `${pathname}?${query}` : pathname, { + scroll: false, + }); + }, + [pathname, router], + ); + + const handleFilterChange = useCallback( + (key: keyof FilterState, value: string) => { + updateFilters({ ...filters, [key]: value }); + }, + [filters, updateFilters], + ); + + const clearFilter = useCallback( + (key: keyof FilterState) => { + updateFilters({ + ...filters, + [key]: key === "status" || key === "asset" ? "all" : "", + }); + }, + [filters, updateFilters], + ); + + const clearAllFilters = useCallback(() => { + updateFilters(DEFAULT_FILTERS); + }, [updateFilters]); + + const handleConfirmed = useCallback( + (event: { + id: string; + amount: number; + asset: string; + asset_issuer: string | null; + recipient: string; + tx_id: string; + confirmed_at: string; + }) => { + setPayments((prev) => + prev.map((payment) => + payment.id === event.id + ? { ...payment, status: "confirmed" } + : payment, + ), + ); + setFlashedIds((prev) => new Set([...prev, event.id])); + setTimeout(() => { + setFlashedIds((prev) => { + const next = new Set(prev); + next.delete(event.id); + return next; + }); + }, 1200); + }, + [], + ); + + usePaymentSocket(merchantId, handleConfirmed); + + useEffect(() => { + const controller = new AbortController(); + + async function fetchPayments() { + try { + setLoading(true); + setError(null); + + if (!apiKey) { + setError(t("missingApiKey")); + setPayments([]); + setTotalCount(0); + setLoading(false); + return; + } + + const apiUrl = + process.env.NEXT_PUBLIC_API_URL || "http://localhost:4000"; + const params = buildSearchParams(filters); + params.set("page", page.toString()); + params.set("limit", LIMIT.toString()); + + const response = await fetch( + `${apiUrl}/api/payments?${params.toString()}`, + { + headers: { + "x-api-key": apiKey, + }, + signal: controller.signal, + }, + ); + + if (!response.ok) { + throw new Error(t("fetchFailed")); + } + + const data: PaginatedResponse = await response.json(); + setPayments(data.payments ?? []); + setTotalCount(data.total_count ?? 0); + } catch (err: unknown) { + if (err instanceof Error && err.name === "AbortError") { + return; + } + setError(err instanceof Error ? err.message : t("loadFailed")); + } finally { + setLoading(false); + } + } + + fetchPayments(); + + return () => controller.abort(); + }, [apiKey, filters, t]); + + const handlePaymentClick = (paymentId: string) => { + setSelectedPayment(paymentId); + setIsModalOpen(true); + }; + + const closeModal = () => { + setIsModalOpen(false); + setSelectedPayment(null); + }; + + if (loading) { + return ( +
+
+

Payment History

+

+ View and manage all your payment transactions +

+
+ +
+ +
+ {[...Array(4)].map((_, i) => ( + + ))} +
+
+ +
+
+
+ {[...Array(6)].map((_, i) => ( + + ))} +
+
+
+ {[...Array(10)].map((_, i) => ( +
+
+ + + + + +
+
+ ))} +
+
+
+ ); + } + + if (error) { + return ( +
+
+

Payment History

+

+ View and manage all your payment transactions +

+
+ +
+
+
+
+ + + +
+
+ +
+

+ Unable to Load Payments +

+

{error}

+ +
+
+
+ ); + } + + if (payments.length === 0 && !hasActiveFilters) { + return ( +
+
+

Payment History

+

+ View and manage all your payment transactions +

+
+ +
+
+
+
+ + + +
+
+ +
+

+ No payment history yet +

+

+ Start accepting payments to see your transaction history here. +

+
+
+
+ ); + } + + return ( +
+ {/* Header */} +
+
+

Payment History

+

+ View and manage all your payment transactions +

+
+ +
+ ({ + id: payment.id, + createdAt: payment.created_at, + type: "payment", + status: payment.status, + amount: String(payment.amount), + asset: payment.asset, + sourceAccount: "", + destAccount: "", + hash: payment.id, + description: payment.description ?? "", + }))} + disabled={loading} + filename={`payment_history_${new Date().toISOString().slice(0, 10)}.csv`} + /> +
+
+ + {/* Stats Cards */} +
+
+
+
+

+ Total Payments +

+

{totalCount}

+
+
+ + + +
+
+
+ +
+
+
+

+ Confirmed +

+

+ {payments.filter((p) => p.status === "confirmed").length} +

+
+
+ + + +
+
+
+ +
+
+
+

+ Pending +

+

+ {payments.filter((p) => p.status === "pending").length} +

+
+
+ + + +
+
+
+ +
+
+
+

+ Failed +

+

+ {payments.filter((p) => p.status === "failed").length} +

+
+
+ + + +
+
+
+
+ + {/* Filters */} +
+
+
+ +
+ + handleFilterChange("search", event.target.value) + } + placeholder="Search by ID or description..." + className="w-full rounded-xl border border-white/10 bg-black/40 py-2.5 pl-10 pr-4 text-sm text-white placeholder:text-slate-600 focus:border-mint/50 focus:outline-none focus:ring-1 focus:ring-mint/50" + /> + + + +
+
+ +
+
+ + +
+ +
+ + +
+ +
+ + + handleFilterChange("dateFrom", event.target.value) + } + className="rounded-xl border border-white/10 bg-black/40 px-3 py-2.5 text-sm text-white focus:border-mint/50 focus:outline-none focus:ring-1 focus:ring-mint/50 [color-scheme:dark]" + /> +
+ +
+ + + handleFilterChange("dateTo", event.target.value) + } + className="rounded-xl border border-white/10 bg-black/40 px-3 py-2.5 text-sm text-white focus:border-mint/50 focus:outline-none focus:ring-1 focus:ring-mint/50 [color-scheme:dark]" + /> +
+
+ + {hasActiveFilters && ( +
+ Active filters: + + {filters.search && ( + + Search: "{filters.search}" + + + )} + {filters.status !== "all" && ( + + Status: {filters.status} + + + )} + {filters.asset !== "all" && ( + + Asset: {filters.asset} + + + )} + {filters.dateFrom && ( + + From: {filters.dateFrom} + + + )} + {filters.dateTo && ( + + To: {filters.dateTo} + + + )} + + +
+ )} +
+
+ + {/* Results Info */} +
+

+ Showing {payments.length} of {totalCount} payments + {hasActiveFilters && " (filtered)"} +

+
+ + {/* Payment Table */} +
+ + + + + + + + + + + + + + {payments.map((payment) => ( + + + + + + + + + ))} + +
+ ID + + Status + + Amount + + Description + + Date + + Actions +
+ + {payment.id.slice(0, 8)}... + + + + {toStatusLabel(t, payment.status)} + + + {payment.amount} {payment.asset} + + {payment.description || "—"} + + {new Date(payment.created_at).toLocaleDateString(locale, { + year: "numeric", + month: "short", + day: "numeric", + hour: "2-digit", + minute: "2-digit", + })} + + +
+
+ + {/* Empty State for Filtered Results */} + {payments.length === 0 && hasActiveFilters && ( +
+
+
+
+ + + +
+
+

+ No payments found +

+

+ Try adjusting your filters to see more results +

+ +
+ )} + + {/* Payment Detail Modal */} + {selectedPayment && ( + + )} +
+ ); +}