Due Date
{new Date(Number(invoice.due_date) * 1000).toLocaleDateString(undefined, { dateStyle: 'long' })}
diff --git a/src/components/RemindMeButton.tsx b/src/components/RemindMeButton.tsx
new file mode 100644
index 0000000..c18f883
--- /dev/null
+++ b/src/components/RemindMeButton.tsx
@@ -0,0 +1,88 @@
+"use client";
+
+import { useEffect, useMemo, useState } from "react";
+import type { Invoice } from "@/utils/soroban";
+import {
+ getInvoiceReminderPreference,
+ scheduleInvoiceReminder,
+ setInvoiceReminderPreference,
+} from "@/utils/invoiceReminders";
+
+interface RemindMeButtonProps {
+ invoice: Invoice;
+ viewerAddress: string | null;
+}
+
+export default function RemindMeButton({ invoice, viewerAddress }: RemindMeButtonProps) {
+ const [preference, setPreference] = useState<"enabled" | "denied" | null>(null);
+ const [isRequesting, setIsRequesting] = useState(false);
+ const isInvoiceParty = viewerAddress === invoice.payer || viewerAddress === invoice.freelancer;
+ const canUseNotifications = typeof window !== "undefined" && "Notification" in window;
+
+ useEffect(() => {
+ const timeout = window.setTimeout(() => {
+ setPreference(getInvoiceReminderPreference(invoice.id));
+ }, 0);
+ return () => window.clearTimeout(timeout);
+ }, [invoice.id]);
+
+ useEffect(() => {
+ if (!canUseNotifications || preference !== "enabled") return;
+ const timeoutId = scheduleInvoiceReminder(invoice);
+ return () => {
+ if (timeoutId !== null) window.clearTimeout(timeoutId);
+ };
+ }, [canUseNotifications, invoice, preference]);
+
+ const shouldPrompt = useMemo(() => {
+ if (!isInvoiceParty || !canUseNotifications || preference !== null) return false;
+ return Notification.permission !== "denied";
+ }, [canUseNotifications, isInvoiceParty, preference]);
+
+ const enableReminder = async () => {
+ if (!canUseNotifications) return;
+
+ setIsRequesting(true);
+ try {
+ const permission = Notification.permission === "default"
+ ? await Notification.requestPermission()
+ : Notification.permission;
+
+ if (permission === "granted") {
+ setInvoiceReminderPreference(invoice.id, "enabled");
+ setPreference("enabled");
+ } else {
+ setInvoiceReminderPreference(invoice.id, "denied");
+ setPreference("denied");
+ }
+ } finally {
+ setIsRequesting(false);
+ }
+ };
+
+ if (preference === "enabled") {
+ return (
+
+ Reminder enabled: Invoice #{invoice.id.toString()} is due tomorrow.
+
+ );
+ }
+
+ if (!shouldPrompt) return null;
+
+ return (
+
+
+ Get notified 24 hours before this invoice expires?
+
+
+
+ );
+}
diff --git a/src/components/__tests__/RemindMeButton.test.tsx b/src/components/__tests__/RemindMeButton.test.tsx
new file mode 100644
index 0000000..7795fde
--- /dev/null
+++ b/src/components/__tests__/RemindMeButton.test.tsx
@@ -0,0 +1,66 @@
+import React from "react";
+import { fireEvent, render, screen, waitFor } from "@testing-library/react";
+import { beforeEach, describe, expect, it, vi } from "vitest";
+import RemindMeButton from "../RemindMeButton";
+import type { Invoice } from "@/utils/soroban";
+
+const invoice: Invoice = {
+ id: 24n,
+ freelancer: "GFREELANCER",
+ payer: "GPAYER",
+ amount: 1000n,
+ due_date: BigInt(Math.floor(Date.now() / 1000) + 48 * 60 * 60),
+ discount_rate: 300,
+ status: "Funded",
+};
+
+function installNotification(permission: NotificationPermission, requestPermission = vi.fn()) {
+ Object.defineProperty(window, "Notification", {
+ configurable: true,
+ value: Object.assign(vi.fn(), { permission, requestPermission }),
+ });
+}
+
+describe("RemindMeButton", () => {
+ beforeEach(() => {
+ window.localStorage.clear();
+ vi.clearAllMocks();
+ installNotification("default", vi.fn().mockResolvedValue("granted"));
+ });
+
+ it("prompts invoice parties to opt into 24-hour due date reminders", () => {
+ render(
);
+
+ expect(screen.getByText("Get notified 24 hours before this invoice expires?")).toBeInTheDocument();
+ expect(screen.getByRole("button", { name: /remind me/i })).toBeInTheDocument();
+ });
+
+ it("does not prompt wallets that are not parties to the invoice", () => {
+ render(
);
+
+ expect(screen.queryByText("Get notified 24 hours before this invoice expires?")).not.toBeInTheDocument();
+ });
+
+ it("stores enabled preference after granted permission", async () => {
+ render(
);
+
+ fireEvent.click(screen.getByRole("button", { name: /remind me/i }));
+
+ await waitFor(() => {
+ expect(window.localStorage.getItem("iln_invoice_reminder_24")).toBe("enabled");
+ });
+ expect(screen.getByText("Reminder enabled: Invoice #24 is due tomorrow.")).toBeInTheDocument();
+ });
+
+ it("stores denied preference and hides the prompt when permission is denied", async () => {
+ installNotification("default", vi.fn().mockResolvedValue("denied"));
+ render(
);
+
+ fireEvent.click(screen.getByRole("button", { name: /remind me/i }));
+
+ await waitFor(() => {
+ expect(window.localStorage.getItem("iln_invoice_reminder_24")).toBe("denied");
+ });
+ expect(screen.queryByText("Get notified 24 hours before this invoice expires?")).not.toBeInTheDocument();
+ });
+});
diff --git a/src/utils/__tests__/invoiceReminders.test.ts b/src/utils/__tests__/invoiceReminders.test.ts
new file mode 100644
index 0000000..1d81258
--- /dev/null
+++ b/src/utils/__tests__/invoiceReminders.test.ts
@@ -0,0 +1,48 @@
+import { describe, expect, it, vi } from "vitest";
+import {
+ getInvoiceReminderKey,
+ getReminderDelayMs,
+ scheduleInvoiceReminder,
+} from "@/utils/invoiceReminders";
+import type { Invoice } from "@/utils/soroban";
+
+const invoice: Invoice = {
+ id: 9n,
+ freelancer: "GFREELANCER",
+ payer: "GPAYER",
+ amount: 1000n,
+ due_date: 1_900_000_000n,
+ discount_rate: 300,
+ status: "Funded",
+};
+
+describe("invoice reminder helpers", () => {
+ it("keys localStorage preferences by invoice ID", () => {
+ expect(getInvoiceReminderKey(9n)).toBe("iln_invoice_reminder_9");
+ });
+
+ it("schedules reminders 24 hours before the due date", () => {
+ const nowMs = Number(invoice.due_date) * 1000 - 30 * 60 * 60 * 1000;
+ expect(getReminderDelayMs(invoice, nowMs)).toBe(6 * 60 * 60 * 1000);
+ });
+
+ it("does not schedule reminders for past-due invoices", () => {
+ expect(getReminderDelayMs(invoice, Number(invoice.due_date) * 1000 + 1)).toBeNull();
+ });
+
+ it("fires the required browser notification message", () => {
+ vi.useFakeTimers();
+ const notification = vi.fn();
+ Object.defineProperty(window, "Notification", {
+ configurable: true,
+ value: Object.assign(notification, { permission: "granted" }),
+ });
+
+ const nowMs = Number(invoice.due_date) * 1000 - 24 * 60 * 60 * 1000;
+ scheduleInvoiceReminder(invoice, nowMs);
+ vi.runOnlyPendingTimers();
+
+ expect(notification).toHaveBeenCalledWith("Invoice #9 is due tomorrow");
+ vi.useRealTimers();
+ });
+});
diff --git a/src/utils/federation.ts b/src/utils/federation.ts
index 11922eb..391a8eb 100644
--- a/src/utils/federation.ts
+++ b/src/utils/federation.ts
@@ -4,6 +4,11 @@ import { RPC_URL } from "@/constants";
const horizonServer = new rpc.Server(RPC_URL);
const federationCache = new Map
();
+interface AccountHomeDomain {
+ home_domain?: string;
+ homeDomain?: string;
+}
+
export async function resolveFederatedAddress(address: string): Promise {
if (!address) return address;
const cached = federationCache.get(address);
@@ -11,10 +16,11 @@ export async function resolveFederatedAddress(address: string): Promise
try {
const account = await horizonServer.getAccount(address);
- const homeDomain = account.home_domain ?? (account as any).homeDomain;
- if (!homeDomain) return address;
+ const { home_domain: homeDomain, homeDomain: camelHomeDomain } = account as AccountHomeDomain;
+ const resolvedHomeDomain = homeDomain ?? camelHomeDomain;
+ if (!resolvedHomeDomain) return address;
- const stellarTomlResponse = await fetch(`https://${homeDomain}/.well-known/stellar.toml`);
+ const stellarTomlResponse = await fetch(`https://${resolvedHomeDomain}/.well-known/stellar.toml`);
if (!stellarTomlResponse.ok) return address;
const toml = await stellarTomlResponse.text();
diff --git a/src/utils/invoiceReminders.ts b/src/utils/invoiceReminders.ts
new file mode 100644
index 0000000..7db39e5
--- /dev/null
+++ b/src/utils/invoiceReminders.ts
@@ -0,0 +1,37 @@
+import type { Invoice } from "@/utils/soroban";
+
+export type ReminderPreference = "enabled" | "denied";
+
+export const REMINDER_ADVANCE_MS = 24 * 60 * 60 * 1000;
+
+export function getInvoiceReminderKey(invoiceId: bigint): string {
+ return `iln_invoice_reminder_${invoiceId.toString()}`;
+}
+
+export function getInvoiceReminderPreference(invoiceId: bigint): ReminderPreference | null {
+ if (typeof window === "undefined") return null;
+ const value = window.localStorage.getItem(getInvoiceReminderKey(invoiceId));
+ return value === "enabled" || value === "denied" ? value : null;
+}
+
+export function setInvoiceReminderPreference(invoiceId: bigint, preference: ReminderPreference): void {
+ window.localStorage.setItem(getInvoiceReminderKey(invoiceId), preference);
+}
+
+export function getReminderDelayMs(invoice: Invoice, nowMs = Date.now()): number | null {
+ const dueMs = Number(invoice.due_date) * 1000;
+ if (!Number.isFinite(dueMs) || dueMs <= nowMs) return null;
+ return Math.max(0, dueMs - REMINDER_ADVANCE_MS - nowMs);
+}
+
+export function scheduleInvoiceReminder(invoice: Invoice, nowMs = Date.now()): number | null {
+ if (typeof window === "undefined" || typeof Notification === "undefined") return null;
+ const delay = getReminderDelayMs(invoice, nowMs);
+ if (delay === null) return null;
+
+ return window.setTimeout(() => {
+ if (Notification.permission === "granted") {
+ new Notification(`Invoice #${invoice.id.toString()} is due tomorrow`);
+ }
+ }, delay);
+}