From fddb585a742fadd0c5b6585dc50df65145d0d143 Mon Sep 17 00:00:00 2001 From: unrealtim-tech Date: Tue, 29 Sep 2026 10:54:49 +0100 Subject: [PATCH] feat: add payer behavior analytics, invoice collaboration workspace, creator networking, and milestone celebration UI - #848: payer behavior analytics (on-time rate, avg delay, segments) - #849: invoice collaboration workspace (participants, roles, notes, checklist) - #850: creator networking (suggestions by shared tags, connect/accept/remove) - #851: invoice milestone celebration UI (25/50/75/100% funding banners) Closes #848 Closes #849 Closes #850 Closes #851 --- src/__tests__/CreatorNetwork.test.tsx | 30 +++++ .../InvoiceCollaborationWorkspace.test.tsx | 36 ++++++ .../InvoiceMilestoneCelebration.test.tsx | 24 ++++ src/__tests__/payerBehavior.test.tsx | 26 +++++ src/app/analytics/payers/page.tsx | 10 ++ src/app/creator/network/page.tsx | 10 ++ src/app/invoice/[id]/workspace/page.tsx | 10 ++ .../analytics/PayerBehaviorAnalytics.tsx | 65 +++++++++++ .../InvoiceMilestoneCelebration.tsx | 42 +++++++ src/components/network/CreatorNetwork.tsx | 77 +++++++++++++ .../InvoiceCollaborationWorkspace.tsx | 107 ++++++++++++++++++ src/lib/creatorNetwork.ts | 44 +++++++ src/lib/invoiceMilestones.ts | 26 +++++ src/lib/invoiceWorkspace.ts | 55 +++++++++ src/lib/payerBehavior.ts | 51 +++++++++ 15 files changed, 613 insertions(+) create mode 100644 src/__tests__/CreatorNetwork.test.tsx create mode 100644 src/__tests__/InvoiceCollaborationWorkspace.test.tsx create mode 100644 src/__tests__/InvoiceMilestoneCelebration.test.tsx create mode 100644 src/__tests__/payerBehavior.test.tsx create mode 100644 src/app/analytics/payers/page.tsx create mode 100644 src/app/creator/network/page.tsx create mode 100644 src/app/invoice/[id]/workspace/page.tsx create mode 100644 src/components/analytics/PayerBehaviorAnalytics.tsx create mode 100644 src/components/milestones/InvoiceMilestoneCelebration.tsx create mode 100644 src/components/network/CreatorNetwork.tsx create mode 100644 src/components/workspace/InvoiceCollaborationWorkspace.tsx create mode 100644 src/lib/creatorNetwork.ts create mode 100644 src/lib/invoiceMilestones.ts create mode 100644 src/lib/invoiceWorkspace.ts create mode 100644 src/lib/payerBehavior.ts diff --git a/src/__tests__/CreatorNetwork.test.tsx b/src/__tests__/CreatorNetwork.test.tsx new file mode 100644 index 0000000..7d904f7 --- /dev/null +++ b/src/__tests__/CreatorNetwork.test.tsx @@ -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(); + fireEvent.click(screen.getAllByText("Connect")[0]); + expect(screen.getByText("Pending (1)")).toBeInTheDocument(); + }); +}); diff --git a/src/__tests__/InvoiceCollaborationWorkspace.test.tsx b/src/__tests__/InvoiceCollaborationWorkspace.test.tsx new file mode 100644 index 0000000..fe81f52 --- /dev/null +++ b/src/__tests__/InvoiceCollaborationWorkspace.test.tsx @@ -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(); + 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(); + expect(screen.queryByLabelText("New note")).not.toBeInTheDocument(); + expect(screen.getByText("You have view-only access.")).toBeInTheDocument(); + }); +}); diff --git a/src/__tests__/InvoiceMilestoneCelebration.test.tsx b/src/__tests__/InvoiceMilestoneCelebration.test.tsx new file mode 100644 index 0000000..c0aeb42 --- /dev/null +++ b/src/__tests__/InvoiceMilestoneCelebration.test.tsx @@ -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(); + 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(); + expect(screen.queryByRole("status")).not.toBeInTheDocument(); + }); +}); diff --git a/src/__tests__/payerBehavior.test.tsx b/src/__tests__/payerBehavior.test.tsx new file mode 100644 index 0000000..9b07b76 --- /dev/null +++ b/src/__tests__/payerBehavior.test.tsx @@ -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(); + fireEvent.change(screen.getByLabelText("Segment"), { target: { value: "at-risk" } }); + expect(screen.queryByText("GA")).not.toBeInTheDocument(); + expect(screen.getByText("GB")).toBeInTheDocument(); + rerender(); + expect(screen.getByText("No payer activity yet.")).toBeInTheDocument(); + }); +}); diff --git a/src/app/analytics/payers/page.tsx b/src/app/analytics/payers/page.tsx new file mode 100644 index 0000000..060d5fb --- /dev/null +++ b/src/app/analytics/payers/page.tsx @@ -0,0 +1,10 @@ +import PayerBehaviorAnalytics from "@/components/analytics/PayerBehaviorAnalytics"; + +export default function PayerBehaviorPage() { + return ( +
+

Payer behavior

+ +
+ ); +} diff --git a/src/app/creator/network/page.tsx b/src/app/creator/network/page.tsx new file mode 100644 index 0000000..99540a0 --- /dev/null +++ b/src/app/creator/network/page.tsx @@ -0,0 +1,10 @@ +import CreatorNetwork from "@/components/network/CreatorNetwork"; + +export default function CreatorNetworkPage() { + return ( +
+

Creator network

+ +
+ ); +} diff --git a/src/app/invoice/[id]/workspace/page.tsx b/src/app/invoice/[id]/workspace/page.tsx new file mode 100644 index 0000000..6fe4aa7 --- /dev/null +++ b/src/app/invoice/[id]/workspace/page.tsx @@ -0,0 +1,10 @@ +import InvoiceCollaborationWorkspace from "@/components/workspace/InvoiceCollaborationWorkspace"; + +export default function InvoiceWorkspacePage() { + return ( +
+

Collaboration workspace

+ +
+ ); +} diff --git a/src/components/analytics/PayerBehaviorAnalytics.tsx b/src/components/analytics/PayerBehaviorAnalytics.tsx new file mode 100644 index 0000000..9bfa36a --- /dev/null +++ b/src/components/analytics/PayerBehaviorAnalytics.tsx @@ -0,0 +1,65 @@ +"use client"; + +import { useMemo, useState } from "react"; +import { analyzePayers, type PayerPayment, type PayerSegment } from "@/lib/payerBehavior"; + +const SEGMENT_STYLES: Record = { + 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("all"); + const payers = useMemo(() => analyzePayers(payments), [payments]); + const visible = segment === "all" ? payers : payers.filter((p) => p.segment === segment); + + if (payers.length === 0) { + return

No payer activity yet.

; + } + + return ( +
+ +
    + {visible.map((p) => ( +
  • +
    + + {p.payer} + + {p.segment} +
    +
    +
    +
    Paid
    +
    {p.totalPaid.toFixed(2)}
    +
    +
    +
    On time
    +
    {Math.round(p.onTimeRate * 100)}%
    +
    +
    +
    Avg delay
    +
    {p.avgDelayDays.toFixed(1)}d
    +
    +
    +
  • + ))} +
+
+ ); +} diff --git a/src/components/milestones/InvoiceMilestoneCelebration.tsx b/src/components/milestones/InvoiceMilestoneCelebration.tsx new file mode 100644 index 0000000..f925fe4 --- /dev/null +++ b/src/components/milestones/InvoiceMilestoneCelebration.tsx @@ -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(null); + const milestone = newestMilestone(raised, target); + const showBanner = milestone !== null && dismissed !== milestone.percent; + + return ( +
+
+
+
+
    + {milestonesFor(raised, target).map((m) => ( +
  1. + {m.label} +
  2. + ))} +
+ {showBanner && milestone && ( +
+ + + Milestone reached: {milestone.label}! + + +
+ )} +
+ ); +} diff --git a/src/components/network/CreatorNetwork.tsx b/src/components/network/CreatorNetwork.tsx new file mode 100644 index 0000000..393a234 --- /dev/null +++ b/src/components/network/CreatorNetwork.tsx @@ -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(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 ( +
+
+

Suggested creators

+ {suggestions.length === 0 &&

No suggestions right now.

} +
    + {suggestions.map(({ creator, sharedTags }) => ( +
  • +
    +
    {creator.name}
    +
    {sharedTags.join(", ")}
    +
    + +
  • + ))} +
+
+
+
+

Connections ({connected.length})

+
    + {connected.map((c) => ( +
  • + {c.name} + +
  • + ))} +
+
+
+

Pending ({pending.length})

+
    + {pending.map((c) => ( +
  • + {c.name} awaiting response +
  • + ))} +
+
+
+
+ ); +} diff --git a/src/components/workspace/InvoiceCollaborationWorkspace.tsx b/src/components/workspace/InvoiceCollaborationWorkspace.tsx new file mode 100644 index 0000000..41707f5 --- /dev/null +++ b/src/components/workspace/InvoiceCollaborationWorkspace.tsx @@ -0,0 +1,107 @@ +"use client"; + +import { useState } from "react"; +import { + addNote, + addTask, + canEdit, + progress, + toggleTask, + type WorkspaceState, +} from "@/lib/invoiceWorkspace"; + +export default function InvoiceCollaborationWorkspace({ + initial, + currentUserId, +}: { + initial: WorkspaceState; + currentUserId: string; +}) { + const [state, setState] = useState(initial); + const [note, setNote] = useState(""); + const [task, setTask] = useState(""); + const me = state.participants.find((p) => p.id === currentUserId); + const editable = me ? canEdit(me.role) : false; + const nameOf = (id: string) => state.participants.find((p) => p.id === id)?.name ?? "Unknown"; + + return ( +
+ + +
+
+
+

Checklist

+ {progress(state)}% done +
+
    + {state.tasks.map((t) => ( +
  • + +
  • + ))} +
+ {editable && ( +
{ + e.preventDefault(); + setState(addTask(state, { id: crypto.randomUUID(), title: task, done: false })); + setTask(""); + }} + > + setTask(e.target.value)} /> + +
+ )} +
+ +
+

Notes

+
    + {state.notes.map((n) => ( +
  • +
    {nameOf(n.authorId)}
    + {n.text} +
  • + ))} +
+ {editable ? ( +
{ + e.preventDefault(); + setState( + addNote(state, { id: crypto.randomUUID(), authorId: currentUserId, text: note, createdAt: new Date().toISOString() }), + ); + setNote(""); + }} + > + setNote(e.target.value)} /> + +
+ ) : ( +

You have view-only access.

+ )} +
+
+
+ ); +} diff --git a/src/lib/creatorNetwork.ts b/src/lib/creatorNetwork.ts new file mode 100644 index 0000000..6b9cce9 --- /dev/null +++ b/src/lib/creatorNetwork.ts @@ -0,0 +1,44 @@ +export interface NetworkCreator { + address: string; + name: string; + tags: string[]; +} + +export type ConnectionStatus = "none" | "pending" | "connected"; + +export interface NetworkState { + connections: string[]; + pending: string[]; +} + +export const statusOf = (state: NetworkState, address: string): ConnectionStatus => + state.connections.includes(address) ? "connected" : state.pending.includes(address) ? "pending" : "none"; + +export function requestConnection(state: NetworkState, self: string, address: string): NetworkState { + if (address === self || statusOf(state, address) !== "none") return state; + return { ...state, pending: [...state.pending, address] }; +} + +export function acceptConnection(state: NetworkState, address: string): NetworkState { + if (!state.pending.includes(address)) return state; + return { connections: [...state.connections, address], pending: state.pending.filter((a) => a !== address) }; +} + +export function removeConnection(state: NetworkState, address: string): NetworkState { + return { connections: state.connections.filter((a) => a !== address), pending: state.pending.filter((a) => a !== address) }; +} + +export function suggestCreators( + me: NetworkCreator, + candidates: NetworkCreator[], + state: NetworkState, + limit = 5, +): { creator: NetworkCreator; sharedTags: string[] }[] { + const mine = new Set(me.tags.map((t) => t.toLowerCase())); + return candidates + .filter((c) => c.address !== me.address && statusOf(state, c.address) === "none") + .map((creator) => ({ creator, sharedTags: creator.tags.filter((t) => mine.has(t.toLowerCase())) })) + .filter((s) => s.sharedTags.length > 0) + .sort((a, b) => b.sharedTags.length - a.sharedTags.length || a.creator.name.localeCompare(b.creator.name)) + .slice(0, limit); +} diff --git a/src/lib/invoiceMilestones.ts b/src/lib/invoiceMilestones.ts new file mode 100644 index 0000000..6e9674c --- /dev/null +++ b/src/lib/invoiceMilestones.ts @@ -0,0 +1,26 @@ +export const MILESTONE_PERCENTS = [25, 50, 75, 100] as const; + +export interface InvoiceMilestone { + percent: number; + label: string; + reached: boolean; +} + +export function fundedPercent(raised: number, target: number): number { + if (target <= 0) return 0; + return Math.min(100, Math.max(0, (raised / target) * 100)); +} + +export function milestonesFor(raised: number, target: number): InvoiceMilestone[] { + const pct = fundedPercent(raised, target); + return MILESTONE_PERCENTS.map((percent) => ({ + percent, + label: percent === 100 ? "Fully funded" : `${percent}% funded`, + reached: pct >= percent, + })); +} + +export function newestMilestone(raised: number, target: number): InvoiceMilestone | null { + const reached = milestonesFor(raised, target).filter((m) => m.reached); + return reached.length ? reached[reached.length - 1] : null; +} diff --git a/src/lib/invoiceWorkspace.ts b/src/lib/invoiceWorkspace.ts new file mode 100644 index 0000000..42eb310 --- /dev/null +++ b/src/lib/invoiceWorkspace.ts @@ -0,0 +1,55 @@ +export type WorkspaceRole = "owner" | "editor" | "viewer"; + +export interface WorkspaceParticipant { + id: string; + name: string; + role: WorkspaceRole; +} + +export interface WorkspaceNote { + id: string; + authorId: string; + text: string; + createdAt: string; +} + +export interface WorkspaceTask { + id: string; + title: string; + assigneeId?: string; + done: boolean; +} + +export interface WorkspaceState { + participants: WorkspaceParticipant[]; + notes: WorkspaceNote[]; + tasks: WorkspaceTask[]; +} + +export const canEdit = (role: WorkspaceRole) => role === "owner" || role === "editor"; + +export function addNote(state: WorkspaceState, note: WorkspaceNote): WorkspaceState { + const author = state.participants.find((p) => p.id === note.authorId); + if (!author || !canEdit(author.role) || !note.text.trim()) return state; + return { ...state, notes: [...state.notes, { ...note, text: note.text.trim() }] }; +} + +export function addTask(state: WorkspaceState, task: WorkspaceTask): WorkspaceState { + if (!task.title.trim()) return state; + return { ...state, tasks: [...state.tasks, { ...task, title: task.title.trim() }] }; +} + +export function toggleTask(state: WorkspaceState, taskId: string): WorkspaceState { + return { ...state, tasks: state.tasks.map((t) => (t.id === taskId ? { ...t, done: !t.done } : t)) }; +} + +export function setRole(state: WorkspaceState, participantId: string, role: WorkspaceRole): WorkspaceState { + const target = state.participants.find((p) => p.id === participantId); + if (!target || target.role === "owner") return state; + return { ...state, participants: state.participants.map((p) => (p.id === participantId ? { ...p, role } : p)) }; +} + +export function progress(state: WorkspaceState): number { + if (state.tasks.length === 0) return 0; + return Math.round((state.tasks.filter((t) => t.done).length / state.tasks.length) * 100); +} diff --git a/src/lib/payerBehavior.ts b/src/lib/payerBehavior.ts new file mode 100644 index 0000000..bb6d98b --- /dev/null +++ b/src/lib/payerBehavior.ts @@ -0,0 +1,51 @@ +export interface PayerPayment { + payer: string; + invoiceId: string; + amount: number; + dueAt: string; + paidAt: string | null; +} + +export type PayerSegment = "reliable" | "slow" | "at-risk"; + +export interface PayerBehavior { + payer: string; + invoices: number; + paid: number; + totalPaid: number; + onTimeRate: number; + avgDelayDays: number; + segment: PayerSegment; +} + +const DAY_MS = 86_400_000; + +export function segmentFor(onTimeRate: number, unpaidRate: number): PayerSegment { + if (unpaidRate >= 0.5 || onTimeRate < 0.4) return "at-risk"; + if (onTimeRate < 0.8) return "slow"; + return "reliable"; +} + +export function analyzePayers(payments: PayerPayment[]): PayerBehavior[] { + const groups = new Map(); + for (const p of payments) groups.set(p.payer, [...(groups.get(p.payer) ?? []), p]); + + return [...groups.entries()] + .map(([payer, list]) => { + const paidList = list.filter((p) => p.paidAt); + const delays = paidList.map((p) => (Date.parse(p.paidAt!) - Date.parse(p.dueAt)) / DAY_MS); + const onTime = delays.filter((d) => d <= 0).length; + const onTimeRate = list.length ? onTime / list.length : 0; + const unpaidRate = list.length ? (list.length - paidList.length) / list.length : 0; + return { + payer, + invoices: list.length, + paid: paidList.length, + totalPaid: paidList.reduce((sum, p) => sum + p.amount, 0), + onTimeRate, + avgDelayDays: delays.length ? Math.max(0, delays.reduce((a, b) => a + b, 0) / delays.length) : 0, + segment: segmentFor(onTimeRate, unpaidRate), + }; + }) + .sort((a, b) => b.totalPaid - a.totalPaid); +}