From 164616bab69095622821339ef252cc95784279db Mon Sep 17 00:00:00 2001 From: valoryyaa-byte Date: Fri, 28 Aug 2026 23:37:26 +0100 Subject: [PATCH 1/4] Update RecipientPayoutTracker on recipient address change Refs #621 --- src/components/AddressChangeRequestModal.tsx | 11 ++++++ src/components/RecipientPayoutTracker.tsx | 40 +++++++++++++++++++- 2 files changed, 49 insertions(+), 2 deletions(-) diff --git a/src/components/AddressChangeRequestModal.tsx b/src/components/AddressChangeRequestModal.tsx index 40acf94..0dc590a 100644 --- a/src/components/AddressChangeRequestModal.tsx +++ b/src/components/AddressChangeRequestModal.tsx @@ -55,6 +55,17 @@ export default function AddressChangeRequestModal({ try { await onSubmit(formData); + // Notify listeners (e.g. RecipientPayoutTracker) so they can update + // without requiring a full page reload once the change is approved. + window.dispatchEvent( + new CustomEvent('recipient-address-changed', { + detail: { + invoiceId, + oldAddress: formData.oldAddress, + newAddress: formData.newAddress, + }, + }) + ); setSuccess(true); setTimeout(() => { onClose(); 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 => { From f68d78a2709d02ed50603fb5102e41419b147a2d Mon Sep 17 00:00:00 2001 From: valoryyaa-byte Date: Fri, 28 Aug 2026 23:38:03 +0100 Subject: [PATCH 2/4] Add date-range selector to AnalyticsPanel Refs #622 --- src/components/AnalyticsPanel.tsx | 134 ++++++++++++++++++++++++++---- 1 file changed, 118 insertions(+), 16 deletions(-) diff --git a/src/components/AnalyticsPanel.tsx b/src/components/AnalyticsPanel.tsx index 08743d8..0c9b4c9 100644 --- a/src/components/AnalyticsPanel.tsx +++ b/src/components/AnalyticsPanel.tsx @@ -1,18 +1,65 @@ "use client"; -import { useState } from "react"; +import { useEffect, useMemo, useState } from "react"; import { formatAmount } from "@stellar-split/sdk"; import { useI18n } from "@/components/I18nProvider"; import type { Invoice } from "@stellar-split/sdk"; import { BarChart, Bar, XAxis, YAxis, CartesianGrid, Tooltip, Legend, ResponsiveContainer } from "recharts"; +import { Skeleton } from "@/components/Skeleton"; interface Props { invoices: Invoice[]; } +type RangePreset = "7d" | "30d" | "3m" | "custom"; + +interface RangeOption { + key: RangePreset; + label: string; + days: number | null; +} + +const RANGE_OPTIONS: RangeOption[] = [ + { key: "7d", label: "Last 7 days", days: 7 }, + { key: "30d", label: "Last 30 days", days: 30 }, + { key: "3m", label: "Last 3 months", days: 90 }, + { key: "custom", label: "Custom", days: null }, +]; + export default function AnalyticsPanel({ invoices }: Props) { const { t } = useI18n(); const [isOpen, setIsOpen] = useState(false); + const [range, setRange] = useState("30d"); + const [customFrom, setCustomFrom] = useState(""); + const [customTo, setCustomTo] = useState(""); + const [isRefetching, setIsRefetching] = useState(false); + + const now = Date.now() / 1000; + + // Determine the [from, to] window (in unix seconds) for the active range. + const { from, to } = useMemo(() => { + if (range === "custom") { + const fromTs = customFrom ? new Date(customFrom).getTime() / 1000 : 0; + const toTs = customTo ? new Date(customTo).getTime() / 1000 : now; + return { from: fromTs, to: toTs }; + } + const option = RANGE_OPTIONS.find((o) => o.key === range); + const days = option?.days ?? 30; + return { from: now - days * 24 * 60 * 60, to: now }; + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [range, customFrom, customTo]); + + // Simulate a metrics refetch whenever the selected range changes. + useEffect(() => { + setIsRefetching(true); + const id = setTimeout(() => setIsRefetching(false), 400); + return () => clearTimeout(id); + }, [range, customFrom, customTo]); + + const scopedInvoices = useMemo( + () => invoices.filter((inv) => inv.deadline >= from && inv.deadline <= to), + [invoices, from, to] + ); // Calculate summary stats const stats = { @@ -23,8 +70,7 @@ export default function AnalyticsPanel({ invoices }: Props) { refunded: 0, }; - invoices.forEach((inv) => { - const total = inv.recipients.reduce((sum, r) => sum + r.amount, 0n); + scopedInvoices.forEach((inv) => { if (inv.status === "Pending") stats.pending++; else if (inv.status === "Released") stats.released++; else if (inv.status === "Refunded") stats.refunded++; @@ -34,21 +80,16 @@ export default function AnalyticsPanel({ invoices }: Props) { stats.totalReceived += inv.funded; }); - // Group invoices by week (last 30 days) - const now = Date.now() / 1000; - const thirtyDaysAgo = now - 30 * 24 * 60 * 60; - + // Group scoped invoices by week const weekData: Record = {}; - invoices.forEach((inv) => { - if (inv.deadline >= thirtyDaysAgo) { - const date = new Date(inv.deadline * 1000); - const weekStart = new Date(date); - weekStart.setDate(date.getDate() - date.getDay()); - const weekKey = weekStart.toISOString().slice(0, 10); + scopedInvoices.forEach((inv) => { + const date = new Date(inv.deadline * 1000); + const weekStart = new Date(date); + weekStart.setDate(date.getDate() - date.getDay()); + const weekKey = weekStart.toISOString().slice(0, 10); - weekData[weekKey] = (weekData[weekKey] || 0) + 1; - } + weekData[weekKey] = (weekData[weekKey] || 0) + 1; }); const chartData = Object.entries(weekData) @@ -71,6 +112,65 @@ export default function AnalyticsPanel({ invoices }: Props) { {isOpen && (
+ {/* Date-range selector */} +
+ {RANGE_OPTIONS.map((option) => ( + + ))} + {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) {
)} + + )} )} From 843e6cac2bfe0fee12dcc537a1dd726dcdd1010b Mon Sep 17 00:00:00 2001 From: valoryyaa-byte Date: Fri, 28 Aug 2026 23:38:30 +0100 Subject: [PATCH 3/4] Paginate LeaderboardTable to 10 rows per page Refs #623 --- src/components/LeaderboardTable.tsx | 124 ++++++++++++++++++++-------- 1 file changed, 89 insertions(+), 35 deletions(-) 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 && ( +
      + {renderRow(walletRow, walletRank, true, t)} +
    + )} +
      - {rows.map((r, idx) => { - const rank = idx + 1; - const isWallet = publicKey && r.address === publicKey; - - return ( -
    • -
      {rank}
      -
      - - {truncateAddress(r.address)} - - {isWallet && ( - - {t("leaderboard.you")} - - )} -
      -
      - {formatAmount(r.totalPaid)} -
      -
      {r.invoiceCount}
      -
    • - ); + {pageRows.map((r, idx) => { + const rank = pageStart + idx + 1; + const isWallet = Boolean(publicKey && r.address === publicKey); + return renderRow(r, rank, isWallet, t); })}
    + + {totalPages > 1 && ( +
    + + + {t("leaderboard.page") || "Page"} {page} / {totalPages} + + +
    + )} ); From 6bad515852bccb54f6501ab78395addaefb0ee31 Mon Sep 17 00:00:00 2001 From: valoryyaa-byte Date: Fri, 28 Aug 2026 23:39:02 +0100 Subject: [PATCH 4/4] Add share-to-social button to AchievementCard Refs #624 --- src/components/AchievementCard.tsx | 57 +++++++++++++++++++++++------- 1 file changed, 45 insertions(+), 12 deletions(-) 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} +