Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 6 additions & 3 deletions src/components/AmountDenominationInput.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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);
}
})();

Expand All @@ -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
Expand Down
100 changes: 93 additions & 7 deletions src/components/ReminderChecker.tsx
Original file line number Diff line number Diff line change
@@ -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<Reminder[]>([]);
const [openMenuFor, setOpenMenuFor] = useState<string | null>(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 (
<div className="fixed bottom-4 right-4 z-40 flex flex-col gap-2 max-w-sm">
{dueReminders.map((reminder) => (
<div
key={reminder.invoiceId}
className="bg-gray-900 border border-gray-700 rounded-lg p-3 shadow-lg flex items-start justify-between gap-3"
>
<div className="text-sm text-gray-200">
<p className="font-semibold">Invoice #{reminder.invoiceId}</p>
<p className="text-gray-400">{reminder.message}</p>
</div>
<div className="relative shrink-0">
<button
onClick={() =>
setOpenMenuFor(openMenuFor === reminder.invoiceId ? null : reminder.invoiceId)
}
className="text-xs font-medium px-2 py-1 rounded bg-gray-800 hover:bg-gray-700 transition-colors"
>
Snooze ▼
</button>
{openMenuFor === reminder.invoiceId && (
<div className="absolute top-full right-0 mt-1 bg-gray-800 border border-gray-700 rounded-lg shadow-lg z-50 min-w-max">
{SNOOZE_OPTIONS.map(({ option, label }) => (
<button
key={option}
onClick={() => handleSnooze(reminder.invoiceId, option)}
className="block w-full text-left px-3 py-2 text-xs hover:bg-gray-700 transition-colors border-t border-gray-700 first:border-t-0"
>
{label}
</button>
))}
</div>
)}
</div>
</div>
))}
</div>
);
}
33 changes: 33 additions & 0 deletions src/components/WebhookConfig.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,8 @@ export default function WebhookConfig({ invoiceId }: Props) {
const [testing, setTesting] = useState(false);
const [testStatus, setTestStatus] = useState<string | null>(null);
const [logs, setLogs] = useState<DeliveryLog[]>([]);
const [pinging, setPinging] = useState(false);
const [pingResult, setPingResult] = useState<string | null>(null);

useEffect(() => {
const stored = localStorage.getItem(storageKey(invoiceId));
Expand Down Expand Up @@ -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 (
<section aria-labelledby="webhook-heading" className="mt-8 border-t border-gray-800 pt-6">
<h2 id="webhook-heading" className="text-lg font-semibold mb-3">
Expand Down Expand Up @@ -177,6 +198,13 @@ export default function WebhookConfig({ invoiceId }: Props) {
>
{testing ? "Testing..." : "Test Webhook"}
</button>
<button
onClick={handleSendTestPing}
disabled={pinging}
className="min-h-11 px-4 py-2 rounded-lg bg-purple-600 hover:bg-purple-500 disabled:bg-gray-600 text-sm font-semibold transition-colors"
>
{pinging ? "Pinging..." : "Send Test Ping"}
</button>
<button
onClick={handleRemove}
className="min-h-11 px-4 py-2 rounded-lg bg-gray-700 hover:bg-gray-600 text-sm font-semibold transition-colors"
Expand All @@ -197,6 +225,11 @@ export default function WebhookConfig({ invoiceId }: Props) {
Test result: {testStatus}
</p>
)}
{pingResult && (
<p role="status" className="text-sm text-gray-300 whitespace-pre-wrap font-mono">
{pingResult}
</p>
)}
</div>

{logs.length > 0 && (
Expand Down
8 changes: 7 additions & 1 deletion src/lib/expirySnooze.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@

const SNOOZE_STORAGE_KEY = "invoice_snooze";

export type SnoozeOption = "1h" | "4h" | "until-tomorrow";
export type SnoozeOption = "1h" | "4h" | "24h" | "3d" | "until-tomorrow";

export interface SnoozeState {
invoiceId: string;
Expand Down Expand Up @@ -62,6 +62,12 @@ export function snoozeInvoice(
case "4h":
snoozedUntil = now + 4 * 60 * 60 * 1000;
break;
case "24h":
snoozedUntil = now + 24 * 60 * 60 * 1000;
break;
case "3d":
snoozedUntil = now + 3 * 24 * 60 * 60 * 1000;
break;
case "until-tomorrow":
const tomorrow = new Date();
tomorrow.setDate(tomorrow.getDate() + 1);
Expand Down