Skip to content

Commit 84036b5

Browse files
authored
Merge branch 'main' into feature/invoice-form-enhancements
2 parents 41543bd + 2bae61b commit 84036b5

35 files changed

Lines changed: 3196 additions & 241 deletions
Lines changed: 142 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,142 @@
1+
import { NextRequest } from "next/server";
2+
3+
interface CursorEvent {
4+
type: "cursor";
5+
address: string;
6+
field: string;
7+
timestamp: number;
8+
}
9+
10+
interface PresenceEvent {
11+
type: "presence";
12+
address: string;
13+
online: boolean;
14+
timestamp: number;
15+
}
16+
17+
type CollabEvent = CursorEvent | PresenceEvent;
18+
19+
interface SseClient {
20+
id: string;
21+
controller: ReadableStreamDefaultController;
22+
encoder: TextEncoder;
23+
address: string | null;
24+
}
25+
26+
const clients = new Map<string, SseClient[]>();
27+
28+
function broadcast(invoiceId: string, event: CollabEvent) {
29+
const invoiceClients = clients.get(invoiceId);
30+
if (!invoiceClients) return;
31+
const data = `data: ${JSON.stringify(event)}\n\n`;
32+
const encoded = new TextEncoder().encode(data);
33+
for (const client of invoiceClients) {
34+
try {
35+
client.controller.enqueue(encoded);
36+
} catch {
37+
// client disconnected
38+
}
39+
}
40+
}
41+
42+
function prune(invoiceId: string) {
43+
const invoiceClients = clients.get(invoiceId);
44+
if (!invoiceClients) return;
45+
const now = Date.now();
46+
const active = invoiceClients.filter(
47+
(c) => now - c.id.split("-").map(Number)[1] < 60_000
48+
);
49+
if (active.length === 0) {
50+
clients.delete(invoiceId);
51+
} else {
52+
clients.set(invoiceId, active);
53+
}
54+
}
55+
56+
export async function GET(
57+
_request: NextRequest,
58+
{ params }: { params: { invoiceId: string } }
59+
) {
60+
const { invoiceId } = params;
61+
62+
const stream = new ReadableStream({
63+
start(controller) {
64+
const client: SseClient = {
65+
id: `${Date.now()}-${Math.random().toString(36).slice(2, 8)}`,
66+
controller,
67+
encoder: new TextEncoder(),
68+
address: null,
69+
};
70+
71+
if (!clients.has(invoiceId)) {
72+
clients.set(invoiceId, []);
73+
}
74+
clients.get(invoiceId)!.push(client);
75+
76+
controller.enqueue(
77+
new TextEncoder().encode(`data: ${JSON.stringify({ type: "connected", clientId: client.id })}\n\n`)
78+
);
79+
80+
prune(invoiceId);
81+
},
82+
cancel() {
83+
const invoiceClients = clients.get(invoiceId);
84+
if (invoiceClients) {
85+
const remaining = invoiceClients.filter(
86+
(c) => !c.controller.locked
87+
);
88+
if (remaining.length === 0) {
89+
clients.delete(invoiceId);
90+
broadcast(invoiceId, {
91+
type: "presence",
92+
address: "all",
93+
online: false,
94+
timestamp: Date.now(),
95+
});
96+
} else {
97+
clients.set(invoiceId, remaining);
98+
}
99+
}
100+
},
101+
});
102+
103+
return new Response(stream, {
104+
headers: {
105+
"Content-Type": "text/event-stream",
106+
"Cache-Control": "no-cache, no-transform",
107+
Connection: "keep-alive",
108+
},
109+
});
110+
}
111+
112+
export async function POST(
113+
request: NextRequest,
114+
{ params }: { params: { invoiceId: string } }
115+
) {
116+
const { invoiceId } = params;
117+
const body = (await request.json()) as { address?: string; field?: string; online?: boolean };
118+
119+
if (!body.address) {
120+
return Response.json({ error: "address is required" }, { status: 400 });
121+
}
122+
123+
if (body.field !== undefined) {
124+
broadcast(invoiceId, {
125+
type: "cursor",
126+
address: body.address,
127+
field: body.field,
128+
timestamp: Date.now(),
129+
});
130+
}
131+
132+
if (body.online !== undefined) {
133+
broadcast(invoiceId, {
134+
type: "presence",
135+
address: body.address,
136+
online: body.online,
137+
timestamp: Date.now(),
138+
});
139+
}
140+
141+
return Response.json({ ok: true });
142+
}

src/app/api/invoices/route.ts

Lines changed: 16 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -4,11 +4,13 @@ import { splitClient } from "@/lib/stellar";
44
const PAGE_SIZE = 20;
55

66
/**
7-
* GET /api/invoices?cursor=<id>&limit=20&publicKey=<address>
7+
* GET /api/invoices?cursor=<id>&limit=20&publicKey=<address>&q=<query>
88
*
99
* Cursor-paginated invoice list. `cursor` is the exclusive lower bound —
1010
* the first page omits it, subsequent pages pass the last invoice id returned.
1111
*
12+
* With `q` parameter, performs case-insensitive prefix matching on title and memo.
13+
*
1214
* Response shape:
1315
* { invoices: Invoice[], nextCursor: string | null }
1416
*
@@ -19,6 +21,7 @@ export async function GET(request: NextRequest) {
1921
const publicKey = searchParams.get("publicKey");
2022
const cursorParam = searchParams.get("cursor");
2123
const limitParam = searchParams.get("limit");
24+
const q = searchParams.get("q")?.trim().toLowerCase() || "";
2225

2326
if (!publicKey) {
2427
return NextResponse.json(
@@ -49,8 +52,19 @@ export async function GET(request: NextRequest) {
4952
const mine =
5053
inv.creator === publicKey ||
5154
inv.recipients.some((r) => r.address === publicKey);
55+
5256
if (mine) {
53-
results.push(inv);
57+
if (q) {
58+
const memo = (inv as any).memo as string | undefined;
59+
const matchesQuery =
60+
(inv.title || "").toLowerCase().startsWith(q) ||
61+
(memo || "").toLowerCase().startsWith(q);
62+
if (matchesQuery) {
63+
results.push(inv);
64+
}
65+
} else {
66+
results.push(inv);
67+
}
5468
}
5569
} catch {
5670
// splitClient throws when invoice id does not exist — treat as end of list

src/app/invoice/[id]/page.tsx

Lines changed: 21 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -130,7 +130,7 @@ export default function InvoiceDetailPage({ params }: Props) {
130130
const {
131131
invoice: streamInvoice,
132132
latestEvent,
133-
isConnected,
133+
isConnected: streamConnected,
134134
error: streamError,
135135
} = useInvoiceStream(id);
136136

@@ -460,6 +460,12 @@ export default function InvoiceDetailPage({ params }: Props) {
460460
</div>
461461
)}
462462

463+
{/* Reconnecting indicator */}
464+
<ReconnectionBanner
465+
show={showReconnecting}
466+
isConnected={streamConnected && collabConnected}
467+
/>
468+
463469
{/* Release Banner */}
464470
{showReleaseBanner && (
465471
<ReleaseBanner
@@ -709,10 +715,17 @@ export default function InvoiceDetailPage({ params }: Props) {
709715
placeholder="Amount in USDC"
710716
value={payAmount}
711717
onChange={(e) => setPayAmount(e.target.value)}
718+
onFocus={() => setFocusedField("pay-amount-freighter")}
719+
onBlur={() => {
720+
if (focusedField === "pay-amount-freighter") {
721+
emitFieldBlur();
722+
}
723+
}}
712724
required
713725
aria-label="Amount in USDC"
714726
className="bg-gray-800 border border-gray-700 rounded-lg px-4 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-indigo-500"
715727
/>
728+
<CursorOverlay cursors={remoteCursors} fieldName="pay-amount-freighter" />
716729
{error && <p className="text-red-400 text-sm">{error}</p>}
717730
{txHash && (
718731
<p className="text-green-400 text-sm">
@@ -847,9 +860,16 @@ export default function InvoiceDetailPage({ params }: Props) {
847860
placeholder="0.00"
848861
value={payAmount}
849862
onChange={(e) => setPayAmount(e.target.value)}
863+
onFocus={() => setFocusedField("pay-amount")}
864+
onBlur={() => {
865+
if (focusedField === "pay-amount") {
866+
emitFieldBlur();
867+
}
868+
}}
850869
required
851870
className="w-full min-h-11 bg-gray-800 border border-gray-700 rounded-lg px-4 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-indigo-500"
852871
/>
872+
<CursorOverlay cursors={remoteCursors} fieldName="pay-amount" />
853873
</div>
854874
{paymentError && (
855875
<p role="alert" className="text-red-400 text-sm">{paymentError}</p>

0 commit comments

Comments
 (0)