Skip to content
Merged
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
24 changes: 24 additions & 0 deletions src/__tests__/AdvancedPaymentMethodPicker.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
import React from "react";
import { render, screen, fireEvent } from "@testing-library/react";
import AdvancedPaymentMethodPicker, { DEFAULT_PAYMENT_METHODS } from "@/components/AdvancedPaymentMethodPicker";

describe("AdvancedPaymentMethodPicker", () => {
it("selects a method on click", () => {
const onChange = vi.fn();
render(<AdvancedPaymentMethodPicker value="freighter" onChange={onChange} />);
expect(screen.getByRole("radio", { name: /Freighter wallet/ })).toHaveAttribute("aria-checked", "true");
fireEvent.click(screen.getByRole("radio", { name: /Batch payment/ }));
expect(onChange).toHaveBeenCalledWith("batch");
});

it("skips disabled methods with arrow keys", () => {
const onChange = vi.fn();
const methods = DEFAULT_PAYMENT_METHODS.map((m) =>
m.id === "batch" ? { ...m, disabled: true, disabledReason: "Unavailable" } : m
);
render(<AdvancedPaymentMethodPicker value="freighter" onChange={onChange} methods={methods} />);
expect(screen.getByText("Unavailable")).toBeInTheDocument();
fireEvent.keyDown(screen.getByRole("radio", { name: /Freighter wallet/ }), { key: "ArrowDown" });
expect(onChange).toHaveBeenCalledWith("crossChain");
});
});
37 changes: 37 additions & 0 deletions src/__tests__/InvoiceCalendar.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
import React from "react";
import { render, screen, fireEvent } from "@testing-library/react";
import InvoiceCalendar from "@/components/InvoiceCalendar";
import { buildMonthGrid, groupByDeadline } from "@/lib/invoiceCalendar";

const ts = (y: number, m: number, d: number) => Math.floor(new Date(y, m, d, 12).getTime() / 1000);
const invoices = [
{ id: "1", deadline: ts(2026, 8, 15), status: "Pending" },
{ id: "2", deadline: ts(2026, 8, 15), status: "Released" },
{ id: "3", deadline: 0, status: "Pending" },
];

describe("invoiceCalendar lib", () => {
it("groups by deadline day and skips missing deadlines", () => {
const grouped = groupByDeadline(invoices);
expect(grouped.get("2026-09-15")?.map((i) => i.id)).toEqual(["1", "2"]);
expect(grouped.size).toBe(1);
});

it("builds a 42-day grid starting on Sunday", () => {
const grid = buildMonthGrid(2026, 8, invoices);
expect(grid).toHaveLength(42);
expect(grid[0].date.getDay()).toBe(0);
expect(grid.find((d) => d.key === "2026-09-15")?.invoices).toHaveLength(2);
});
});

describe("InvoiceCalendar", () => {
it("shows invoices for a selected day and navigates months", () => {
render(<InvoiceCalendar invoices={invoices} initialDate={new Date(2026, 8, 1)} />);
expect(screen.getByText(/September 2026/)).toBeInTheDocument();
fireEvent.click(screen.getByLabelText(/Sep 15 2026, 2 invoices due/));
expect(screen.getByTestId("calendar-day-detail")).toHaveTextContent("Invoice #1");
fireEvent.click(screen.getByLabelText("Next month"));
expect(screen.getByText(/October 2026/)).toBeInTheDocument();
});
});
26 changes: 26 additions & 0 deletions src/__tests__/earningsForecast.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
import { forecastEarnings, totalForecast } from "@/lib/earningsForecast";

describe("forecastEarnings", () => {
it("projects a linear trend", () => {
const history = [10, 20, 30].map((v, i) => ({ label: `m${i}`, value: v }));
const points = forecastEarnings(history, 2, ["a", "b"]);
expect(points).toHaveLength(5);
expect(points[2].forecast).toBe(30);
expect(points[3]).toMatchObject({ label: "a", forecast: 40, low: 40, high: 40 });
expect(points[4].forecast).toBe(50);
expect(totalForecast(points)).toBe(90);
});

it("never projects negative earnings and widens the band", () => {
const history = [10, 30, 20, 40].map((v, i) => ({ label: `m${i}`, value: v }));
const points = forecastEarnings(history, 6);
const future = points.filter((p) => p.actual === undefined);
future.forEach((p) => expect(p.low!).toBeGreaterThanOrEqual(0));
expect(future[5].high! - future[5].forecast!).toBeGreaterThan(future[0].high! - future[0].forecast!);
});

it("returns history unchanged when empty or no horizon", () => {
expect(forecastEarnings([], 3)).toEqual([]);
expect(forecastEarnings([{ label: "x", value: 1 }], 0)).toEqual([{ label: "x", actual: 1 }]);
});
});
33 changes: 33 additions & 0 deletions src/__tests__/sharePermissions.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
import { can, revokeGrant, upsertGrant, type ShareGrant } from "@/lib/sharePermissions";

const OWNER = "GOWNER";
const EDITOR = "GEDITOR";
const base: ShareGrant[] = [
{ address: OWNER, role: "owner", grantedAt: 0 },
{ address: EDITOR, role: "editor", grantedAt: 0 },
];

describe("sharePermissions", () => {
it("resolves role permissions with allow/deny overrides", () => {
expect(can(base, EDITOR, "edit")).toBe(true);
expect(can(base, EDITOR, "cancel")).toBe(false);
const g = upsertGrant(base, OWNER, { address: EDITOR, role: "editor", allow: ["cancel"], deny: ["edit"] });
expect(can(g, EDITOR, "cancel")).toBe(true);
expect(can(g, EDITOR, "edit")).toBe(false);
expect(can(g, "GUNKNOWN", "view")).toBe(false);
});

it("revokes access after expiry", () => {
const g = upsertGrant(base, OWNER, { address: "GV", role: "viewer", expiresAt: 1000 }, 0);
expect(can(g, "GV", "view", 999)).toBe(true);
expect(can(g, "GV", "view", 1000)).toBe(false);
});

it("enforces who can manage access", () => {
expect(() => upsertGrant(base, EDITOR, { address: "GX", role: "viewer" })).toThrow();
expect(() => revokeGrant(base, OWNER, OWNER)).toThrow();
const admin = upsertGrant(base, OWNER, { address: "GA", role: "editor", allow: ["manageAccess"] });
expect(() => upsertGrant(admin, "GA", { address: "GX", role: "owner" })).toThrow(/higher/);
expect(revokeGrant(base, OWNER, EDITOR)).toHaveLength(1);
});
});
43 changes: 43 additions & 0 deletions src/app/dashboard/calendar/page.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,43 @@
"use client";

import { useEffect, useState } from "react";
import type { Invoice } from "@stellar-split/sdk";
import { splitClient } from "@/lib/stellar";
import { getFreighterPublicKey } from "@/lib/freighter";
import InvoiceCalendar from "@/components/InvoiceCalendar";

export default function InvoiceCalendarPage() {
const [invoices, setInvoices] = useState<Invoice[]>([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);

useEffect(() => {
async function load() {
try {
const pk = await getFreighterPublicKey().catch(() => null);
if (!pk) {
setError("Connect your wallet to view your invoice calendar.");
return;
}
// as any: getInvoicesByCreator is not yet declared in the published @stellar-split/sdk types
const result: Invoice[] = (await (splitClient as any).getInvoicesByCreator(pk)) ?? [];
setInvoices(result);
} catch (err) {
setError(String(err));
} finally {
setLoading(false);
}
}
load();
}, []);

return (
<main className="max-w-5xl mx-auto w-full px-2 sm:px-6 py-8">
<h1 className="text-3xl font-bold mb-2 px-2 sm:px-0">Invoice Calendar</h1>
<p className="text-gray-400 mb-6 px-2 sm:px-0">Your invoices laid out by deadline.</p>
{loading && <div className="animate-pulse h-96 bg-gray-800 rounded-xl" />}
{error && <p role="alert" className="text-red-400 text-sm">{error}</p>}
{!loading && !error && <InvoiceCalendar invoices={invoices} />}
</main>
);
}
5 changes: 5 additions & 0 deletions src/app/revenue/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ import {
ResponsiveContainer,
} from "recharts";
import type { Invoice } from "@stellar-split/sdk";
import EarningsForecastChart from "@/components/EarningsForecastChart";

/** Convert a bigint USDC amount (7 decimals) to a JS number for charting. */
function toUsdc(amount: bigint): number {
Expand Down Expand Up @@ -191,6 +192,10 @@ export default function RevenuePage() {
</div>
</section>

<EarningsForecastChart
history={monthlyData.map((d) => ({ label: d.month, value: d.usdc }))}
/>

{/* Invoice breakdown table */}
<section aria-labelledby="breakdown-heading" className="bg-gray-900 rounded-xl p-5">
<h2 id="breakdown-heading" className="text-lg font-semibold mb-4">Invoice Breakdown</h2>
Expand Down
27 changes: 27 additions & 0 deletions src/app/settings/sharing/page.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
"use client";

import { useEffect, useState } from "react";
import { getFreighterPublicKey } from "@/lib/freighter";
import SharePermissionsPanel from "@/components/SharePermissionsPanel";

export default function SharingSettingsPage() {
const [address, setAddress] = useState<string | null>(null);
const [error, setError] = useState<string | null>(null);

useEffect(() => {
getFreighterPublicKey()
.then((pk) => (pk ? setAddress(pk) : setError("Connect your wallet to manage shared access.")))
.catch(() => setError("Connect your wallet to manage shared access."));
}, []);

return (
<main className="max-w-2xl mx-auto w-full px-4 sm:px-6 py-16">
<h1 className="text-3xl font-bold mb-2">Sharing & permissions</h1>
<p className="text-gray-400 mb-8">
Control who can view, comment on, edit, or manage your invoices.
</p>
{error && <p role="alert" className="text-red-400 text-sm">{error}</p>}
{address && <SharePermissionsPanel resourceId={`account:${address}`} currentAddress={address} />}
</main>
);
}
124 changes: 124 additions & 0 deletions src/components/AdvancedPaymentMethodPicker.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,124 @@
"use client";

import { useRef } from "react";

export type PaymentMethodId = "freighter" | "batch" | "crossChain" | "confidential";

export interface PaymentMethodOption {
id: PaymentMethodId;
label: string;
description: string;
/** Estimated network fee, e.g. "~0.00001 XLM". */
fee: string;
/** Estimated settlement time, e.g. "~5s". */
eta: string;
recommended?: boolean;
disabled?: boolean;
disabledReason?: string;
}

export const DEFAULT_PAYMENT_METHODS: PaymentMethodOption[] = [
{
id: "freighter",
label: "Freighter wallet",
description: "Pay directly in USDC from your connected Stellar wallet.",
fee: "~0.00001 XLM",
eta: "~5s",
recommended: true,
},
{
id: "batch",
label: "Batch payment",
description: "Queue this payment with others and settle them in one transaction.",
fee: "Shared",
eta: "On submit",
},
{
id: "crossChain",
label: "Cross-chain bridge",
description: "Pay from another chain; funds are bridged to Stellar USDC.",
fee: "Bridge fee",
eta: "~2–10 min",
},
{
id: "confidential",
label: "Confidential payment",
description: "Hide the contributed amount from other participants.",
fee: "~0.0001 XLM",
eta: "~10s",
},
];

interface Props {
value: PaymentMethodId | null;
onChange: (id: PaymentMethodId) => void;
methods?: PaymentMethodOption[];
}

export default function AdvancedPaymentMethodPicker({
value,
onChange,
methods = DEFAULT_PAYMENT_METHODS,
}: Props) {
const refs = useRef<(HTMLButtonElement | null)[]>([]);
const enabled = methods.map((m, i) => (m.disabled ? -1 : i)).filter((i) => i >= 0);
const focusIndex = Math.max(
methods.findIndex((m) => m.id === value && !m.disabled),
enabled[0] ?? 0
);

const onKeyDown = (e: React.KeyboardEvent, index: number) => {
const dir =
e.key === "ArrowDown" || e.key === "ArrowRight" ? 1 : e.key === "ArrowUp" || e.key === "ArrowLeft" ? -1 : 0;
if (!dir || enabled.length === 0) return;
e.preventDefault();
const pos = enabled.indexOf(index);
const next = enabled[(pos + dir + enabled.length) % enabled.length];
refs.current[next]?.focus();
onChange(methods[next].id);
};

return (
<div role="radiogroup" aria-label="Payment method" className="grid grid-cols-1 sm:grid-cols-2 gap-3">
{methods.map((m, i) => {
const selected = value === m.id;
return (
<button
key={m.id}
ref={(el) => {
refs.current[i] = el;
}}
type="button"
role="radio"
aria-checked={selected}
aria-disabled={m.disabled || undefined}
disabled={m.disabled}
tabIndex={i === focusIndex ? 0 : -1}
onClick={() => onChange(m.id)}
onKeyDown={(e) => onKeyDown(e, i)}
className={`text-left rounded-xl border p-4 transition-colors focus:outline-none focus-visible:ring-2 focus-visible:ring-indigo-400 ${
selected ? "border-indigo-500 bg-indigo-500/10" : "border-gray-700 bg-gray-900 hover:border-gray-500"
} disabled:opacity-50 disabled:cursor-not-allowed`}
>
<div className="flex items-center justify-between gap-2">
<span className="font-medium">{m.label}</span>
{m.recommended && (
<span className="rounded-full bg-emerald-500/20 text-emerald-300 text-[10px] px-2 py-0.5">
Recommended
</span>
)}
</div>
<p className="text-sm text-gray-400 mt-1">{m.description}</p>
<div className="flex gap-4 mt-3 text-xs text-gray-500">
<span>Fee: {m.fee}</span>
<span>Time: {m.eta}</span>
</div>
{m.disabled && m.disabledReason && (
<p className="text-xs text-amber-300 mt-2">{m.disabledReason}</p>
)}
</button>
);
})}
</div>
);
}
Loading