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
60 changes: 59 additions & 1 deletion src/app/analytics/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -119,6 +119,7 @@ export default function AnalyticsPage() {
const [error, setError] = useState<string | null>(null);
const [dateFrom, setDateFrom] = useState("");
const [dateTo, setDateTo] = useState("");
const [drilldownStatus, setDrilldownStatus] = useState<string | null>(null);

useEffect(() => {
getFreighterPublicKey()
Expand Down Expand Up @@ -165,6 +166,23 @@ export default function AnalyticsPage() {
});
}, [invoices, dateFrom, dateTo]);

const drilldownInvoices = useMemo(() => {
if (!drilldownStatus) return [];
const now = Date.now() / 1000;
switch (drilldownStatus) {
case "Released":
return filtered.filter((i) => i.status === "Released");
case "Refunded":
return filtered.filter((i) => i.status === "Refunded");
case "Active":
return filtered.filter((i) => i.status === "Pending" && i.deadline >= now);
case "Expired":
return filtered.filter((i) => i.status === "Pending" && i.deadline < now);
default:
return [];
}
}, [filtered, drilldownStatus]);

const stats = useMemo(() => {
const total = filtered.length;
const released = filtered.filter((i) => i.status === "Released");
Expand Down Expand Up @@ -435,11 +453,51 @@ export default function AnalyticsPage() {
Invoice Status Breakdown
</h2>
<Suspense fallback={<ChartFallback />}>
<DynamicStatusPieChart data={stats.statusData} />
<DynamicStatusPieChart
data={stats.statusData}
onSliceClick={(name) => setDrilldownStatus(name)}
/>
</Suspense>
<p className="text-xs text-gray-500 mt-2">
Click a slice to drill into those invoices.
</p>
</div>
</div>

{drilldownStatus && (
<div
className="bg-gray-900 rounded-xl border border-gray-800 p-5 mb-8"
data-testid="status-drilldown-panel"
>
<div className="flex items-center justify-between mb-4">
<h2 className="text-sm font-semibold text-gray-300 uppercase tracking-wider">
{drilldownStatus} invoices ({drilldownInvoices.length})
</h2>
<button
type="button"
onClick={() => setDrilldownStatus(null)}
className="text-xs text-indigo-400 hover:text-indigo-300 transition-colors"
>
Clear
</button>
</div>
{drilldownInvoices.length === 0 ? (
<p className="text-sm text-gray-500">No invoices in this category.</p>
) : (
<ul className="divide-y divide-gray-800">
{drilldownInvoices.slice(0, 50).map((inv) => (
<li key={inv.id} className="py-2 flex items-center justify-between text-sm">
<span className="text-gray-300">Invoice #{inv.id}</span>
<span className="text-gray-500">
{stroopsToUsdc(inv.funded).toFixed(2)} USDC
</span>
</li>
))}
</ul>
)}
</div>
)}

<div className="grid grid-cols-1 lg:grid-cols-2 gap-6 mb-8">
<div className="bg-gray-900 rounded-xl border border-gray-800 p-5">
<h2 className="text-sm font-semibold text-gray-300 mb-4 uppercase tracking-wider">
Expand Down
112 changes: 106 additions & 6 deletions src/app/search/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -80,6 +80,15 @@ function hasActiveFilters(c: FilterCriteria): boolean {
});
}

/** Counts how many individual filter dimensions are currently active (for a filter-count badge). */
function countActiveFilters(c: FilterCriteria): number {
return Object.keys(c).reduce((count, k) => {
const v = (c as unknown as Record<string, unknown>)[k];
const active = Array.isArray(v) ? v.length > 0 : v !== undefined;
return active ? count + 1 : count;
}, 0);
}

// ── Status badge styles ───────────────────────────────────────────────────────

const STATUS_STYLES: Record<string, string> = {
Expand All @@ -88,6 +97,60 @@ const STATUS_STYLES: Record<string, string> = {
Refunded: 'bg-gray-500/20 text-gray-300',
};

// ── Highlight ───────────────────────────────────────────────────────────────

function escapeRegExp(s: string): string {
return s.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
}

/** Wraps occurrences of `query` inside `text` with a highlight <mark>. */
function highlightMatch(text: string, query: string): React.ReactNode {
const trimmed = query.trim();
if (!trimmed) return text;
const escaped = escapeRegExp(trimmed);
const parts = text.split(new RegExp(`(${escaped})`, 'ig'));
if (parts.length === 1) return text;
return parts.map((part, i) =>
part.toLowerCase() === trimmed.toLowerCase() ? (
<mark key={i} className="bg-indigo-500/40 text-indigo-100 rounded px-0.5">
{part}
</mark>
) : (
<span key={i}>{part}</span>
),
);
}

// ── Smart suggestions ─────────────────────────────────────────────────────────

/** Builds lightweight "did you mean" style suggestions from history + common terms. */
function buildSmartSuggestions(
query: string,
recentSearches: string[],
savedSearches: SavedSearch[],
): string[] {
const trimmed = query.trim().toLowerCase();
if (!trimmed) return [];
const pool = [
...recentSearches,
...savedSearches.map((s) => s.query),
...STATUSES,
...DEFAULT_TOKENS,
];
const seen = new Set<string>();
const suggestions: string[] = [];
for (const candidate of pool) {
const c = candidate.trim();
if (!c || c.toLowerCase() === trimmed) continue;
if (c.toLowerCase().includes(trimmed) && !seen.has(c.toLowerCase())) {
seen.add(c.toLowerCase());
suggestions.push(c);
}
if (suggestions.length >= 5) break;
}
return suggestions;
}

// ── FullTextSearchBar ─────────────────────────────────────────────────────────

interface FullTextSearchBarProps {
Expand Down Expand Up @@ -356,9 +419,11 @@ function ColumnPicker({
function InvoiceRow({
invoice,
visibleColumns,
highlightQuery,
}: {
invoice: Invoice;
visibleColumns: SearchColumn[];
highlightQuery?: string;
}) {
const total = invoice.recipients.reduce((s, r) => s + r.amount, 0n);
const fundedPct = total > 0n ? Number((invoice.funded * 100n) / total) : 0;
Expand All @@ -367,7 +432,7 @@ function InvoiceRow({
<div className="bg-gray-900 rounded-xl p-4 hover:bg-gray-800 transition-colors cursor-pointer">
<div className="flex flex-wrap items-center gap-3">
<span className="text-sm font-semibold text-gray-300 shrink-0">
Invoice #{invoice.id}
Invoice #{highlightQuery ? highlightMatch(String(invoice.id), highlightQuery) : invoice.id}
</span>

{visibleColumns.includes('amount') && (
Expand All @@ -380,7 +445,7 @@ function InvoiceRow({
<span
className={`text-xs px-2 py-0.5 rounded-full font-semibold ${STATUS_STYLES[invoice.status] ?? ''}`}
>
{invoice.status}
{highlightQuery ? highlightMatch(invoice.status, highlightQuery) : invoice.status}
</span>
)}

Expand All @@ -392,7 +457,9 @@ function InvoiceRow({

{visibleColumns.includes('creator') && (
<span className="text-xs text-gray-500 font-mono">
{truncateAddress(invoice.creator)}
{highlightQuery
? highlightMatch(truncateAddress(invoice.creator), highlightQuery)
: truncateAddress(invoice.creator)}
</span>
)}

Expand All @@ -417,9 +484,10 @@ function InvoiceRow({
interface VirtualListProps {
items: Invoice[];
visibleColumns: SearchColumn[];
highlightQuery?: string;
}

function VirtualList({ items, visibleColumns }: VirtualListProps) {
function VirtualList({ items, visibleColumns, highlightQuery }: VirtualListProps) {
const [scrollTop, setScrollTop] = useState(0);

const startIdx = Math.floor(scrollTop / ITEM_HEIGHT_PX);
Expand All @@ -444,7 +512,7 @@ function VirtualList({ items, visibleColumns }: VirtualListProps) {
className="mb-4"
>
<Link href={`/invoice/${inv.id}`}>
<InvoiceRow invoice={inv} visibleColumns={visibleColumns} />
<InvoiceRow invoice={inv} visibleColumns={visibleColumns} highlightQuery={highlightQuery} />
</Link>
</div>
))}
Expand Down Expand Up @@ -829,6 +897,15 @@ function SearchPageInner() {
<span>{shareStatus || 'Share search'}</span>
</button>
<ColumnPicker visibleColumns={visibleColumns} onToggle={handleToggleColumn} />
{countActiveFilters(criteria) > 0 && (
<span
className="text-xs px-2 py-0.5 rounded-full bg-indigo-500/20 text-indigo-300 font-semibold"
data-testid="active-filter-count"
title="Number of active filter dimensions"
>
{countActiveFilters(criteria)} filter{countActiveFilters(criteria) !== 1 ? 's' : ''}
</span>
)}
{filtersActive && (
<button
type="button"
Expand All @@ -855,6 +932,29 @@ function SearchPageInner() {
onFocus={() => setShowHistory(true)}
inputRef={fullTextInputRef}
/>
{fullTextQuery.trim() && !showHistory && (
(() => {
const suggestions = buildSmartSuggestions(fullTextQuery, recentSearches, savedSearches);
if (suggestions.length === 0) return null;
return (
<div
className="absolute top-full left-0 right-0 mt-1 z-20 bg-gray-900 border border-gray-700 rounded-lg p-2 flex flex-wrap gap-2"
data-testid="smart-suggestions"
>
{suggestions.map((s) => (
<button
key={s}
type="button"
onClick={() => setFullTextQuery(s)}
className="text-xs px-2 py-1 rounded-full bg-gray-800 text-indigo-300 hover:bg-gray-700 transition-colors"
>
{s}
</button>
))}
</div>
);
})()
)}
{showHistory && (
<div className="absolute top-full left-0 right-0 mt-1 z-30">
<SearchHistoryPanel
Expand Down Expand Up @@ -1148,7 +1248,7 @@ function SearchPageInner() {
) : results.length === 0 ? (
<EmptyState hasInvoices={allInvoices.length > 0} hasFilters={filtersActive} />
) : (
<VirtualList items={results} visibleColumns={visibleColumns} />
<VirtualList items={results} visibleColumns={visibleColumns} highlightQuery={fullTextQuery} />
)}
</main>
);
Expand Down
Loading