= {
+ 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 (
+
+
+
Certification
+
+ {level === "none" ? "Not certified" : level}
+
+
+
+ {next ? `Requirements for ${next} certification` : "Top certification reached"}
+
+
+
+ {requirements.map((r) => (
+ -
+ {r.met ? "✓" : "○"} {r.label}
+ {r.met ? " (met)" : " (not met)"}
+
+ ))}
+
+
+ );
+}
diff --git a/src/components/pay/SocialProofWidget.tsx b/src/components/pay/SocialProofWidget.tsx
new file mode 100644
index 0000000..18ffa1c
--- /dev/null
+++ b/src/components/pay/SocialProofWidget.tsx
@@ -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 (
+
+ {summary.count > 0 && (
+
+ {summary.uniquePayers} {summary.uniquePayers === 1 ? "person" : "people"} paid in the last 24 hours
+
+ )}
+
+ {items.map((i) => (
+ -
+
+ {i.payerLabel} paid {i.amountLabel}
+
+ {i.timeAgo}
+
+ ))}
+
+
+ );
+}
diff --git a/src/components/recommendations/PortfolioOptimization.tsx b/src/components/recommendations/PortfolioOptimization.tsx
new file mode 100644
index 0000000..14eca63
--- /dev/null
+++ b/src/components/recommendations/PortfolioOptimization.tsx
@@ -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 = {
+ 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 Your invoice portfolio looks healthy — no recommendations right now.
;
+ }
+
+ return (
+
+ {tips.map((t) => (
+ -
+ {t.severity}
+ {t.message}
+
+ ))}
+
+ );
+}
diff --git a/src/lib/creatorAccountability.test.ts b/src/lib/creatorAccountability.test.ts
new file mode 100644
index 0000000..a125fe6
--- /dev/null
+++ b/src/lib/creatorAccountability.test.ts
@@ -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");
+ });
+});
diff --git a/src/lib/creatorAccountability.ts b/src/lib/creatorAccountability.ts
new file mode 100644
index 0000000..a414a39
--- /dev/null
+++ b/src/lib/creatorAccountability.ts
@@ -0,0 +1,59 @@
+export interface AccountabilityInput {
+ totalInvoices: number;
+ fulfilledInvoices: number;
+ cancelledInvoices: number;
+ disputes: number;
+ disputesLost: number;
+ /** Average time the creator takes to respond to payer messages, in hours. */
+ avgResponseHours: number;
+}
+
+export type AccountabilityGrade = "A" | "B" | "C" | "D" | "F" | "unrated";
+
+export interface AccountabilityResult {
+ /** 0-100, or null when there is not enough history to rate the creator. */
+ score: number | null;
+ grade: AccountabilityGrade;
+ breakdown: { fulfillment: number; disputes: number; responsiveness: number };
+}
+
+/** Minimum invoices before a creator is rated. */
+export const MIN_INVOICES_FOR_SCORE = 3;
+
+const FAST_RESPONSE_HOURS = 4;
+const SLOW_RESPONSE_HOURS = 72;
+
+const clamp01 = (n: number) => Math.min(1, Math.max(0, Number.isFinite(n) ? n : 0));
+
+export function gradeForScore(score: number): Exclude {
+ if (score >= 90) return "A";
+ if (score >= 75) return "B";
+ if (score >= 60) return "C";
+ if (score >= 40) return "D";
+ return "F";
+}
+
+/**
+ * Weights: fulfilment 50, disputes 30 (a lost dispute counts double), responsiveness 20.
+ * Cancelled invoices count against fulfilment.
+ */
+export function computeAccountabilityScore(input: AccountabilityInput): AccountabilityResult {
+ const total = input.totalInvoices;
+ if (total < MIN_INVOICES_FOR_SCORE) {
+ return { score: null, grade: "unrated", breakdown: { fulfillment: 0, disputes: 0, responsiveness: 0 } };
+ }
+
+ const fulfilledShare = clamp01((input.fulfilledInvoices - input.cancelledInvoices * 0.5) / total);
+ const disputePenalty = clamp01((input.disputes + input.disputesLost) / total);
+ const responseShare = clamp01(
+ (SLOW_RESPONSE_HOURS - input.avgResponseHours) / (SLOW_RESPONSE_HOURS - FAST_RESPONSE_HOURS),
+ );
+
+ const breakdown = {
+ fulfillment: Math.round(fulfilledShare * 50),
+ disputes: Math.round((1 - disputePenalty) * 30),
+ responsiveness: Math.round(responseShare * 20),
+ };
+ const score = breakdown.fulfillment + breakdown.disputes + breakdown.responsiveness;
+ return { score, grade: gradeForScore(score), breakdown };
+}
diff --git a/src/lib/creatorCertification.test.ts b/src/lib/creatorCertification.test.ts
new file mode 100644
index 0000000..ebfd6d0
--- /dev/null
+++ b/src/lib/creatorCertification.test.ts
@@ -0,0 +1,74 @@
+import { describe, it, expect } from "vitest";
+import { evaluateCertification, type CertificationInput } from "./creatorCertification";
+
+const base: CertificationInput = {
+ walletVerified: false,
+ kycVerified: false,
+ accountAgeDays: 0,
+ completedInvoices: 0,
+ disputeRate: 0,
+ onTimeRate: 0,
+};
+
+describe("evaluateCertification", () => {
+ it("is uncertified for a new creator and targets bronze", () => {
+ const r = evaluateCertification(base);
+ expect(r.level).toBe("none");
+ expect(r.next).toBe("bronze");
+ expect(r.progress).toBeCloseTo(1 / 3);
+ });
+
+ it("awards bronze and targets silver", () => {
+ const r = evaluateCertification({ ...base, walletVerified: true, completedInvoices: 3 });
+ expect(r.level).toBe("bronze");
+ expect(r.next).toBe("silver");
+ expect(r.requirements.map((q) => q.id)).toEqual(["kyc", "invoices", "age", "disputes"]);
+ expect(r.progress).toBeCloseTo(1 / 4);
+ });
+
+ it("awards silver and targets gold", () => {
+ const r = evaluateCertification({
+ ...base,
+ walletVerified: true,
+ kycVerified: true,
+ completedInvoices: 20,
+ accountAgeDays: 100,
+ });
+ expect(r.level).toBe("silver");
+ expect(r.next).toBe("gold");
+ });
+
+ it("awards gold with no next tier", () => {
+ const r = evaluateCertification({
+ walletVerified: true,
+ kycVerified: true,
+ accountAgeDays: 400,
+ completedInvoices: 60,
+ disputeRate: 0.01,
+ onTimeRate: 0.95,
+ });
+ expect(r.level).toBe("gold");
+ expect(r.next).toBeNull();
+ expect(r.progress).toBe(1);
+ expect(r.requirements.every((q) => q.met)).toBe(true);
+ });
+
+ it("does not skip a tier whose requirements are unmet", () => {
+ // Meets every gold requirement but never verified a wallet.
+ const r = evaluateCertification({
+ walletVerified: false,
+ kycVerified: true,
+ accountAgeDays: 400,
+ completedInvoices: 60,
+ disputeRate: 0.01,
+ onTimeRate: 0.95,
+ });
+ expect(r.level).toBe("none");
+ expect(r.next).toBe("bronze");
+ });
+
+ it("blocks a tier when the dispute rate is too high", () => {
+ const r = evaluateCertification({ ...base, walletVerified: true, completedInvoices: 10, disputeRate: 0.25 });
+ expect(r.level).toBe("none");
+ });
+});
diff --git a/src/lib/creatorCertification.ts b/src/lib/creatorCertification.ts
new file mode 100644
index 0000000..8e4c9d7
--- /dev/null
+++ b/src/lib/creatorCertification.ts
@@ -0,0 +1,70 @@
+export interface CertificationInput {
+ walletVerified: boolean;
+ kycVerified: boolean;
+ accountAgeDays: number;
+ completedInvoices: number;
+ disputeRate: number; // 0-1
+ onTimeRate: number; // 0-1 share of invoices fulfilled on time
+}
+
+export type CertificationTier = "bronze" | "silver" | "gold";
+export type CertificationLevel = "none" | CertificationTier;
+
+export interface CertificationRequirement {
+ id: string;
+ label: string;
+ met: boolean;
+}
+
+export interface CertificationResult {
+ level: CertificationLevel;
+ next: CertificationTier | null;
+ /** Requirements for the next tier (or for the top tier once it is reached). */
+ requirements: CertificationRequirement[];
+ /** Share of `requirements` that are met, 0-1. */
+ progress: number;
+}
+
+const TIERS: { tier: CertificationTier; requirements: (i: CertificationInput) => CertificationRequirement[] }[] = [
+ {
+ tier: "bronze",
+ requirements: (i) => [
+ { id: "wallet", label: "Verify your wallet", met: i.walletVerified },
+ { id: "invoices", label: "Complete 3 invoices", met: i.completedInvoices >= 3 },
+ { id: "disputes", label: "Keep your dispute rate under 20%", met: i.disputeRate < 0.2 },
+ ],
+ },
+ {
+ tier: "silver",
+ requirements: (i) => [
+ { id: "kyc", label: "Verify your identity", met: i.kycVerified },
+ { id: "invoices", label: "Complete 15 invoices", met: i.completedInvoices >= 15 },
+ { id: "age", label: "Have an account at least 90 days old", met: i.accountAgeDays >= 90 },
+ { id: "disputes", label: "Keep your dispute rate under 10%", met: i.disputeRate < 0.1 },
+ ],
+ },
+ {
+ tier: "gold",
+ requirements: (i) => [
+ { id: "invoices", label: "Complete 50 invoices", met: i.completedInvoices >= 50 },
+ { id: "age", label: "Have an account at least 1 year old", met: i.accountAgeDays >= 365 },
+ { id: "disputes", label: "Keep your dispute rate under 5%", met: i.disputeRate < 0.05 },
+ { id: "on-time", label: "Fulfil at least 90% of invoices on time", met: i.onTimeRate >= 0.9 },
+ ],
+ },
+];
+
+/** A tier is only awarded once every lower tier's requirements are also met. */
+export function evaluateCertification(input: CertificationInput): CertificationResult {
+ let level: CertificationLevel = "none";
+ for (const { tier, requirements } of TIERS) {
+ const reqs = requirements(input);
+ if (reqs.every((r) => r.met)) {
+ level = tier;
+ continue;
+ }
+ return { level, next: tier, requirements: reqs, progress: reqs.filter((r) => r.met).length / reqs.length };
+ }
+ const top = TIERS[TIERS.length - 1].requirements(input);
+ return { level, next: null, requirements: top, progress: 1 };
+}
diff --git a/src/lib/portfolioOptimization.test.ts b/src/lib/portfolioOptimization.test.ts
new file mode 100644
index 0000000..8df0db8
--- /dev/null
+++ b/src/lib/portfolioOptimization.test.ts
@@ -0,0 +1,93 @@
+import { describe, it, expect } from "vitest";
+import { optimizePortfolio, type PortfolioInvoice } from "./portfolioOptimization";
+
+const DAY = 86_400_000;
+const NOW = 100 * DAY;
+
+const inv = (over: Partial & { id: string }): PortfolioInvoice => ({
+ title: over.id,
+ amount: 100,
+ status: "open",
+ recipients: ["GA"],
+ createdAt: NOW - DAY,
+ ...over,
+});
+
+const ids = (tips: ReturnType) => tips.map((t) => t.id);
+
+describe("optimizePortfolio", () => {
+ it("returns nothing for an empty portfolio", () => {
+ expect(optimizePortfolio([], NOW)).toEqual([]);
+ });
+
+ it("flags overdue and soon-due open invoices, most urgent first", () => {
+ const tips = optimizePortfolio(
+ [
+ inv({ id: "a", recipients: ["GA"], dueAt: NOW - DAY }),
+ inv({ id: "b", recipients: ["GB"], dueAt: NOW + 2 * DAY }),
+ inv({ id: "c", recipients: ["GC"], dueAt: NOW + 10 * DAY }),
+ ],
+ NOW,
+ );
+ expect(ids(tips)).toEqual(["overdue", "expiring"]);
+ expect(tips[0].invoiceIds).toEqual(["a"]);
+ expect(tips[1].invoiceIds).toEqual(["b"]);
+ });
+
+ it("ignores due dates on invoices that are not open", () => {
+ const tips = optimizePortfolio([inv({ id: "a", status: "paid", dueAt: NOW - DAY })], NOW);
+ expect(tips).toEqual([]);
+ });
+
+ it("flags stale open invoices that have no deadline", () => {
+ const tips = optimizePortfolio([inv({ id: "old", createdAt: NOW - 45 * DAY }), inv({ id: "new" })], NOW);
+ expect(ids(tips)).toEqual(["stale"]);
+ expect(tips[0].invoiceIds).toEqual(["old"]);
+ });
+
+ it("flags recipient concentration above 50% of active value", () => {
+ const tips = optimizePortfolio(
+ [
+ inv({ id: "a", recipients: ["GA"] }),
+ inv({ id: "b", recipients: ["GA"] }),
+ inv({ id: "c", recipients: ["GB"], amount: 50 }),
+ ],
+ NOW,
+ );
+ expect(ids(tips)).toEqual(["concentration"]);
+ expect(tips[0].message).toContain("80%");
+ expect(tips[0].invoiceIds).toEqual(["a", "b"]);
+ });
+
+ it("does not flag concentration for balanced recipients or tiny portfolios", () => {
+ expect(
+ ids(optimizePortfolio([inv({ id: "a", recipients: ["GA"] }), inv({ id: "b", recipients: ["GB"] }), inv({ id: "c", recipients: ["GC"] })], NOW)),
+ ).toEqual([]);
+ expect(ids(optimizePortfolio([inv({ id: "a" }), inv({ id: "b" })], NOW))).toEqual([]);
+ });
+
+ it("suggests batching three or more small open invoices", () => {
+ const tips = optimizePortfolio(
+ [
+ inv({ id: "a", amount: 2, recipients: ["GA"] }),
+ inv({ id: "b", amount: 3, recipients: ["GB"] }),
+ inv({ id: "c", amount: 4, recipients: ["GC"] }),
+ ],
+ NOW,
+ );
+ expect(ids(tips)).toEqual(["small-invoices"]);
+ });
+
+ it("flags a high cancellation rate", () => {
+ const list = [
+ inv({ id: "a", status: "cancelled" }),
+ inv({ id: "b", status: "cancelled" }),
+ inv({ id: "c", status: "paid" }),
+ inv({ id: "d", status: "paid" }),
+ inv({ id: "e", status: "paid" }),
+ ];
+ const tips = optimizePortfolio(list, NOW);
+ expect(ids(tips)).toEqual(["cancellations"]);
+ expect(tips[0].message).toContain("40%");
+ });
+});
diff --git a/src/lib/portfolioOptimization.ts b/src/lib/portfolioOptimization.ts
new file mode 100644
index 0000000..602ab3e
--- /dev/null
+++ b/src/lib/portfolioOptimization.ts
@@ -0,0 +1,124 @@
+export type PortfolioInvoiceStatus = "open" | "funded" | "paid" | "expired" | "cancelled";
+
+export interface PortfolioInvoice {
+ id: string;
+ title: string;
+ amount: number;
+ status: PortfolioInvoiceStatus;
+ recipients: string[];
+ createdAt: number; // ms since epoch
+ dueAt?: number; // ms since epoch
+}
+
+export type TipKind = "overdue" | "expiring" | "stale" | "concentration" | "small-invoices" | "cancellations";
+export type TipSeverity = "critical" | "warning" | "info";
+
+export interface OptimizationTip {
+ id: TipKind;
+ severity: TipSeverity;
+ message: string;
+ invoiceIds: string[];
+}
+
+const DAY_MS = 86_400_000;
+const EXPIRING_WITHIN_DAYS = 3;
+const STALE_AFTER_DAYS = 30;
+const CONCENTRATION_SHARE = 0.5;
+const CONCENTRATION_MIN_INVOICES = 3;
+const SMALL_AMOUNT = 10;
+const SMALL_MIN_COUNT = 3;
+const CANCEL_RATE = 0.3;
+const CANCEL_MIN_INVOICES = 5;
+
+const SEVERITY_ORDER: Record = { critical: 0, warning: 1, info: 2 };
+
+const plural = (n: number, word: string) => `${n} ${word}${n === 1 ? "" : "s"}`;
+
+/** Suggests ways to tidy up and de-risk an invoice portfolio, most urgent first. */
+export function optimizePortfolio(invoices: PortfolioInvoice[], now: number = Date.now()): OptimizationTip[] {
+ const tips: OptimizationTip[] = [];
+ const open = invoices.filter((i) => i.status === "open");
+
+ const overdue = open.filter((i) => i.dueAt !== undefined && i.dueAt < now);
+ if (overdue.length) {
+ tips.push({
+ id: "overdue",
+ severity: "critical",
+ message: `${plural(overdue.length, "open invoice")} past due — extend the deadline or cancel.`,
+ invoiceIds: overdue.map((i) => i.id),
+ });
+ }
+
+ const expiring = open.filter(
+ (i) => i.dueAt !== undefined && i.dueAt >= now && i.dueAt - now <= EXPIRING_WITHIN_DAYS * DAY_MS,
+ );
+ if (expiring.length) {
+ tips.push({
+ id: "expiring",
+ severity: "warning",
+ message: `${plural(expiring.length, "invoice")} due within ${EXPIRING_WITHIN_DAYS} days — send a reminder.`,
+ invoiceIds: expiring.map((i) => i.id),
+ });
+ }
+
+ const stale = open.filter((i) => i.dueAt === undefined && now - i.createdAt > STALE_AFTER_DAYS * DAY_MS);
+ if (stale.length) {
+ tips.push({
+ id: "stale",
+ severity: "warning",
+ message: `${plural(stale.length, "open invoice")} older than ${STALE_AFTER_DAYS} days with no deadline — set one or close it.`,
+ invoiceIds: stale.map((i) => i.id),
+ });
+ }
+
+ const active = invoices.filter((i) => i.status === "open" || i.status === "funded");
+ if (active.length >= CONCENTRATION_MIN_INVOICES) {
+ const totals = new Map();
+ let grand = 0;
+ for (const inv of active) {
+ if (!inv.recipients.length) continue;
+ const share = inv.amount / inv.recipients.length;
+ grand += inv.amount;
+ for (const r of inv.recipients) totals.set(r, (totals.get(r) ?? 0) + share);
+ }
+ let topRecipient: string | undefined;
+ let topTotal = 0;
+ totals.forEach((total, r) => {
+ if (total > topTotal) {
+ topRecipient = r;
+ topTotal = total;
+ }
+ });
+ if (topRecipient !== undefined && grand > 0 && topTotal / grand > CONCENTRATION_SHARE) {
+ const pct = Math.round((topTotal / grand) * 100);
+ tips.push({
+ id: "concentration",
+ severity: "info",
+ message: `${pct}% of active value goes to one recipient (${topRecipient}) — consider diversifying.`,
+ invoiceIds: active.filter((i) => i.recipients.includes(topRecipient as string)).map((i) => i.id),
+ });
+ }
+ }
+
+ const small = open.filter((i) => i.amount < SMALL_AMOUNT);
+ if (small.length >= SMALL_MIN_COUNT) {
+ tips.push({
+ id: "small-invoices",
+ severity: "info",
+ message: `${plural(small.length, "open invoice")} under ${SMALL_AMOUNT} — batch them to save on fees.`,
+ invoiceIds: small.map((i) => i.id),
+ });
+ }
+
+ const cancelled = invoices.filter((i) => i.status === "cancelled");
+ if (invoices.length >= CANCEL_MIN_INVOICES && cancelled.length / invoices.length > CANCEL_RATE) {
+ tips.push({
+ id: "cancellations",
+ severity: "warning",
+ message: `${Math.round((cancelled.length / invoices.length) * 100)}% of your invoices were cancelled — review your templates and deadlines.`,
+ invoiceIds: cancelled.map((i) => i.id),
+ });
+ }
+
+ return tips.sort((a, b) => SEVERITY_ORDER[a.severity] - SEVERITY_ORDER[b.severity]);
+}
diff --git a/src/lib/socialProof.test.ts b/src/lib/socialProof.test.ts
new file mode 100644
index 0000000..4b05360
--- /dev/null
+++ b/src/lib/socialProof.test.ts
@@ -0,0 +1,74 @@
+import { describe, it, expect } from "vitest";
+import { buildSocialProof, formatTimeAgo, maskAddress, summarizeSocialProof, type RecentPayment } from "./socialProof";
+
+const MIN = 60_000;
+const HOUR = 60 * MIN;
+const DAY = 24 * HOUR;
+const NOW = 10 * DAY;
+const ADDR_A = "GAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAWHF";
+const ADDR_B = "GBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBZZZZ";
+
+const pay = (over: Partial & { id: string }): RecentPayment => ({
+ payer: ADDR_A,
+ amount: 10,
+ asset: "XLM",
+ paidAt: NOW - MIN,
+ ...over,
+});
+
+describe("maskAddress", () => {
+ it("shortens long addresses and keeps short ones", () => {
+ expect(maskAddress(ADDR_A)).toBe("GAAA…AWHF");
+ expect(maskAddress(ADDR_A)).not.toContain(ADDR_A);
+ expect(maskAddress("GABC")).toBe("GABC");
+ });
+});
+
+describe("formatTimeAgo", () => {
+ it("formats each range", () => {
+ expect(formatTimeAgo(NOW - 30_000, NOW)).toBe("just now");
+ expect(formatTimeAgo(NOW - 5 * MIN, NOW)).toBe("5m ago");
+ expect(formatTimeAgo(NOW - 3 * HOUR, NOW)).toBe("3h ago");
+ expect(formatTimeAgo(NOW - 2 * DAY, NOW)).toBe("2d ago");
+ });
+
+ it("treats future timestamps as just now", () => {
+ expect(formatTimeAgo(NOW + HOUR, NOW)).toBe("just now");
+ });
+});
+
+describe("buildSocialProof", () => {
+ it("orders newest first, limits results and masks payers", () => {
+ const items = buildSocialProof(
+ [pay({ id: "old", paidAt: NOW - HOUR }), pay({ id: "new", paidAt: NOW - MIN, payer: ADDR_B, amount: 2.5 }), pay({ id: "mid", paidAt: NOW - 10 * MIN })],
+ NOW,
+ 2,
+ );
+ expect(items.map((i) => i.id)).toEqual(["new", "mid"]);
+ expect(items[0]).toEqual({
+ id: "new",
+ payerLabel: maskAddress(ADDR_B),
+ amountLabel: "2.5 XLM",
+ timeAgo: "1m ago",
+ });
+ });
+
+ it("drops future-dated and non-positive payments", () => {
+ const items = buildSocialProof([pay({ id: "future", paidAt: NOW + HOUR }), pay({ id: "zero", amount: 0 }), pay({ id: "ok" })], NOW);
+ expect(items.map((i) => i.id)).toEqual(["ok"]);
+ });
+
+ it("returns an empty list for no payments", () => {
+ expect(buildSocialProof([], NOW)).toEqual([]);
+ });
+});
+
+describe("summarizeSocialProof", () => {
+ it("counts payments and unique payers inside the window", () => {
+ const s = summarizeSocialProof(
+ [pay({ id: "1" }), pay({ id: "2" }), pay({ id: "3", payer: ADDR_B }), pay({ id: "4", paidAt: NOW - 2 * DAY })],
+ NOW,
+ );
+ expect(s).toEqual({ count: 3, uniquePayers: 2 });
+ });
+});
diff --git a/src/lib/socialProof.ts b/src/lib/socialProof.ts
new file mode 100644
index 0000000..3c30365
--- /dev/null
+++ b/src/lib/socialProof.ts
@@ -0,0 +1,61 @@
+export interface RecentPayment {
+ id: string;
+ payer: string; // Stellar address
+ amount: number;
+ asset: string;
+ paidAt: number; // ms since epoch
+}
+
+export interface SocialProofItem {
+ id: string;
+ payerLabel: string;
+ amountLabel: string;
+ timeAgo: string;
+}
+
+export interface SocialProofSummary {
+ count: number;
+ uniquePayers: number;
+}
+
+const MINUTE_MS = 60_000;
+const HOUR_MS = 3_600_000;
+const DAY_MS = 86_400_000;
+
+/** Shortens an address for display (never shows a full wallet address). */
+export function maskAddress(address: string): string {
+ if (address.length <= 8) return address;
+ return `${address.slice(0, 4)}…${address.slice(-4)}`;
+}
+
+export function formatTimeAgo(timestamp: number, now: number = Date.now()): string {
+ const diff = Math.max(0, now - timestamp);
+ if (diff < MINUTE_MS) return "just now";
+ if (diff < HOUR_MS) return `${Math.floor(diff / MINUTE_MS)}m ago`;
+ if (diff < DAY_MS) return `${Math.floor(diff / HOUR_MS)}h ago`;
+ return `${Math.floor(diff / DAY_MS)}d ago`;
+}
+
+/** Most recent payments first, with masked payers, ready to display. */
+export function buildSocialProof(payments: RecentPayment[], now: number = Date.now(), limit = 5): SocialProofItem[] {
+ return [...payments]
+ .filter((p) => p.paidAt <= now && p.amount > 0)
+ .sort((a, b) => b.paidAt - a.paidAt)
+ .slice(0, limit)
+ .map((p) => ({
+ id: p.id,
+ payerLabel: maskAddress(p.payer),
+ amountLabel: `${p.amount} ${p.asset}`,
+ timeAgo: formatTimeAgo(p.paidAt, now),
+ }));
+}
+
+/** Counts payments (and distinct payers) within the trailing window. */
+export function summarizeSocialProof(
+ payments: RecentPayment[],
+ now: number = Date.now(),
+ windowMs: number = DAY_MS,
+): SocialProofSummary {
+ const recent = payments.filter((p) => p.paidAt <= now && now - p.paidAt <= windowMs && p.amount > 0);
+ return { count: recent.length, uniquePayers: new Set(recent.map((p) => p.payer)).size };
+}