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
40 changes: 40 additions & 0 deletions src/__tests__/CreatorAccountabilityScore.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,40 @@
import React from "react";
import { render, screen } from "@testing-library/react";
import CreatorAccountabilityScore from "@/components/creator/CreatorAccountabilityScore";

describe("CreatorAccountabilityScore", () => {
it("renders the score, grade and breakdown", () => {
render(
<CreatorAccountabilityScore
stats={{
totalInvoices: 20,
fulfilledInvoices: 20,
cancelledInvoices: 0,
disputes: 0,
disputesLost: 0,
avgResponseHours: 2,
}}
/>,
);
expect(screen.getByLabelText("Grade A")).toBeInTheDocument();
expect(screen.getByRole("meter")).toHaveAttribute("aria-valuenow", "100");
expect(screen.getByText("50 / 50")).toBeInTheDocument();
});

it("explains when there is not enough history", () => {
render(
<CreatorAccountabilityScore
stats={{
totalInvoices: 1,
fulfilledInvoices: 1,
cancelledInvoices: 0,
disputes: 0,
disputesLost: 0,
avgResponseHours: 2,
}}
/>,
);
expect(screen.getByText(/Not enough invoice history/)).toBeInTheDocument();
expect(screen.queryByRole("meter")).toBeNull();
});
});
40 changes: 40 additions & 0 deletions src/__tests__/CreatorCertification.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,40 @@
import React from "react";
import { render, screen } from "@testing-library/react";
import CreatorCertification from "@/components/creator/CreatorCertification";

const base = {
walletVerified: false,
kycVerified: false,
accountAgeDays: 0,
completedInvoices: 0,
disputeRate: 0,
onTimeRate: 0,
};

describe("CreatorCertification", () => {
it("shows the uncertified state and bronze requirements", () => {
render(<CreatorCertification profile={base} />);
expect(screen.getByText("Not certified")).toBeInTheDocument();
expect(screen.getByText("Requirements for bronze certification")).toBeInTheDocument();
expect(screen.getByText(/Verify your wallet/)).toBeInTheDocument();
expect(screen.getByRole("progressbar")).toHaveAttribute("aria-valuenow", "33");
});

it("shows the top tier when gold is reached", () => {
render(
<CreatorCertification
profile={{
walletVerified: true,
kycVerified: true,
accountAgeDays: 400,
completedInvoices: 60,
disputeRate: 0.01,
onTimeRate: 0.95,
}}
/>,
);
expect(screen.getByText("gold")).toBeInTheDocument();
expect(screen.getByText("Top certification reached")).toBeInTheDocument();
expect(screen.getByRole("progressbar")).toHaveAttribute("aria-valuenow", "100");
});
});
26 changes: 26 additions & 0 deletions src/__tests__/PortfolioOptimization.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
import React from "react";
import { render, screen } from "@testing-library/react";
import PortfolioOptimization from "@/components/recommendations/PortfolioOptimization";

const DAY = 86_400_000;
const NOW = 100 * DAY;

describe("PortfolioOptimization", () => {
it("renders recommendations for an at-risk portfolio", () => {
render(
<PortfolioOptimization
now={NOW}
invoices={[
{ id: "a", title: "Late", amount: 100, status: "open", recipients: ["GA"], createdAt: NOW - 5 * DAY, dueAt: NOW - DAY },
]}
/>,
);
expect(screen.getByRole("list", { name: "Portfolio optimization recommendations" })).toBeInTheDocument();
expect(screen.getByText(/1 open invoice past due/)).toBeInTheDocument();
});

it("shows a healthy state when there is nothing to recommend", () => {
render(<PortfolioOptimization invoices={[]} now={NOW} />);
expect(screen.getByText(/looks healthy/)).toBeInTheDocument();
});
});
27 changes: 27 additions & 0 deletions src/__tests__/SocialProofWidget.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
import React from "react";
import { render, screen } from "@testing-library/react";
import SocialProofWidget from "@/components/pay/SocialProofWidget";

const MIN = 60_000;
const NOW = 1_000_000_000;
const ADDR = "GAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAWHF";

describe("SocialProofWidget", () => {
it("shows recent payments with masked payer addresses", () => {
render(
<SocialProofWidget
now={NOW}
payments={[{ id: "1", payer: ADDR, amount: 25, asset: "XLM", paidAt: NOW - 5 * MIN }]}
/>,
);
expect(screen.getByText("1 person paid in the last 24 hours")).toBeInTheDocument();
expect(screen.getByText("5m ago")).toBeInTheDocument();
expect(screen.getByText(/paid 25 XLM/)).toBeInTheDocument();
expect(screen.queryByText(ADDR)).toBeNull();
});

it("renders nothing when there are no payments", () => {
const { container } = render(<SocialProofWidget payments={[]} now={NOW} />);
expect(container).toBeEmptyDOMElement();
});
});
52 changes: 52 additions & 0 deletions src/components/creator/CreatorAccountabilityScore.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,52 @@
import { computeAccountabilityScore, type AccountabilityInput } from "@/lib/creatorAccountability";

const ROWS = [
{ key: "fulfillment", label: "Fulfilment", max: 50 },
{ key: "disputes", label: "Disputes", max: 30 },
{ key: "responsiveness", label: "Responsiveness", max: 20 },
] as const;

export default function CreatorAccountabilityScore({ stats }: { stats: AccountabilityInput }) {
const { score, grade, breakdown } = computeAccountabilityScore(stats);

if (score === null) {
return (
<section aria-label="Creator accountability score" className="w-full max-w-md rounded-lg border border-gray-200 p-4">
<h2 className="text-base font-semibold">Accountability score</h2>
<p className="mt-2 text-sm text-gray-500">Not enough invoice history to rate this creator yet.</p>
</section>
);
}

return (
<section aria-label="Creator accountability score" className="w-full max-w-md rounded-lg border border-gray-200 p-4">
<div className="flex items-center justify-between gap-2">
<h2 className="text-base font-semibold">Accountability score</h2>
<span className="text-2xl font-bold" aria-label={`Grade ${grade}`}>
{grade}
</span>
</div>
<div
role="meter"
aria-label="Accountability score"
aria-valuemin={0}
aria-valuemax={100}
aria-valuenow={score}
className="mt-2 h-2 w-full overflow-hidden rounded-full bg-gray-200"
>
<div className="h-full bg-indigo-600" style={{ width: `${score}%` }} />
</div>
<p className="mt-1 text-sm text-gray-600">{score} / 100</p>
<dl className="mt-3 grid grid-cols-1 gap-1 text-sm sm:grid-cols-3">
{ROWS.map((r) => (
<div key={r.key} className="flex justify-between gap-2 sm:block">
<dt className="text-gray-500">{r.label}</dt>
<dd className="font-medium">
{breakdown[r.key]} / {r.max}
</dd>
</div>
))}
</dl>
</section>
);
}
45 changes: 45 additions & 0 deletions src/components/creator/CreatorCertification.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,45 @@
import { evaluateCertification, type CertificationInput, type CertificationLevel } from "@/lib/creatorCertification";

const LEVEL_STYLES: Record<CertificationLevel, string> = {
none: "bg-gray-100 text-gray-700",
bronze: "bg-orange-100 text-orange-800",
silver: "bg-slate-200 text-slate-800",
gold: "bg-yellow-100 text-yellow-800",
};

export default function CreatorCertification({ profile }: { profile: CertificationInput }) {
const { level, next, requirements, progress } = evaluateCertification(profile);
const percent = Math.round(progress * 100);

return (
<section aria-label="Creator certification" className="w-full max-w-md rounded-lg border border-gray-200 p-4">
<div className="flex flex-wrap items-center justify-between gap-2">
<h2 className="text-base font-semibold">Certification</h2>
<span className={`rounded-full px-2.5 py-0.5 text-xs font-semibold capitalize ${LEVEL_STYLES[level]}`}>
{level === "none" ? "Not certified" : level}
</span>
</div>
<p className="mt-2 text-sm text-gray-600">
{next ? `Requirements for ${next} certification` : "Top certification reached"}
</p>
<div
role="progressbar"
aria-label="Certification progress"
aria-valuemin={0}
aria-valuemax={100}
aria-valuenow={percent}
className="mt-2 h-2 w-full overflow-hidden rounded-full bg-gray-200"
>
<div className="h-full bg-indigo-600" style={{ width: `${percent}%` }} />
</div>
<ul className="mt-3 space-y-1 text-sm">
{requirements.map((r) => (
<li key={r.id} className={r.met ? "text-green-700" : "text-gray-700"}>
<span aria-hidden="true">{r.met ? "✓" : "○"}</span> {r.label}
<span className="sr-only">{r.met ? " (met)" : " (not met)"}</span>
</li>
))}
</ul>
</section>
);
}
40 changes: 40 additions & 0 deletions src/components/pay/SocialProofWidget.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,40 @@
"use client";

import { useMemo } from "react";
import { buildSocialProof, summarizeSocialProof, type RecentPayment } from "@/lib/socialProof";

interface Props {
payments: RecentPayment[];
limit?: number;
/** Overridable for deterministic rendering. */
now?: number;
}

export default function SocialProofWidget({ payments, limit = 5, now }: Props) {
const { items, summary } = useMemo(() => {
const at = now ?? Date.now();
return { items: buildSocialProof(payments, at, limit), summary: summarizeSocialProof(payments, at) };
}, [payments, limit, now]);

if (items.length === 0) return null;

return (
<section aria-label="Recent payments" className="w-full max-w-sm rounded-lg border border-gray-200 p-3">
{summary.count > 0 && (
<p className="text-sm font-medium">
{summary.uniquePayers} {summary.uniquePayers === 1 ? "person" : "people"} paid in the last 24 hours
</p>
)}
<ul aria-live="polite" className="mt-2 space-y-1 text-sm">
{items.map((i) => (
<li key={i.id} className="flex flex-wrap items-center justify-between gap-x-2">
<span className="truncate text-gray-700">
<span className="font-mono">{i.payerLabel}</span> paid {i.amountLabel}
</span>
<span className="text-xs text-gray-500">{i.timeAgo}</span>
</li>
))}
</ul>
</section>
);
}
35 changes: 35 additions & 0 deletions src/components/recommendations/PortfolioOptimization.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
"use client";

import { useMemo } from "react";
import { optimizePortfolio, type PortfolioInvoice, type TipSeverity } from "@/lib/portfolioOptimization";

interface Props {
invoices: PortfolioInvoice[];
/** Overridable for deterministic rendering. */
now?: number;
}

const SEVERITY_STYLES: Record<TipSeverity, string> = {
critical: "border-red-200 bg-red-50 text-red-900",
warning: "border-yellow-200 bg-yellow-50 text-yellow-900",
info: "border-blue-200 bg-blue-50 text-blue-900",
};

export default function PortfolioOptimization({ invoices, now }: Props) {
const tips = useMemo(() => optimizePortfolio(invoices, now), [invoices, now]);

if (tips.length === 0) {
return <p className="text-sm text-gray-500">Your invoice portfolio looks healthy — no recommendations right now.</p>;
}

return (
<ul aria-label="Portfolio optimization recommendations" className="grid grid-cols-1 gap-2">
{tips.map((t) => (
<li key={t.id} className={`rounded-lg border p-3 text-sm ${SEVERITY_STYLES[t.severity]}`}>
<span className="mr-2 text-xs font-semibold uppercase">{t.severity}</span>
{t.message}
</li>
))}
</ul>
);
}
68 changes: 68 additions & 0 deletions src/lib/creatorAccountability.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,68 @@
import { describe, it, expect } from "vitest";
import { computeAccountabilityScore, gradeForScore, type AccountabilityInput } from "./creatorAccountability";

const perfect: AccountabilityInput = {
totalInvoices: 20,
fulfilledInvoices: 20,
cancelledInvoices: 0,
disputes: 0,
disputesLost: 0,
avgResponseHours: 2,
};

describe("computeAccountabilityScore", () => {
it("is unrated below the minimum history", () => {
const r = computeAccountabilityScore({ ...perfect, totalInvoices: 2, fulfilledInvoices: 2 });
expect(r.score).toBeNull();
expect(r.grade).toBe("unrated");
});

it("gives a perfect creator 100 / A", () => {
const r = computeAccountabilityScore(perfect);
expect(r.score).toBe(100);
expect(r.grade).toBe("A");
expect(r.breakdown).toEqual({ fulfillment: 50, disputes: 30, responsiveness: 20 });
});

it("penalises cancellations against fulfilment", () => {
// (10 - 4 * 0.5) / 20 = 0.4 -> 20 fulfilment points
const r = computeAccountabilityScore({ ...perfect, fulfilledInvoices: 10, cancelledInvoices: 4 });
expect(r.breakdown.fulfillment).toBe(20);
});

it("counts lost disputes double", () => {
// (2 + 2) / 20 = 0.2 -> 24 dispute points
const r = computeAccountabilityScore({ ...perfect, disputes: 2, disputesLost: 2 });
expect(r.breakdown.disputes).toBe(24);
});

it("scores responsiveness linearly between 4h and 72h", () => {
expect(computeAccountabilityScore({ ...perfect, avgResponseHours: 4 }).breakdown.responsiveness).toBe(20);
expect(computeAccountabilityScore({ ...perfect, avgResponseHours: 38 }).breakdown.responsiveness).toBe(10);
expect(computeAccountabilityScore({ ...perfect, avgResponseHours: 500 }).breakdown.responsiveness).toBe(0);
});

it("never leaves the 0-100 range for out-of-range input", () => {
const r = computeAccountabilityScore({
totalInvoices: 5,
fulfilledInvoices: 50,
cancelledInvoices: 0,
disputes: 99,
disputesLost: 99,
avgResponseHours: -10,
});
expect(r.score).toBeGreaterThanOrEqual(0);
expect(r.score).toBeLessThanOrEqual(100);
});
});

describe("gradeForScore", () => {
it("maps score boundaries to grades", () => {
expect(gradeForScore(90)).toBe("A");
expect(gradeForScore(89)).toBe("B");
expect(gradeForScore(75)).toBe("B");
expect(gradeForScore(60)).toBe("C");
expect(gradeForScore(40)).toBe("D");
expect(gradeForScore(39)).toBe("F");
});
});
Loading
Loading