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
20 changes: 20 additions & 0 deletions .claude/settings.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
{
"permissions": {
"allow": [
"Bash",
"Read",
"Edit",
"Write",
"WebFetch",
"Grep",
"Glob",
"LS",
"MultiEdit",
"NotebookRead",
"NotebookEdit",
"TodoRead",
"TodoWrite",
"WebSearch"
]
}
}
129 changes: 129 additions & 0 deletions src/app/api/presence/[invoiceId]/route.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,129 @@
import { NextRequest, NextResponse } from 'next/server';
import type { CoCreatorPresence, PresenceHeartbeat } from '@/types/presence';

// In-memory store for presence data
// In production, this would use Redis or a database
const presenceStore = new Map<string, Map<string, CoCreatorPresence>>();
const lastHeartbeatStore = new Map<string, Map<string, number>>();

const INACTIVE_TIMEOUT = 10_000; // 10 seconds

// Helper to clean up inactive users
function cleanupInactiveUsers(invoiceId: string) {
const roster = presenceStore.get(invoiceId);
const heartbeats = lastHeartbeatStore.get(invoiceId);

if (!roster || !heartbeats) return;

const now = Date.now();
const toDelete: string[] = [];

heartbeats.forEach((lastHeartbeat, userId) => {
if (now - lastHeartbeat > INACTIVE_TIMEOUT) {
toDelete.push(userId);
}
});

toDelete.forEach((userId) => {
roster.delete(userId);
heartbeats.delete(userId);
});
}

// Helper to verify co-creator permissions
// In production, this would validate against actual invoice permissions
async function hasCoCreatorPermission(invoiceId: string): Promise<boolean> {
// For now, allow all requests
// In production: verify user has co-creator role for this invoice
return true;
}

export async function GET(
request: NextRequest,
{ params }: { params: { invoiceId: string } }
) {
try {
const invoiceId = params.invoiceId;

// Cleanup inactive users
cleanupInactiveUsers(invoiceId);

// Get current roster
const roster = presenceStore.get(invoiceId) || new Map();
const active = Array.from(roster.values());

return NextResponse.json({
active,
timestamp: Date.now(),
});
} catch (error) {
console.error('[Presence API] GET error:', error);
return NextResponse.json(
{ error: 'Failed to fetch presence' },
{ status: 500 }
);
}
}

export async function POST(
request: NextRequest,
{ params }: { params: { invoiceId: string } }
) {
try {
const invoiceId = params.invoiceId;

// Verify co-creator permissions
const hasPermission = await hasCoCreatorPermission(invoiceId);
if (!hasPermission) {
return NextResponse.json(
{ error: 'Insufficient permissions' },
{ status: 403 }
);
}

const heartbeat: PresenceHeartbeat = await request.json();

// Validate heartbeat
if (!heartbeat.userId || !heartbeat.displayName || !heartbeat.focusedSection) {
return NextResponse.json(
{ error: 'Invalid heartbeat data' },
{ status: 400 }
);
}

// Initialize invoice roster if needed
if (!presenceStore.has(invoiceId)) {
presenceStore.set(invoiceId, new Map());
lastHeartbeatStore.set(invoiceId, new Map());
}

const roster = presenceStore.get(invoiceId)!;
const heartbeats = lastHeartbeatStore.get(invoiceId)!;

// Update presence data
const presence: CoCreatorPresence = {
...heartbeat,
lastSeen: Date.now(),
};

roster.set(heartbeat.userId, presence);
heartbeats.set(heartbeat.userId, Date.now());

// Cleanup after update
cleanupInactiveUsers(invoiceId);

// Return updated roster
const active = Array.from(roster.values());

return NextResponse.json({
active,
timestamp: Date.now(),
});
} catch (error) {
console.error('[Presence API] POST error:', error);
return NextResponse.json(
{ error: 'Failed to update presence' },
{ status: 500 }
);
}
}
53 changes: 47 additions & 6 deletions src/app/invoice/[id]/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -57,6 +57,9 @@ import { cancelReminder, setReminder } from "@/lib/reminders";
import { recordCooldown } from "@/lib/cooldown";
import { exportTimelineAsImage } from "@/lib/timelineImageExport";
import type { PaymentChannelState } from "@/components/PaymentChannelPanel";
import { useInvoicePresence } from "@/hooks/useInvoicePresence";
import PresenceBar from "@/components/PresenceBar";
import InvoiceSection from "@/components/InvoiceSection";

const RecipientPieChart = dynamic(() => import("@/components/RecipientPieChart"), { ssr: false });
const InvoiceQR = dynamic(() => import("@/components/InvoiceQR"), { ssr: false });
Expand Down Expand Up @@ -99,6 +102,18 @@ export default function InvoiceDetailPage({ params }: Props) {
error: streamError,
} = useInvoiceStream(id);

// Presence indicators for co-creator awareness
const {
presenceRoster,
currentFocusedSection,
updateFocusedSection,
} = useInvoicePresence({
invoiceId: id,
userId: publicKey || "anonymous",
displayName: publicKey ? truncateAddress(publicKey) : "Anonymous",
enabled: Boolean(publicKey),
});

const [payAmount, setPayAmount] = useState("");
const [paying, setPaying] = useState(false);
const [txHash, setTxHash] = useState<string | null>(null);
Expand Down Expand Up @@ -382,6 +397,13 @@ export default function InvoiceDetailPage({ params }: Props) {
/>
)}

{/* Presence Bar — shows active co-creators */}
{presenceRoster.length > 0 && (
<div className="mb-6">
<PresenceBar presenceRoster={presenceRoster} currentUserId={publicKey || ""} />
</div>
)}

{/* Header */}
<div className="flex items-start justify-between gap-4 mb-6 flex-wrap">
<div className="flex items-center gap-3 flex-wrap">
Expand Down Expand Up @@ -448,8 +470,14 @@ export default function InvoiceDetailPage({ params }: Props) {
</div>
</div>

{/* Quick Info Cards */}
<div className="grid grid-cols-2 sm:grid-cols-4 gap-3 mb-8">
{/* Details Section */}
<InvoiceSection
sectionId="details"
onFocusChange={updateFocusedSection}
className="mb-8"
>
{/* Quick Info Cards */}
<div className="grid grid-cols-2 sm:grid-cols-4 gap-3 mb-8">
<div className="bg-gray-800/60 border border-gray-700 rounded-xl px-4 py-3">
<p className="text-xs text-gray-500 uppercase tracking-wide mb-1">Creator</p>
<p className="text-sm font-mono text-gray-200 truncate" title={invoice.creator}>
Expand Down Expand Up @@ -498,8 +526,14 @@ export default function InvoiceDetailPage({ params }: Props) {
<h2 id="timeline-heading" className="text-lg font-semibold text-white mb-4">Status Timeline</h2>
<StatusTimeline invoice={invoice} total={total} />
</section>

{/* Payments */}
</InvoiceSection>

{/* Payments Section */}
<InvoiceSection
sectionId="payments"
onFocusChange={updateFocusedSection}
className="mb-8"
>
<section className="mb-8">
<h2 className="text-lg font-semibold text-white mb-3">
Payments ({invoice.payments.length})
Expand Down Expand Up @@ -533,6 +567,7 @@ export default function InvoiceDetailPage({ params }: Props) {
</div>
)}
</section>
</InvoiceSection>

{invoice.status === "Pending" && (
<div className="flex flex-col gap-6">
Expand Down Expand Up @@ -582,8 +617,14 @@ export default function InvoiceDetailPage({ params }: Props) {
</div>
)}

{/* Recipients */}
<RecipientPayoutTracker invoice={invoice} publicKey={publicKey} />
{/* Recipients Section */}
<InvoiceSection
sectionId="recipients"
onFocusChange={updateFocusedSection}
className="mb-8"
>
<RecipientPayoutTracker invoice={invoice} publicKey={publicKey} />
</InvoiceSection>

{/* Split Calculator */}
{invoice.status === "Pending" && <SplitCalculator invoice={invoice} />}
Expand Down
68 changes: 68 additions & 0 deletions src/components/InvoiceSection.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,68 @@
'use client';

import React, { useEffect, useRef } from 'react';
import type { InvoiceSectionFocus } from '@/types/presence';

interface InvoiceSectionProps {
sectionId: InvoiceSectionFocus;
onFocusChange: (section: InvoiceSectionFocus) => void;
children: React.ReactNode;
className?: string;
}

/**
* InvoiceSection component wraps invoice sections and tracks when they enter/exit the viewport.
* Uses IntersectionObserver to detect which section the user is currently viewing.
*/
export default function InvoiceSection({
sectionId,
onFocusChange,
children,
className = '',
}: InvoiceSectionProps) {
const sectionRef = useRef<HTMLDivElement>(null);
const observerRef = useRef<IntersectionObserver | null>(null);
const isInViewRef = useRef(false);

useEffect(() => {
if (!sectionRef.current) return;

// Create IntersectionObserver to detect when section is in viewport
observerRef.current = new IntersectionObserver(
(entries) => {
entries.forEach((entry) => {
// Mark section as in view when it's at least 30% visible
const isNowInView = entry.isIntersecting && entry.intersectionRatio >= 0.3;

if (isNowInView && !isInViewRef.current) {
// Section just came into view
isInViewRef.current = true;
onFocusChange(sectionId);
} else if (!isNowInView && isInViewRef.current) {
// Section just left view
isInViewRef.current = false;
}
});
},
{
threshold: [0.3], // Trigger when 30% of section is visible
rootMargin: '0px',
}
);

observerRef.current.observe(sectionRef.current);

return () => {
if (observerRef.current) {
observerRef.current.disconnect();
observerRef.current = null;
}
};
}, [sectionId, onFocusChange]);

return (
<div ref={sectionRef} className={className}>
{children}
</div>
);
}
Loading