From 5192d38990ef9d070124ebc168f22127efaa10b3 Mon Sep 17 00:00:00 2001 From: studiomonkeyx Date: Mon, 31 Aug 2026 11:41:32 +0000 Subject: [PATCH] feat: reminder snooze, webhook test ping, and XLM/USDC precision fix - ReminderChecker: surface due reminders with a Snooze dropdown (1h/24h/3d) backed by expirySnooze persistence - expirySnooze: add 24h and 3d snooze options - WebhookConfig: add "Send Test Ping" button that POSTs a test payload and shows the response status/body inline - AmountDenominationInput: format converted amounts to 7 decimals for XLM and 2 decimals for USDC Closes #659 Closes #660 Closes #655 TransferOwnershipModal (#664) already satisfies its acceptance criteria (double-entry address confirmation with disabled submit until match) per existing tests in src/__tests__/TransferOwnershipModal.test.tsx; no change needed. Closes #664 --- src/components/AmountDenominationInput.tsx | 9 +- src/components/ReminderChecker.tsx | 100 +++++++++++++++++++-- src/components/WebhookConfig.tsx | 33 +++++++ src/lib/expirySnooze.ts | 8 +- 4 files changed, 139 insertions(+), 11 deletions(-) diff --git a/src/components/AmountDenominationInput.tsx b/src/components/AmountDenominationInput.tsx index 24a6d218..2151476e 100644 --- a/src/components/AmountDenominationInput.tsx +++ b/src/components/AmountDenominationInput.tsx @@ -53,9 +53,9 @@ export default function AmountDenominationInput({ if (!xlmToUsdcRate || !value || isNaN(parseFloat(value))) return null; const n = parseFloat(value); if (denomination === "XLM") { - return (n * xlmToUsdcRate).toFixed(4); + return (n * xlmToUsdcRate).toFixed(2); } else { - return (n / xlmToUsdcRate).toFixed(4); + return (n / xlmToUsdcRate).toFixed(7); } })(); @@ -77,8 +77,11 @@ export default function AmountDenominationInput({ converted = n / xlmToUsdcRate; } + // next === "USDC" means we just converted XLM -> USDC (2 decimals); + // next === "XLM" means we just converted USDC -> XLM (7 decimals). + const decimals = next === "USDC" ? 2 : 7; onDenominationChange(next); - onChange(converted.toFixed(7).replace(/\.?0+$/, "")); + onChange(converted.toFixed(decimals)); }, [denomination, value, xlmToUsdcRate, onDenominationChange, onChange]); const borderCls = error diff --git a/src/components/ReminderChecker.tsx b/src/components/ReminderChecker.tsx index 94fc4ccf..483cbf75 100644 --- a/src/components/ReminderChecker.tsx +++ b/src/components/ReminderChecker.tsx @@ -1,16 +1,102 @@ "use client"; -import { useEffect } from "react"; -import { checkAndFireReminders } from "@/lib/reminders"; +import { useState, useEffect, useCallback } from "react"; +import { getReminders, type Reminder } from "@/lib/reminders"; +import { getSnoozeState, snoozeInvoice, type SnoozeOption } from "@/lib/expirySnooze"; + +const CHECK_INTERVAL_MS = 60_000; + +const SNOOZE_OPTIONS: { option: SnoozeOption; label: string }[] = [ + { option: "1h", label: "1 hour" }, + { option: "24h", label: "24 hours" }, + { option: "3d", label: "3 days" }, +]; + +async function fireNotification(reminder: Reminder) { + if (!("Notification" in window)) return; + const show = () => + new Notification(`StellarSplit Reminder — Invoice #${reminder.invoiceId}`, { + body: reminder.message, + icon: "/favicon.ico", + }); + if (Notification.permission === "granted") { + show(); + } else if (Notification.permission === "default") { + const permission = await Notification.requestPermission(); + if (permission === "granted") show(); + } +} /** - * ReminderChecker — invisible client component that fires browser notifications - * for any past-due reminders on every page load. + * ReminderChecker — fires browser notifications for past-due reminders and + * lets the user snooze any that are currently showing. */ export default function ReminderChecker() { - useEffect(() => { - checkAndFireReminders(); + const [dueReminders, setDueReminders] = useState([]); + const [openMenuFor, setOpenMenuFor] = useState(null); + + const checkReminders = useCallback(() => { + const now = new Date(); + const due = getReminders().filter( + (r) => new Date(r.reminderDate) <= now && !getSnoozeState(r.invoiceId) + ); + setDueReminders((prev) => { + const newlyDue = due.filter((r) => !prev.some((p) => p.invoiceId === r.invoiceId)); + newlyDue.forEach(fireNotification); + return due; + }); }, []); - return null; + useEffect(() => { + checkReminders(); + const interval = setInterval(checkReminders, CHECK_INTERVAL_MS); + return () => clearInterval(interval); + }, [checkReminders]); + + const handleSnooze = (invoiceId: string, option: SnoozeOption) => { + snoozeInvoice(invoiceId, option); + setOpenMenuFor(null); + setDueReminders((prev) => prev.filter((r) => r.invoiceId !== invoiceId)); + }; + + if (dueReminders.length === 0) return null; + + return ( +
+ {dueReminders.map((reminder) => ( +
+
+

Invoice #{reminder.invoiceId}

+

{reminder.message}

+
+
+ + {openMenuFor === reminder.invoiceId && ( +
+ {SNOOZE_OPTIONS.map(({ option, label }) => ( + + ))} +
+ )} +
+
+ ))} +
+ ); } diff --git a/src/components/WebhookConfig.tsx b/src/components/WebhookConfig.tsx index cad0b359..e00e1db8 100644 --- a/src/components/WebhookConfig.tsx +++ b/src/components/WebhookConfig.tsx @@ -31,6 +31,8 @@ export default function WebhookConfig({ invoiceId }: Props) { const [testing, setTesting] = useState(false); const [testStatus, setTestStatus] = useState(null); const [logs, setLogs] = useState([]); + const [pinging, setPinging] = useState(false); + const [pingResult, setPingResult] = useState(null); useEffect(() => { const stored = localStorage.getItem(storageKey(invoiceId)); @@ -114,6 +116,25 @@ export default function WebhookConfig({ invoiceId }: Props) { } }; + const handleSendTestPing = async () => { + if (!url) return; + setPinging(true); + setPingResult(null); + try { + const res = await fetch(url, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ event: "test" }), + }); + const text = await res.text(); + setPingResult(`Status: ${res.status}\n${text.slice(0, 200)}`); + } catch (e) { + setPingResult(`Error: ${String(e)}`); + } finally { + setPinging(false); + } + }; + return (

@@ -177,6 +198,13 @@ export default function WebhookConfig({ invoiceId }: Props) { > {testing ? "Testing..." : "Test Webhook"} +