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 34f84dc..a01e9a0 100644 --- a/src/app/invoice/[id]/page.tsx +++ b/src/app/invoice/[id]/page.tsx @@ -34,7 +34,6 @@ import SuccessAnimation from "@/components/SuccessAnimation"; import RecipientPayoutTracker from "@/components/RecipientPayoutTracker"; import RecipientListSkeleton from "@/components/invoice/RecipientListSkeleton"; import CloneLineageTree from "@/components/CloneLineageTree"; -import TransferOwnershipModal from "@/components/TransferOwnershipModal"; import StellarErrorBoundary from "@/components/error/StellarErrorBoundary"; import { useStellarQuery } from "@/hooks/useStellarQuery"; import { useRecentInvoices } from "@/hooks/useRecentInvoices"; @@ -357,6 +356,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(); @@ -443,6 +443,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: "⌛" }; @@ -515,7 +521,7 @@ export default function InvoiceDetailPage({ params }: Props) { Retroactive )} - + {canAct && }
@@ -565,16 +571,18 @@ export default function InvoiceDetailPage({ params }: Props) { {pushStatus === "active" ? "Notifications active" : "Notifications off"} )} - - {(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 && ( )} @@ -852,22 +874,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. +

+ )}
@@ -894,6 +917,15 @@ export default function InvoiceDetailPage({ params }: Props) { />
+ {recipientShare && ( + + )} {paymentError && (

{paymentError}

)} @@ -1072,4 +1104,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 8ac69c0..d21139a 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"; @@ -650,7 +649,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); } @@ -680,7 +679,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 78fbfa4..4e9a04d 100644 --- a/src/lib/stellar.ts +++ b/src/lib/stellar.ts @@ -11,11 +11,12 @@ let _client: StellarSplitClient | null = null; export const MEMO_MAX_BYTES = 28; 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 ?? "";