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 && ( + )} {/* Installment schedule — only shown to payers with a registered plan */} - {publicKey && loadedSplitMeta?.installments && loadedSplitMeta.installments.length > 0 && ( + {isRecipient && publicKey && loadedSplitMeta?.installments && loadedSplitMeta.installments.length > 0 && ( )} {/* Deadline extension request/approval flow */} - {invoice.status === "Pending" && ( + {invoice.status === "Pending" && canAct && ( )} @@ -816,22 +848,23 @@ export default function InvoiceDetailPage({ params }: Props) { {/* TODO: Re-enable when payment channel is fully implemented */} {/* Pay button → opens modal */} - {invoice.status === "Pending" && publicKey && ( + {invoice.status === "Pending" && isRecipient && publicKey && ( -
+
-

Pay toward this invoice

+

Pay toward this invoice

- - -
-

Pay Toward Invoice

+ {recipientShare && ( +

+ Your share is {formatAmount(recipientShare.amount)} USDC. +

+ )}
@@ -851,6 +884,15 @@ export default function InvoiceDetailPage({ params }: Props) { 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" />
+ {recipientShare && ( + + )} {paymentError && (

{paymentError}

)} @@ -1024,4 +1066,4 @@ export default function InvoiceDetailPage({ params }: Props) { />
); -} \ No newline at end of file +} diff --git a/src/app/invoice/new/page.tsx b/src/app/invoice/new/page.tsx index 259d6e7..5dcaef2 100644 --- a/src/app/invoice/new/page.tsx +++ b/src/app/invoice/new/page.tsx @@ -1,7 +1,6 @@ "use client"; import { useState, useEffect, useCallback, useMemo, Suspense } from "react"; -import { useRouter, useSearchParams } from "next/navigation"; import { useRouter, useSearchParams, usePathname } from "next/navigation"; import dynamic from "next/dynamic"; import Stepper, { type Step } from "@/components/ui/Stepper"; @@ -498,7 +497,7 @@ function NewInvoiceForm() { if (apiPayload && (apiPayload as any).recipients?.length > 0) { fetch(`/api/invoices/${invoiceId}`, { method: "PATCH", - headers: { "Content-Type": "application/json" }, + headers: { "Content-Type": "application/json", "x-wallet-public-key": creator }, body: JSON.stringify({ splitMeta: apiPayload }), }).catch(() => null); } @@ -528,7 +527,7 @@ function NewInvoiceForm() { if (apiPayload && (apiPayload as any).recipients?.length > 0) { fetch(`/api/invoices/${invoiceId}`, { method: "PATCH", - headers: { "Content-Type": "application/json" }, + headers: { "Content-Type": "application/json", "x-wallet-public-key": creator }, body: JSON.stringify({ splitMeta: apiPayload }), }).catch(() => null); } diff --git a/src/app/verify/[id]/page.tsx b/src/app/verify/[id]/page.tsx index 311c800..f4841a3 100644 --- a/src/app/verify/[id]/page.tsx +++ b/src/app/verify/[id]/page.tsx @@ -7,6 +7,7 @@ import CustomizationDisplay from "@/components/CustomizationDisplay"; import VerifyPayButton from "./VerifyPayButton"; import CopyLinkButton from "@/components/CopyLinkButton"; import ReputationBadge from "@/components/ReputationBadge"; +import SplitSummaryCard from "@/components/invoice/SplitSummaryCard"; interface Props { params: { id: string }; @@ -134,6 +135,8 @@ export default async function VerifyPage({ params }: Props) { + +

Payments ({invoice.payments.length}) @@ -161,4 +164,4 @@ export default async function VerifyPage({ params }: Props) { ); -} \ No newline at end of file +} diff --git a/src/components/ActivityFeed.tsx b/src/components/ActivityFeed.tsx index cb366ec..71f16c7 100644 --- a/src/components/ActivityFeed.tsx +++ b/src/components/ActivityFeed.tsx @@ -7,6 +7,7 @@ import { type ActivityEvent, } from "@/hooks/useActivityFeed"; import type { ActivityEventType } from "@/types/activity"; +import RelativeTime from "@/components/ui/RelativeTime"; const EVENT_TYPE_LABELS: Record = { payment_received: "Payment", @@ -34,18 +35,6 @@ function truncateAddress(address: string): string { return `${address.slice(0, 6)}...${address.slice(-4)}`; } -function formatTimestamp(ts: number): string { - const diff = Date.now() - ts; - const seconds = Math.floor(diff / 1000); - if (seconds < 60) return "just now"; - const minutes = Math.floor(seconds / 60); - if (minutes < 60) return `${minutes}m ago`; - const hours = Math.floor(minutes / 60); - if (hours < 24) return `${hours}h ago`; - const days = Math.floor(hours / 24); - return `${days}d ago`; -} - function describeEvent(event: ActivityEvent): string { const actor = truncateAddress(event.actor); switch (event.type) { @@ -135,9 +124,7 @@ function FeedItem({

{describeEvent(event)}

- + 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 && ( + + + ); +} diff --git a/src/components/ui/RelativeTime.tsx b/src/components/ui/RelativeTime.tsx new file mode 100644 index 0000000..beb33ba --- /dev/null +++ b/src/components/ui/RelativeTime.tsx @@ -0,0 +1,58 @@ +"use client"; + +import { useEffect, useMemo, useState } from "react"; + +const MINUTE_MS = 60_000; +const WEEK_MS = 7 * 24 * 60 * 60 * 1000; + +function formatDisplay(value: Date, now: number) { + const diffMs = value.getTime() - now; + const absMs = Math.abs(diffMs); + + if (absMs > WEEK_MS) { + return new Intl.DateTimeFormat(undefined, { + dateStyle: "medium", + timeStyle: "short", + }).format(value); + } + + const units: Array<[Intl.RelativeTimeFormatUnit, number]> = [ + ["day", 86_400_000], + ["hour", 3_600_000], + ["minute", 60_000], + ]; + + const formatter = new Intl.RelativeTimeFormat(undefined, { numeric: "auto" }); + for (const [unit, unitMs] of units) { + if (absMs >= unitMs || unit === "minute") { + return formatter.format(Math.round(diffMs / unitMs), unit); + } + } + + return formatter.format(0, "minute"); +} + +export default function RelativeTime({ iso, className }: { iso: string; className?: string }) { + const [now, setNow] = useState(() => Date.now()); + const date = useMemo(() => new Date(iso), [iso]); + + useEffect(() => { + const id = window.setInterval(() => setNow(Date.now()), MINUTE_MS); + return () => window.clearInterval(id); + }, []); + + if (Number.isNaN(date.getTime())) { + return ; + } + + const absolute = new Intl.DateTimeFormat(undefined, { + dateStyle: "full", + timeStyle: "long", + }).format(date); + + return ( + + ); +} diff --git a/src/hooks/useFeeSpikeDetection.ts b/src/hooks/useFeeSpikeDetection.ts new file mode 100644 index 0000000..5e95477 --- /dev/null +++ b/src/hooks/useFeeSpikeDetection.ts @@ -0,0 +1,57 @@ +"use client"; + +import { useEffect, useState } from "react"; +import { NORMAL_FEE_THRESHOLD } from "@/lib/stellar"; + +export interface FeeSpikeState { + p95AcceptedFee: number | null; + isSpike: boolean; + loading: boolean; +} + +export function useFeeSpikeDetection(): FeeSpikeState { + const [state, setState] = useState({ + p95AcceptedFee: null, + isSpike: false, + loading: true, + }); + + useEffect(() => { + let cancelled = false; + + const poll = async () => { + if (document.visibilityState === "hidden") return; + try { + const response = await fetch("/api/fees", { cache: "no-store" }); + if (!response.ok) throw new Error("Failed to fetch fee stats"); + const json = await response.json(); + const p95AcceptedFee = Number(json.p95_accepted_fee); + if (!cancelled) { + setState({ + p95AcceptedFee, + isSpike: p95AcceptedFee > NORMAL_FEE_THRESHOLD * 2, + loading: false, + }); + } + } catch { + if (!cancelled) setState((current) => ({ ...current, loading: false })); + } + }; + + const handleVisibilityChange = () => { + if (document.visibilityState === "visible") void poll(); + }; + + void poll(); + const intervalId = window.setInterval(poll, 60_000); + document.addEventListener("visibilitychange", handleVisibilityChange); + + return () => { + cancelled = true; + window.clearInterval(intervalId); + document.removeEventListener("visibilitychange", handleVisibilityChange); + }; + }, []); + + return state; +} diff --git a/src/hooks/useInvoiceRole.ts b/src/hooks/useInvoiceRole.ts new file mode 100644 index 0000000..36946e4 --- /dev/null +++ b/src/hooks/useInvoiceRole.ts @@ -0,0 +1,17 @@ +"use client"; + +import { useMemo } from "react"; +import type { Invoice } from "@stellar-split/sdk"; + +export type InvoiceRole = "creator" | "recipient" | "viewer"; + +export function getInvoiceRole(invoice: Invoice | null, publicKey: string | null): InvoiceRole { + if (!invoice || !publicKey) return "viewer"; + if (invoice.creator === publicKey) return "creator"; + if (invoice.recipients.some((recipient) => recipient.address === publicKey)) return "recipient"; + return "viewer"; +} + +export function useInvoiceRole(invoice: Invoice | null, publicKey: string | null): InvoiceRole { + return useMemo(() => getInvoiceRole(invoice, publicKey), [invoice, publicKey]); +} diff --git a/src/lib/formatters.ts b/src/lib/formatters.ts new file mode 100644 index 0000000..c9edfed --- /dev/null +++ b/src/lib/formatters.ts @@ -0,0 +1,9 @@ +import { formatAmount } from "@stellar-split/sdk"; + +export function formatXLM(amount: bigint): string { + return Number(formatAmount(amount)).toFixed(7); +} + +export function stroopsToXLM(stroops: number): string { + return (stroops / 10_000_000).toFixed(7); +} diff --git a/src/lib/stellar.ts b/src/lib/stellar.ts index 0a8b695..2f5e1bf 100644 --- a/src/lib/stellar.ts +++ b/src/lib/stellar.ts @@ -10,11 +10,12 @@ import { NETWORK_PASSPHRASE } from "./freighter"; let _client: StellarSplitClient | null = null; export const RPC_URL = process.env.NEXT_PUBLIC_RPC_URL ?? "https://soroban-testnet.stellar.org"; -const HORIZON_URL = +export const HORIZON_URL = process.env.NEXT_PUBLIC_HORIZON_URL ?? (process.env.NEXT_PUBLIC_STELLAR_NETWORK === "mainnet" ? "https://horizon.stellar.org" : "https://horizon-testnet.stellar.org"); +export const NORMAL_FEE_THRESHOLD = 100; export const USDC_CONTRACT_ID = process.env.NEXT_PUBLIC_USDC_ADDRESS ?? "";