Overview

- +
+ + +
{stats.map((stat) => ( diff --git a/frontend/src/components/Dashboard/NetworkSelector.css b/frontend/src/components/Dashboard/NetworkSelector.css new file mode 100644 index 0000000..7706587 --- /dev/null +++ b/frontend/src/components/Dashboard/NetworkSelector.css @@ -0,0 +1,29 @@ +.network-selector { + display: flex; + flex-direction: column; + gap: 8px; +} + +.network-selector__select { + padding: 6px 10px; + border: 1px solid var(--color-input-border); + border-radius: var(--radius); + background: var(--color-input-bg); + color: var(--color-text); + font-size: 0.875rem; + cursor: pointer; +} + +.network-selector__select:focus { + outline: 2px solid var(--color-primary); + outline-offset: 2px; +} + +.network-selector__warning { + padding: 8px 12px; + background: var(--color-danger-soft-bg); + color: var(--color-danger-soft-text); + border-radius: var(--radius); + font-size: 0.8rem; + line-height: 1.4; +} diff --git a/frontend/src/components/Dashboard/NetworkSelector.tsx b/frontend/src/components/Dashboard/NetworkSelector.tsx new file mode 100644 index 0000000..236578c --- /dev/null +++ b/frontend/src/components/Dashboard/NetworkSelector.tsx @@ -0,0 +1,26 @@ +import { useNetwork } from "../../hooks/useNetwork"; +import "./NetworkSelector.css"; + +export default function NetworkSelector() { + const { network, setNetwork, isMainnet } = useNetwork(); + + return ( +
+ + {isMainnet && ( +
+ ⚠️ You are connected to Mainnet. Transactions are + irreversible and use real funds. +
+ )} +
+ ); +} diff --git a/frontend/src/components/InvoiceSearchFilter.css b/frontend/src/components/InvoiceSearchFilter.css new file mode 100644 index 0000000..26ae7bd --- /dev/null +++ b/frontend/src/components/InvoiceSearchFilter.css @@ -0,0 +1,216 @@ +.invoice-filter { + display: flex; + flex-direction: column; + gap: 16px; +} + +.invoice-filter__controls { + display: flex; + flex-wrap: wrap; + gap: 12px; + align-items: flex-end; +} + +.invoice-filter__search { + flex: 1; + min-width: 220px; + padding: 8px 12px; + border: 1px solid var(--color-input-border); + border-radius: var(--radius); + background: var(--color-input-bg); + color: var(--color-text); + font-size: 0.875rem; +} + +.invoice-filter__search:focus, +.invoice-filter__select:focus, +.invoice-filter__date:focus { + outline: 2px solid var(--color-primary); + outline-offset: 2px; +} + +.invoice-filter__select { + padding: 8px 10px; + border: 1px solid var(--color-input-border); + border-radius: var(--radius); + background: var(--color-input-bg); + color: var(--color-text); + font-size: 0.875rem; + cursor: pointer; +} + +.invoice-filter__select--sm { + padding: 4px 8px; + font-size: 0.8rem; +} + +.invoice-filter__date-range { + display: flex; + gap: 10px; + flex-wrap: wrap; +} + +.invoice-filter__date-label { + display: flex; + flex-direction: column; + gap: 4px; + font-size: 0.8rem; + color: var(--color-text-muted); +} + +.invoice-filter__date { + padding: 6px 8px; + border: 1px solid var(--color-input-border); + border-radius: var(--radius); + background: var(--color-input-bg); + color: var(--color-text); + font-size: 0.875rem; +} + +.invoice-filter__meta { + display: flex; + align-items: center; + justify-content: space-between; + flex-wrap: wrap; + gap: 8px; +} + +.invoice-filter__count { + font-size: 0.875rem; + color: var(--color-text-muted); +} + +.invoice-filter__page-size-label { + display: flex; + align-items: center; + gap: 6px; + font-size: 0.875rem; + color: var(--color-text-muted); +} + +.invoice-filter__empty { + padding: 24px; + text-align: center; + color: var(--color-text-muted); + font-size: 0.9rem; +} + +/* Table */ +.invoice-table { + width: 100%; + border-collapse: collapse; + font-size: 0.875rem; +} + +.invoice-table th, +.invoice-table td { + padding: 10px 12px; + text-align: left; + border-bottom: 1px solid var(--color-border); +} + +.invoice-table th { + font-weight: 600; + color: var(--color-text-muted); + background: var(--color-card-bg); +} + +.invoice-table tr:hover td { + background: var(--color-sidebar-hover-bg); +} + +.invoice-table__id { + font-weight: 600; + color: var(--color-primary); +} + +.invoice-table__address { + font-family: monospace; + font-size: 0.8rem; +} + +/* Status badges */ +.status-badge { + display: inline-block; + padding: 2px 8px; + border-radius: 9999px; + font-size: 0.75rem; + font-weight: 600; + text-transform: capitalize; +} + +.status-badge--pending { + background: var(--color-info-soft-bg); + color: var(--color-info-soft-text); +} + +.status-badge--paid { + background: var(--color-success-soft-bg); + color: var(--color-success-soft-text); +} + +.status-badge--expired, +.status-badge--cancelled { + background: var(--color-danger-soft-bg); + color: var(--color-danger-soft-text); +} + +.status-badge--refundrequested { + background: #fef3c7; + color: #92400e; +} + +.status-badge--released { + background: var(--color-success-soft-bg); + color: var(--color-success-soft-text); +} + +/* Pagination */ +.invoice-filter__pagination { + display: flex; + align-items: center; + justify-content: center; + gap: 16px; +} + +.pagination-btn { + padding: 6px 14px; + border: 1px solid var(--color-border); + border-radius: var(--radius); + background: var(--color-card-bg); + color: var(--color-text); + font-size: 0.875rem; + cursor: pointer; + transition: background 0.15s, border-color 0.15s; +} + +.pagination-btn:disabled { + opacity: 0.4; + cursor: not-allowed; +} + +.pagination-btn:not(:disabled):hover { + background: var(--color-input-bg); + border-color: var(--color-primary); +} + +.pagination-info { + font-size: 0.875rem; + color: var(--color-text-muted); +} + +@media (max-width: 768px) { + .invoice-filter__controls { + flex-direction: column; + } + + .invoice-filter__search { + min-width: unset; + width: 100%; + } + + .invoice-table { + display: block; + overflow-x: auto; + } +} diff --git a/frontend/src/components/InvoiceSearchFilter.tsx b/frontend/src/components/InvoiceSearchFilter.tsx new file mode 100644 index 0000000..589ad76 --- /dev/null +++ b/frontend/src/components/InvoiceSearchFilter.tsx @@ -0,0 +1,178 @@ +import { useState, useMemo } from "react"; +import { Invoice, InvoiceStatus } from "../../types"; +import "./InvoiceSearchFilter.css"; + +const ALL_STATUSES: InvoiceStatus[] = [ + "Pending", + "Paid", + "Expired", + "Cancelled", + "RefundRequested", + "Released", +]; + +const PAGE_SIZE_OPTIONS = [10, 25, 50] as const; + +interface Props { + invoices: Invoice[]; +} + +function formatDate(ts: number | null): string { + if (!ts) return "—"; + return new Date(ts * 1000).toLocaleDateString(); +} + +export default function InvoiceSearchFilter({ invoices }: Props) { + const [query, setQuery] = useState(""); + const [statusFilter, setStatusFilter] = useState(""); + const [dateFrom, setDateFrom] = useState(""); + const [dateTo, setDateTo] = useState(""); + const [pageSize, setPageSize] = useState<(typeof PAGE_SIZE_OPTIONS)[number]>(10); + const [page, setPage] = useState(1); + + const filtered = useMemo(() => { + const q = query.trim().toLowerCase(); + const fromTs = dateFrom ? new Date(dateFrom).getTime() / 1000 : null; + const toTs = dateTo ? new Date(dateTo).getTime() / 1000 + 86400 : null; + + return invoices.filter((inv) => { + if (q && !inv.id.toLowerCase().includes(q) && !inv.merchant.toLowerCase().includes(q)) { + return false; + } + if (statusFilter && inv.status !== statusFilter) return false; + if (fromTs && inv.created_at !== null && inv.created_at < fromTs) return false; + if (toTs && inv.created_at !== null && inv.created_at > toTs) return false; + return true; + }); + }, [invoices, query, statusFilter, dateFrom, dateTo]); + + const totalPages = Math.max(1, Math.ceil(filtered.length / pageSize)); + const currentPage = Math.min(page, totalPages); + const pageItems = filtered.slice((currentPage - 1) * pageSize, currentPage * pageSize); + + const handleFilterChange = () => setPage(1); + + return ( +
+
+ { setQuery(e.target.value); handleFilterChange(); }} + aria-label="Search invoices" + /> + + + +
+ + +
+
+ +
+ + {filtered.length} invoice{filtered.length !== 1 ? "s" : ""} found + + +
+ + {pageItems.length === 0 ? ( +

No invoices match the current filters.

+ ) : ( + + + + + + + + + + + + + {pageItems.map((inv) => ( + + + + + + + + + ))} + +
IDMerchantAmount (USDC)StatusCreatedExpires
#{inv.id} + {inv.merchant.slice(0, 6)}…{inv.merchant.slice(-4)} + {inv.amount_usdc} + + {inv.status} + + {formatDate(inv.created_at)}{formatDate(inv.expires_at)}
+ )} + +
+ + + Page {currentPage} of {totalPages} + + +
+
+ ); +} diff --git a/frontend/src/hooks/useNetwork.ts b/frontend/src/hooks/useNetwork.ts new file mode 100644 index 0000000..0a40312 --- /dev/null +++ b/frontend/src/hooks/useNetwork.ts @@ -0,0 +1,35 @@ +import { useState, useCallback } from "react"; + +export type Network = "testnet" | "mainnet"; + +const NETWORK_STORAGE_KEY = "comebackhere-network"; + +const RPC_ENDPOINTS: Record = { + testnet: + import.meta.env.VITE_SOROBAN_RPC_TESTNET ?? + "https://soroban-testnet.stellar.org", + mainnet: + import.meta.env.VITE_SOROBAN_RPC_MAINNET ?? + "https://soroban-mainnet.stellar.org", +}; + +function getStoredNetwork(): Network { + const stored = window.localStorage.getItem(NETWORK_STORAGE_KEY); + return stored === "mainnet" ? "mainnet" : "testnet"; +} + +export function useNetwork() { + const [network, setNetworkState] = useState(getStoredNetwork); + + const setNetwork = useCallback((next: Network) => { + window.localStorage.setItem(NETWORK_STORAGE_KEY, next); + setNetworkState(next); + }, []); + + return { + network, + setNetwork, + isMainnet: network === "mainnet", + rpcUrl: RPC_ENDPOINTS[network], + }; +} diff --git a/frontend/src/types/index.ts b/frontend/src/types/index.ts index 18777d9..86ab85f 100644 --- a/frontend/src/types/index.ts +++ b/frontend/src/types/index.ts @@ -17,3 +17,23 @@ export interface SettlementApprovalProps { signerAddress: string threshold: number } + +export type InvoiceStatus = + | 'Pending' + | 'Paid' + | 'Expired' + | 'Cancelled' + | 'RefundRequested' + | 'Released' + +export interface Invoice { + id: string + merchant: string + payer: string + amount_usdc: string + gross_usdc: string + expires_at: number + status: InvoiceStatus + paid_at: number | null + created_at: number | null +}