From 7cff5583d67e40b385c5fcc1a7a1803f5f4e0681 Mon Sep 17 00:00:00 2001
From: Oryke <297373441+Oryke@users.noreply.github.com>
Date: Tue, 28 Jul 2026 21:05:38 +0000
Subject: [PATCH] feat(invoice): add fee alerts and role-aware summaries
Closes: #510
Closes: #511
Closes: #512
Closes: #513
---
src/app/api/fees/route.ts | 24 +++
src/app/api/invoices/[id]/route.ts | 15 ++
src/app/invoice/[id]/page.tsx | 176 ++++++++++++--------
src/app/invoice/new/page.tsx | 5 +-
src/app/verify/[id]/page.tsx | 5 +-
src/components/ActivityFeed.tsx | 17 +-
src/components/AuditLogTable.tsx | 19 +--
src/components/CommentSection.tsx | 13 +-
src/components/DeadlineCountdown.tsx | 35 ++--
src/components/InvoiceTimeline.tsx | 20 +--
src/components/StatusTimeline.tsx | 20 +--
src/components/VersionHistory.tsx | 9 +-
src/components/invoice/CommentBubble.tsx | 11 +-
src/components/invoice/SplitSummaryCard.tsx | 67 ++++++++
src/components/layout/FeeSpikeBanner.tsx | 31 ++++
src/components/ui/RelativeTime.tsx | 58 +++++++
src/hooks/useFeeSpikeDetection.ts | 57 +++++++
src/hooks/useInvoiceRole.ts | 17 ++
src/lib/formatters.ts | 9 +
src/lib/stellar.ts | 3 +-
20 files changed, 429 insertions(+), 182 deletions(-)
create mode 100644 src/app/api/fees/route.ts
create mode 100644 src/components/invoice/SplitSummaryCard.tsx
create mode 100644 src/components/layout/FeeSpikeBanner.tsx
create mode 100644 src/components/ui/RelativeTime.tsx
create mode 100644 src/hooks/useFeeSpikeDetection.ts
create mode 100644 src/hooks/useInvoiceRole.ts
create mode 100644 src/lib/formatters.ts
diff --git a/src/app/api/fees/route.ts b/src/app/api/fees/route.ts
new file mode 100644
index 0000000..212e391
--- /dev/null
+++ b/src/app/api/fees/route.ts
@@ -0,0 +1,24 @@
+import { NextResponse } from "next/server";
+import { HORIZON_URL } from "@/lib/stellar";
+
+export async function GET() {
+ try {
+ const response = await fetch(`${HORIZON_URL}/fee_stats`, {
+ cache: "no-store",
+ });
+
+ if (!response.ok) {
+ return NextResponse.json({ error: "Failed to fetch fee stats" }, { status: 502 });
+ }
+
+ const stats = await response.json();
+ return NextResponse.json({
+ p95_accepted_fee: Number(stats?.fee_charged?.p95 ?? stats?.max_fee?.p95 ?? 0),
+ });
+ } catch (error) {
+ return NextResponse.json(
+ { error: "Failed to fetch fee stats", details: error instanceof Error ? error.message : String(error) },
+ { status: 500 }
+ );
+ }
+}
diff --git a/src/app/api/invoices/[id]/route.ts b/src/app/api/invoices/[id]/route.ts
index 8a1c898..db47ab0 100644
--- a/src/app/api/invoices/[id]/route.ts
+++ b/src/app/api/invoices/[id]/route.ts
@@ -1,4 +1,5 @@
import { NextRequest, NextResponse } from "next/server";
+import { splitClient } from "@/lib/stellar";
import { safeParseSplitMeta, type SplitMetaInput } from "@/lib/splitMetaSchema";
interface SplitMetaStore {
@@ -13,6 +14,20 @@ export async function PATCH(
) {
try {
const invoiceId = params.id;
+ const walletPublicKey = request.headers.get("x-wallet-public-key");
+
+ if (!walletPublicKey) {
+ return NextResponse.json({ error: "Missing wallet public key" }, { status: 403 });
+ }
+
+ const invoice = await splitClient.getInvoice(invoiceId);
+ const isCreator = invoice.creator === walletPublicKey;
+ const isRecipient = invoice.recipients.some((recipient) => recipient.address === walletPublicKey);
+
+ if (!isCreator && !isRecipient) {
+ return NextResponse.json({ error: "Not authorized for this invoice" }, { status: 403 });
+ }
+
const rawBody = await request.json();
const { splitMeta } = rawBody as { splitMeta?: unknown };
diff --git a/src/app/invoice/[id]/page.tsx b/src/app/invoice/[id]/page.tsx
index db2f765..d3513a7 100644
--- a/src/app/invoice/[id]/page.tsx
+++ b/src/app/invoice/[id]/page.tsx
@@ -33,9 +33,11 @@ import DeadlineExtensionPanel from "@/components/DeadlineExtensionPanel";
import SuccessAnimation from "@/components/SuccessAnimation";
import RecipientPayoutTracker from "@/components/RecipientPayoutTracker";
import CloneLineageTree from "@/components/CloneLineageTree";
-import TransferOwnershipModal from "@/components/TransferOwnershipModal";
import StellarErrorBoundary from "@/components/error/StellarErrorBoundary";
import { useStellarQuery } from "@/hooks/useStellarQuery";
+import FeeSpikeBanner from "@/components/layout/FeeSpikeBanner";
+import SplitSummaryCard from "@/components/invoice/SplitSummaryCard";
+import { useInvoiceRole } from "@/hooks/useInvoiceRole";
const POLL_MS = 10_000;
@@ -76,6 +78,7 @@ import { useInvoicePresence } from "@/hooks/useInvoicePresence";
import PresenceBar from "@/components/PresenceBar";
import InvoiceSection from "@/components/InvoiceSection";
import AmountDisplay from "@/components/invoice/AmountDisplay";
+import CooldownBadge from "@/components/CooldownBadge";
const RecipientPieChart = dynamic(() => import("@/components/RecipientPieChart"), { ssr: false });
const InvoiceQR = dynamic(() => import("@/components/InvoiceQR"), { ssr: false });
@@ -350,6 +353,7 @@ export default function InvoiceDetailPage({ params }: Props) {
const total = invoice
? invoice.recipients.reduce((s, r) => s + r.amount, 0n)
: 0n;
+ const role = useInvoiceRole(invoice, publicKey);
const handlePay = async (e: React.FormEvent) => {
e.preventDefault();
@@ -436,6 +440,12 @@ export default function InvoiceDetailPage({ params }: Props) {
if (!invoice) return null;
+ const isCreator = role === "creator";
+ const isRecipient = role === "recipient";
+ const canAct = isCreator || isRecipient;
+ const recipientShare = publicKey
+ ? invoice.recipients.find((recipient) => recipient.address === publicKey)
+ : undefined;
const remaining = total - invoice.funded;
const status = statusConfig[invoice.status] || { label: invoice.status, color: "bg-gray-500", icon: "⌛" };
@@ -449,6 +459,8 @@ export default function InvoiceDetailPage({ params }: Props) {
return (
+
+
{/* Reconnecting indicator */}
{showReconnecting && (
@@ -500,30 +512,34 @@ export default function InvoiceDetailPage({ params }: Props) {
Retroactive
)}
-
+ {canAct && }
-
-
-
-
- {pushStatus !== "unsupported" && !isRetroactive && (
+ {canAct &&
}
+ {canAct && (
+
+ )}
+ {isCreator && (
+
+ )}
+ {isCreator &&
}
+ {canAct && pushStatus !== "unsupported" && !isRetroactive && (
)}
-
- {(invoice as any).confidential && (
+ {canAct && (
+
+ )}
+ {isRecipient && (invoice as any).confidential && (
)}
-
-
- {invoice.status === "Pending" && publicKey === invoice.creator && (
+ {canAct && (
+
+ )}
+ {isCreator && (
+
+ )}
+ {invoice.status === "Pending" && isCreator && (
diff --git a/src/components/AuditLogTable.tsx b/src/components/AuditLogTable.tsx
index ac68fee..00bf603 100644
--- a/src/components/AuditLogTable.tsx
+++ b/src/components/AuditLogTable.tsx
@@ -3,6 +3,7 @@
import { useEffect, useState } from "react";
import { splitClient } from "@/lib/stellar";
import { truncateAddress } from "@stellar-split/sdk";
+import RelativeTime from "@/components/ui/RelativeTime";
import {
buildInvoiceArchive,
generateArchiveFilename,
@@ -108,22 +109,6 @@ export default function AuditLogTable({ invoiceId, invoice }: Props) {
const startIdx = (currentPage - 1) * ENTRIES_PER_PAGE;
const paginatedEntries = entries.slice(startIdx, startIdx + ENTRIES_PER_PAGE);
- const formatTimestamp = (timestamp: number): string => {
- try {
- return new Intl.DateTimeFormat("en-US", {
- year: "numeric",
- month: "short",
- day: "numeric",
- hour: "2-digit",
- minute: "2-digit",
- second: "2-digit",
- timeZoneName: "short",
- }).format(new Date(timestamp * 1000));
- } catch {
- return new Date(timestamp * 1000).toISOString();
- }
- };
-
return (
@@ -148,7 +133,7 @@ export default function AuditLogTable({ invoiceId, invoice }: Props) {
{truncateAddress(entry.actor)}
- {formatTimestamp(entry.timestamp)}
+
|
))}
diff --git a/src/components/CommentSection.tsx b/src/components/CommentSection.tsx
index 0c32a40..33a5758 100644
--- a/src/components/CommentSection.tsx
+++ b/src/components/CommentSection.tsx
@@ -2,6 +2,7 @@
import { useEffect, useRef, useState } from "react";
import { parseMentions, notifyMention } from "@/lib/notifications";
+import RelativeTime from "@/components/ui/RelativeTime";
interface Comment {
id: string;
@@ -47,14 +48,6 @@ function deleteComment(id: string) {
);
}
-function relativeTime(timestamp: number): string {
- const diff = (Date.now() - timestamp) / 1000;
- if (diff < 60) return "just now";
- if (diff < 3600) return `${Math.floor(diff / 60)} minutes ago`;
- if (diff < 86400) return `${Math.floor(diff / 3600)} hours ago`;
- return `${Math.floor(diff / 86400)} days ago`;
-}
-
/** Stellar address pattern — must match parseMentions regex. */
const MENTION_SPLIT_RE = /(\bG[A-Z0-9]{55}\b)/g;
const MENTION_TEST_RE = /^G[A-Z0-9]{55}$/;
@@ -137,7 +130,9 @@ export default function CommentSection({ invoiceId, walletAddress }: Props) {
>
{renderCommentText(c.text)}
-
{relativeTime(c.timestamp)}
+
+
+
{evt.description}
{evt.actor && (
diff --git a/src/components/StatusTimeline.tsx b/src/components/StatusTimeline.tsx
index d9e5533..5856eb9 100644
--- a/src/components/StatusTimeline.tsx
+++ b/src/components/StatusTimeline.tsx
@@ -1,6 +1,7 @@
"use client";
import type { Invoice } from "@stellar-split/sdk";
+import RelativeTime from "@/components/ui/RelativeTime";
interface TimelineEvent {
key: string;
@@ -10,18 +11,6 @@ interface TimelineEvent {
actor?: string;
}
-function relativeTime(ts: number): string {
- const diff = Math.floor((Date.now() / 1000) - ts);
- if (diff < 60) return "just now";
- if (diff < 3600) return `${Math.floor(diff / 60)}m ago`;
- if (diff < 86400) return `${Math.floor(diff / 3600)}h ago`;
- return `${Math.floor(diff / 86400)}d ago`;
-}
-
-function absoluteTime(ts: number): string {
- return new Date(ts * 1000).toLocaleString();
-}
-
function truncate(addr: string) {
return addr.length > 12 ? `${addr.slice(0, 6)}…${addr.slice(-4)}` : addr;
}
@@ -164,11 +153,8 @@ export default function StatusTimeline({ invoice, total }: Props) {
)}
{ev.timestamp ? (
-
- {relativeTime(ev.timestamp)}
+
+
) : (
timestamp pending
diff --git a/src/components/VersionHistory.tsx b/src/components/VersionHistory.tsx
index d70b102..decb212 100644
--- a/src/components/VersionHistory.tsx
+++ b/src/components/VersionHistory.tsx
@@ -3,6 +3,7 @@
import { useEffect, useState } from "react";
import { splitClient } from "@/lib/stellar";
import { truncateAddress } from "@stellar-split/sdk";
+import RelativeTime from "@/components/ui/RelativeTime";
interface HistoryEntry {
action: string;
@@ -117,13 +118,7 @@ export default function VersionHistory({ invoiceId }: Props) {
{entry.action}
- {new Intl.DateTimeFormat("en-US", {
- year: "numeric",
- month: "short",
- day: "numeric",
- hour: "2-digit",
- minute: "2-digit",
- }).format(new Date(entry.timestamp * 1000))}
+
{/* Diff display */}
diff --git a/src/components/invoice/CommentBubble.tsx b/src/components/invoice/CommentBubble.tsx
index 90d25d3..8eef300 100644
--- a/src/components/invoice/CommentBubble.tsx
+++ b/src/components/invoice/CommentBubble.tsx
@@ -5,6 +5,7 @@ import remarkGfm from "remark-gfm";
import { truncateAddress } from "@stellar-split/sdk";
import ReactionBar from "./ReactionBar";
import type { AllowedEmoji } from "@/lib/commentStore";
+import RelativeTime from "@/components/ui/RelativeTime";
export interface CommentWithReactions {
id: string;
@@ -24,14 +25,6 @@ interface Props {
reactionsDisabled?: boolean;
}
-function relativeTime(timestamp: number): string {
- const diff = (Date.now() - timestamp) / 1000;
- if (diff < 60) return "just now";
- if (diff < 3600) return `${Math.floor(diff / 60)}m ago`;
- if (diff < 86400) return `${Math.floor(diff / 3600)}h ago`;
- return `${Math.floor(diff / 86400)}d ago`;
-}
-
export default function CommentBubble({
comment,
canDelete,
@@ -55,7 +48,7 @@ export default function CommentBubble({
{truncateAddress(comment.authorAddress)}
-
{relativeTime(comment.createdAt)}
+
{canDelete && (