diff --git a/src/components/AmountDenominationInput.tsx b/src/components/AmountDenominationInput.tsx index 24a6d21..2151476 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 94fc4cc..483cbf7 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 cad0b35..e00e1db 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"} +