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
30 changes: 30 additions & 0 deletions src/__tests__/CreatorNetwork.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
import { render, screen, fireEvent } from "@testing-library/react";
import CreatorNetwork from "@/components/network/CreatorNetwork";
import { acceptConnection, requestConnection, suggestCreators } from "@/lib/creatorNetwork";

const me = { address: "ME", name: "Me", tags: ["design", "web"] };
const creators = [
{ address: "A", name: "Ada", tags: ["Design", "web"] },
{ address: "B", name: "Bo", tags: ["audio"] },
{ address: "C", name: "Cy", tags: ["web"] },
];

describe("creator network", () => {
it("suggests creators by shared tags, best match first", () => {
const s = suggestCreators(me, creators, { connections: [], pending: [] });
expect(s.map((x) => x.creator.address)).toEqual(["A", "C"]);
});

it("moves requests through pending to connected and ignores self", () => {
const empty = { connections: [], pending: [] };
expect(requestConnection(empty, "ME", "ME")).toBe(empty);
const pending = requestConnection(empty, "ME", "A");
expect(acceptConnection(pending, "A")).toEqual({ connections: ["A"], pending: [] });
});

it("shows a pending request after clicking Connect", () => {
render(<CreatorNetwork me={me} creators={creators} />);
fireEvent.click(screen.getAllByText("Connect")[0]);
expect(screen.getByText("Pending (1)")).toBeInTheDocument();
});
});
36 changes: 36 additions & 0 deletions src/__tests__/InvoiceCollaborationWorkspace.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
import { render, screen, fireEvent } from "@testing-library/react";
import InvoiceCollaborationWorkspace from "@/components/workspace/InvoiceCollaborationWorkspace";
import { addNote, progress, setRole } from "@/lib/invoiceWorkspace";

const initial = {
participants: [
{ id: "o", name: "Olu", role: "owner" as const },
{ id: "v", name: "Vee", role: "viewer" as const },
],
notes: [],
tasks: [{ id: "t1", title: "Confirm amounts", done: false }],
};

describe("invoice collaboration workspace", () => {
it("blocks viewers from adding notes and protects the owner role", () => {
const note = { id: "n", authorId: "v", text: "hi", createdAt: "2026-01-01T00:00:00Z" };
expect(addNote(initial, note)).toBe(initial);
expect(setRole(initial, "o", "viewer")).toBe(initial);
expect(progress(initial)).toBe(0);
});

it("lets an editor add notes and complete tasks", () => {
render(<InvoiceCollaborationWorkspace initial={initial} currentUserId="o" />);
fireEvent.change(screen.getByLabelText("New note"), { target: { value: "Looks good" } });
fireEvent.click(screen.getByText("Add note"));
expect(screen.getByText("Looks good")).toBeInTheDocument();
fireEvent.click(screen.getByLabelText("Confirm amounts"));
expect(screen.getByText("100% done")).toBeInTheDocument();
});

it("is read-only for viewers", () => {
render(<InvoiceCollaborationWorkspace initial={initial} currentUserId="v" />);
expect(screen.queryByLabelText("New note")).not.toBeInTheDocument();
expect(screen.getByText("You have view-only access.")).toBeInTheDocument();
});
});
24 changes: 24 additions & 0 deletions src/__tests__/InvoiceMilestoneCelebration.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
import { render, screen, fireEvent } from "@testing-library/react";
import InvoiceMilestoneCelebration from "@/components/milestones/InvoiceMilestoneCelebration";
import { fundedPercent, newestMilestone } from "@/lib/invoiceMilestones";

describe("invoice milestones", () => {
it("computes funding and the newest milestone", () => {
expect(fundedPercent(150, 100)).toBe(100);
expect(fundedPercent(5, 0)).toBe(0);
expect(newestMilestone(10, 100)).toBeNull();
expect(newestMilestone(60, 100)?.percent).toBe(50);
});

it("celebrates the newest milestone and can be dismissed", () => {
render(<InvoiceMilestoneCelebration raised={80} target={100} />);
expect(screen.getByRole("status")).toHaveTextContent("75% funded");
fireEvent.click(screen.getByText("Dismiss"));
expect(screen.queryByRole("status")).not.toBeInTheDocument();
});

it("shows no banner before the first milestone", () => {
render(<InvoiceMilestoneCelebration raised={10} target={100} />);
expect(screen.queryByRole("status")).not.toBeInTheDocument();
});
});
26 changes: 26 additions & 0 deletions src/__tests__/payerBehavior.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
import { render, screen, fireEvent } from "@testing-library/react";
import PayerBehaviorAnalytics from "@/components/analytics/PayerBehaviorAnalytics";
import { analyzePayers } from "@/lib/payerBehavior";

const payments = [
{ payer: "GA", invoiceId: "1", amount: 10, dueAt: "2026-01-10T00:00:00Z", paidAt: "2026-01-09T00:00:00Z" },
{ payer: "GA", invoiceId: "2", amount: 20, dueAt: "2026-02-10T00:00:00Z", paidAt: "2026-02-10T00:00:00Z" },
{ payer: "GB", invoiceId: "3", amount: 5, dueAt: "2026-01-10T00:00:00Z", paidAt: null },
];

describe("payer behavior", () => {
it("segments payers by punctuality", () => {
const [ga, gb] = analyzePayers(payments);
expect(ga).toMatchObject({ payer: "GA", totalPaid: 30, onTimeRate: 1, segment: "reliable" });
expect(gb.segment).toBe("at-risk");
});

it("filters by segment and shows an empty state", () => {
const { rerender } = render(<PayerBehaviorAnalytics payments={payments} />);
fireEvent.change(screen.getByLabelText("Segment"), { target: { value: "at-risk" } });
expect(screen.queryByText("GA")).not.toBeInTheDocument();
expect(screen.getByText("GB")).toBeInTheDocument();
rerender(<PayerBehaviorAnalytics payments={[]} />);
expect(screen.getByText("No payer activity yet.")).toBeInTheDocument();
});
});
10 changes: 10 additions & 0 deletions src/app/analytics/payers/page.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
import PayerBehaviorAnalytics from "@/components/analytics/PayerBehaviorAnalytics";

export default function PayerBehaviorPage() {
return (
<main className="mx-auto max-w-4xl px-4 py-8">
<h1 className="mb-4 text-2xl font-semibold">Payer behavior</h1>
<PayerBehaviorAnalytics />
</main>
);
}
10 changes: 10 additions & 0 deletions src/app/creator/network/page.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
import CreatorNetwork from "@/components/network/CreatorNetwork";

export default function CreatorNetworkPage() {
return (
<main className="mx-auto max-w-4xl px-4 py-8">
<h1 className="mb-4 text-2xl font-semibold">Creator network</h1>
<CreatorNetwork me={{ address: "", name: "", tags: [] }} creators={[]} />
</main>
);
}
10 changes: 10 additions & 0 deletions src/app/invoice/[id]/workspace/page.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
import InvoiceCollaborationWorkspace from "@/components/workspace/InvoiceCollaborationWorkspace";

export default function InvoiceWorkspacePage() {
return (
<main className="mx-auto max-w-4xl px-4 py-8">
<h1 className="mb-4 text-2xl font-semibold">Collaboration workspace</h1>
<InvoiceCollaborationWorkspace initial={{ participants: [], notes: [], tasks: [] }} currentUserId="" />
</main>
);
}
65 changes: 65 additions & 0 deletions src/components/analytics/PayerBehaviorAnalytics.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,65 @@
"use client";

import { useMemo, useState } from "react";
import { analyzePayers, type PayerPayment, type PayerSegment } from "@/lib/payerBehavior";

const SEGMENT_STYLES: Record<PayerSegment, string> = {
reliable: "bg-green-100 text-green-800",
slow: "bg-yellow-100 text-yellow-800",
"at-risk": "bg-red-100 text-red-800",
};

export default function PayerBehaviorAnalytics({ payments = [] }: { payments?: PayerPayment[] }) {
const [segment, setSegment] = useState<PayerSegment | "all">("all");
const payers = useMemo(() => analyzePayers(payments), [payments]);
const visible = segment === "all" ? payers : payers.filter((p) => p.segment === segment);

if (payers.length === 0) {
return <p className="text-sm text-gray-500">No payer activity yet.</p>;
}

return (
<section className="space-y-3" aria-label="Payer behavior analytics">
<label className="flex items-center gap-2 text-sm">
Segment
<select
aria-label="Segment"
className="rounded border px-2 py-1"
value={segment}
onChange={(e) => setSegment(e.target.value as PayerSegment | "all")}
>
<option value="all">All</option>
<option value="reliable">Reliable</option>
<option value="slow">Slow</option>
<option value="at-risk">At risk</option>
</select>
</label>
<ul className="grid gap-2 sm:grid-cols-2">
{visible.map((p) => (
<li key={p.payer} className="rounded border p-3">
<div className="flex items-center justify-between gap-2">
<span className="truncate font-mono text-sm" title={p.payer}>
{p.payer}
</span>
<span className={`rounded px-2 py-0.5 text-xs ${SEGMENT_STYLES[p.segment]}`}>{p.segment}</span>
</div>
<dl className="mt-2 grid grid-cols-3 gap-2 text-xs">
<div>
<dt className="text-gray-500">Paid</dt>
<dd className="tabular-nums">{p.totalPaid.toFixed(2)}</dd>
</div>
<div>
<dt className="text-gray-500">On time</dt>
<dd className="tabular-nums">{Math.round(p.onTimeRate * 100)}%</dd>
</div>
<div>
<dt className="text-gray-500">Avg delay</dt>
<dd className="tabular-nums">{p.avgDelayDays.toFixed(1)}d</dd>
</div>
</dl>
</li>
))}
</ul>
</section>
);
}
42 changes: 42 additions & 0 deletions src/components/milestones/InvoiceMilestoneCelebration.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,42 @@
"use client";

import { useState } from "react";
import { fundedPercent, milestonesFor, newestMilestone } from "@/lib/invoiceMilestones";

export default function InvoiceMilestoneCelebration({ raised, target }: { raised: number; target: number }) {
const [dismissed, setDismissed] = useState<number | null>(null);
const milestone = newestMilestone(raised, target);
const showBanner = milestone !== null && dismissed !== milestone.percent;

return (
<section className="space-y-3" aria-label="Invoice milestones">
<div
role="progressbar"
aria-valuemin={0}
aria-valuemax={100}
aria-valuenow={Math.round(fundedPercent(raised, target))}
className="h-2 w-full overflow-hidden rounded bg-gray-200"
>
<div className="h-full bg-green-500 transition-all motion-reduce:transition-none" style={{ width: `${fundedPercent(raised, target)}%` }} />
</div>
<ol className="flex flex-wrap gap-2 text-xs">
{milestonesFor(raised, target).map((m) => (
<li key={m.percent} className={`rounded px-2 py-1 ${m.reached ? "bg-green-100 text-green-800" : "bg-gray-100 text-gray-500"}`}>
{m.label}
</li>
))}
</ol>
{showBanner && milestone && (
<div role="status" className="flex items-center justify-between gap-3 rounded border border-green-300 bg-green-50 p-3 text-sm">
<span>
<span aria-hidden="true">🎉 </span>
Milestone reached: {milestone.label}!
</span>
<button type="button" className="text-xs underline" onClick={() => setDismissed(milestone.percent)}>
Dismiss
</button>
</div>
)}
</section>
);
}
77 changes: 77 additions & 0 deletions src/components/network/CreatorNetwork.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,77 @@
"use client";

import { useState } from "react";
import {
removeConnection,
requestConnection,
statusOf,
suggestCreators,
type NetworkCreator,
type NetworkState,
} from "@/lib/creatorNetwork";

export default function CreatorNetwork({
me,
creators,
initial = { connections: [], pending: [] },
}: {
me: NetworkCreator;
creators: NetworkCreator[];
initial?: NetworkState;
}) {
const [state, setState] = useState<NetworkState>(initial);
const suggestions = suggestCreators(me, creators, state);
const connected = creators.filter((c) => statusOf(state, c.address) === "connected");
const pending = creators.filter((c) => statusOf(state, c.address) === "pending");

return (
<section className="grid gap-6 md:grid-cols-2" aria-label="Creator network">
<div>
<h2 className="mb-2 font-semibold">Suggested creators</h2>
{suggestions.length === 0 && <p className="text-sm text-gray-500">No suggestions right now.</p>}
<ul className="space-y-2">
{suggestions.map(({ creator, sharedTags }) => (
<li key={creator.address} className="flex items-center justify-between gap-2 rounded border p-2">
<div className="min-w-0">
<div className="truncate text-sm font-medium">{creator.name}</div>
<div className="truncate text-xs text-gray-500">{sharedTags.join(", ")}</div>
</div>
<button
type="button"
className="rounded border px-3 py-1 text-sm"
onClick={() => setState(requestConnection(state, me.address, creator.address))}
>
Connect
</button>
</li>
))}
</ul>
</div>
<div className="space-y-4">
<div>
<h2 className="mb-2 font-semibold">Connections ({connected.length})</h2>
<ul className="space-y-2">
{connected.map((c) => (
<li key={c.address} className="flex items-center justify-between gap-2 rounded border p-2 text-sm">
<span className="truncate">{c.name}</span>
<button type="button" className="text-red-600" onClick={() => setState(removeConnection(state, c.address))}>
Remove
</button>
</li>
))}
</ul>
</div>
<div>
<h2 className="mb-2 font-semibold">Pending ({pending.length})</h2>
<ul className="space-y-2">
{pending.map((c) => (
<li key={c.address} className="rounded border p-2 text-sm">
{c.name} <span className="text-xs text-gray-500">awaiting response</span>
</li>
))}
</ul>
</div>
</div>
</section>
);
}
Loading
Loading