diff --git a/src/components/AchievementCard.tsx b/src/components/AchievementCard.tsx index d50f82c..2436bc3 100644 --- a/src/components/AchievementCard.tsx +++ b/src/components/AchievementCard.tsx @@ -11,6 +11,7 @@ interface Props { export default function AchievementCard({ invoiceId, totalAmount, onDismiss }: Props) { const cardRef = useRef(null); const [busy, setBusy] = useState(false); + const [copied, setCopied] = useState(false); const captureCanvas = async () => { // TODO: html2canvas not installed - commenting out for now @@ -25,9 +26,42 @@ export default function AchievementCard({ invoiceId, totalAmount, onDismiss }: P console.warn("Download feature disabled"); }; + const getShareUrl = () => + typeof window !== "undefined" + ? `${window.location.origin}/invoices/${invoiceId}` + : `/invoices/${invoiceId}`; + const handleShare = async () => { - // TODO: html2canvas not installed - share feature disabled - console.warn("Share feature disabled"); + const shareUrl = getShareUrl(); + const shareText = `I just got paid ${totalAmount} USDC via StellarSplit! ✦`; + + if (typeof navigator !== "undefined" && "share" in navigator) { + setBusy(true); + try { + await navigator.share({ + title: "StellarSplit Achievement", + text: shareText, + url: shareUrl, + }); + } catch (err) { + // AbortError happens when the user cancels the native share sheet — not an error. + if (!(err instanceof DOMException && err.name === "AbortError")) { + console.warn("Share failed", err); + } + } finally { + setBusy(false); + } + return; + } + + // Fallback: copy the achievement URL to the clipboard. + try { + await navigator.clipboard.writeText(`${shareText} ${shareUrl}`); + setCopied(true); + setTimeout(() => setCopied(false), 2000); + } catch (err) { + console.warn("Copy to clipboard failed", err); + } }; return ( @@ -54,16 +88,15 @@ export default function AchievementCard({ invoiceId, totalAmount, onDismiss }: P {/* Actions */}
- {"share" in navigator ? ( - - ) : null} + + ))} + {range === "custom" && ( +
+ setCustomFrom(e.target.value)} + aria-label="Custom range start date" + className="bg-gray-800 border border-gray-700 rounded-lg px-2 py-1 text-xs text-gray-200 focus:outline-none focus:ring-2 focus:ring-indigo-500" + /> + to + setCustomTo(e.target.value)} + aria-label="Custom range end date" + className="bg-gray-800 border border-gray-700 rounded-lg px-2 py-1 text-xs text-gray-200 focus:outline-none focus:ring-2 focus:ring-indigo-500" + /> +
+ )} +
+ + {isRefetching ? ( +
+
+ {[0, 1, 2].map((i) => ( + + ))} +
+
+ {[0, 1, 2].map((i) => ( + + ))} +
+ +
+ ) : ( + <> {/* Summary Cards */}
@@ -83,7 +183,7 @@ export default function AnalyticsPanel({ invoices }: Props) {

{t("dashboard.totalInvoices")}

-

{invoices.length}

+

{scopedInvoices.length}

@@ -133,6 +233,8 @@ export default function AnalyticsPanel({ invoices }: Props) { )} + + )} )} diff --git a/src/components/LeaderboardTable.tsx b/src/components/LeaderboardTable.tsx index e56454f..885c084 100644 --- a/src/components/LeaderboardTable.tsx +++ b/src/components/LeaderboardTable.tsx @@ -1,6 +1,6 @@ "use client"; -import { useMemo } from "react"; +import { useEffect, useMemo, useState } from "react"; import { formatAmount, truncateAddress } from "@stellar-split/sdk"; import { useI18n } from "@/components/I18nProvider"; @@ -17,8 +17,46 @@ interface Props { error: string | null; } +const PAGE_SIZE = 10; + +function renderRow( + r: LeaderboardRow, + rank: number, + isWallet: boolean, + t: (key: string) => string +) { + return ( +
  • +
    {rank}
    +
    + + {truncateAddress(r.address)} + + {isWallet && ( + + {t("leaderboard.you")} + + )} +
    +
    + {formatAmount(r.totalPaid)} +
    +
    {r.invoiceCount}
    +
  • + ); +} + export default function LeaderboardTable({ rows, publicKey, loading, error }: Props) { const { t } = useI18n(); + const [page, setPage] = useState(1); const walletRank = useMemo(() => { if (!publicKey) return null; @@ -26,6 +64,21 @@ export default function LeaderboardTable({ rows, publicKey, loading, error }: Pr return idx >= 0 ? idx + 1 : null; }, [publicKey, rows]); + const totalPages = Math.max(1, Math.ceil(rows.length / PAGE_SIZE)); + + // Reset to page 1 whenever the underlying data set changes. + useEffect(() => { + setPage(1); + }, [rows]); + + const pageStart = (page - 1) * PAGE_SIZE; + const pageRows = rows.slice(pageStart, pageStart + PAGE_SIZE); + + const walletOnPage = + walletRank !== null && walletRank > pageStart && walletRank <= pageStart + PAGE_SIZE; + const walletRow = + walletRank !== null && !walletOnPage ? rows[walletRank - 1] : null; + if (error) { return (
    {t("leaderboard.invoices")}
    + {/* Sticky reminder of the user's own rank when it falls outside the current page */} + {walletRow && walletRank !== null && ( + + )} + + + {totalPages > 1 && ( +
    + + + {t("leaderboard.page") || "Page"} {page} / {totalPages} + + +
    + )} ); diff --git a/src/components/RecipientPayoutTracker.tsx b/src/components/RecipientPayoutTracker.tsx index df1b66f..b234a46 100644 --- a/src/components/RecipientPayoutTracker.tsx +++ b/src/components/RecipientPayoutTracker.tsx @@ -1,6 +1,6 @@ "use client"; -import { useMemo, useState } from "react"; +import { useEffect, useMemo, useState } from "react"; import { formatAmount, truncateAddress } from "@stellar-split/sdk"; import type { Invoice, Recipient } from "@stellar-split/sdk"; import { RecipientDetailRow } from "@/components/invoice/RecipientRow"; @@ -21,8 +21,44 @@ export default function RecipientPayoutTracker({ invoice, publicKey, network = " const [claimingId, setClaimingId] = useState(null); const [claimError, setClaimError] = useState(null); const [claimTx, setClaimTx] = useState(null); + // Maps an old recipient address to its updated address after an approved + // AddressChangeRequestModal submission, so the tracker never shows stale data. + const [addressOverrides, setAddressOverrides] = useState>({}); - const { recipients } = invoice; + useEffect(() => { + const handleAddressChanged = (event: Event) => { + const detail = (event as CustomEvent<{ + invoiceId: string; + oldAddress: string; + newAddress: string; + }>).detail; + if (!detail || detail.invoiceId !== invoice.id) return; + setAddressOverrides((prev) => ({ + ...prev, + [detail.oldAddress]: detail.newAddress, + })); + }; + + window.addEventListener( + "recipient-address-changed", + handleAddressChanged as EventListener + ); + return () => + window.removeEventListener( + "recipient-address-changed", + handleAddressChanged as EventListener + ); + }, [invoice.id]); + + const recipients = useMemo( + () => + invoice.recipients.map((r) => + addressOverrides[r.address] + ? { ...r, address: addressOverrides[r.address] } + : r + ), + [invoice.recipients, addressOverrides] + ); const total = recipients.reduce((s, r) => s + r.amount, 0n); const getStatus = (recipient: Recipient): PayoutStatus => {