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
50 changes: 50 additions & 0 deletions src/__tests__/InvoiceEducationalResources.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,50 @@
import { render, screen, fireEvent } from "@testing-library/react";
import InvoiceEducationalResources from "@/components/InvoiceEducationalResources";
import { INVOICE_RESOURCES, filterResources } from "@/lib/invoiceResources";

describe("filterResources", () => {
it("returns everything with no filter", () => {
expect(filterResources(INVOICE_RESOURCES)).toHaveLength(INVOICE_RESOURCES.length);
});

it("filters by category", () => {
const result = filterResources(INVOICE_RESOURCES, { category: "security" });
expect(result.length).toBeGreaterThan(0);
expect(result.every((r) => r.category === "security")).toBe(true);
});

it("matches the query case-insensitively across title, summary and body", () => {
expect(filterResources(INVOICE_RESOURCES, { query: "PHISHING" }).map((r) => r.id)).toEqual([
"spotting-phishing-links",
]);
expect(filterResources(INVOICE_RESOURCES, { query: "nothing-matches-this" })).toEqual([]);
});

it("combines query and category", () => {
expect(filterResources(INVOICE_RESOURCES, { query: "phishing", category: "tips" })).toEqual([]);
});
});

describe("InvoiceEducationalResources", () => {
it("lists every resource by default", () => {
render(<InvoiceEducationalResources />);
expect(screen.getByText("What is a split invoice?")).toBeInTheDocument();
expect(screen.getAllByRole("listitem")).toHaveLength(INVOICE_RESOURCES.length);
});

it("narrows the list by search and shows an empty state", () => {
render(<InvoiceEducationalResources />);
fireEvent.change(screen.getByLabelText("Search resources"), { target: { value: "deadline" } });
expect(screen.getByText("Choosing a sensible deadline")).toBeInTheDocument();
expect(screen.queryByText("What is a split invoice?")).not.toBeInTheDocument();
fireEvent.change(screen.getByLabelText("Search resources"), { target: { value: "zzzz" } });
expect(screen.getByRole("status")).toHaveTextContent("No resources match your search.");
});

it("filters by category", () => {
render(<InvoiceEducationalResources />);
fireEvent.change(screen.getByLabelText("Category"), { target: { value: "payments" } });
expect(screen.getByText("Paying an invoice with your wallet")).toBeInTheDocument();
expect(screen.queryByText("Understanding invoice statuses")).not.toBeInTheDocument();
});
});
89 changes: 89 additions & 0 deletions src/__tests__/brandTools.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,89 @@
import { render, screen, fireEvent } from "@testing-library/react";
import BrandCustomizationTools from "@/components/settings/BrandCustomizationTools";
import { meetsWcagAaAgainstWhite } from "@/lib/contrast";
import {
BRAND_PRESETS,
normalizeHex,
readableTextColor,
suggestAccessibleAccent,
} from "@/lib/brandTools";

describe("BRAND_PRESETS", () => {
it("only offers colors that pass WCAG AA against white", () => {
for (const p of BRAND_PRESETS) {
expect(meetsWcagAaAgainstWhite(p.accentColor)).toBe(true);
}
});
});

describe("normalizeHex", () => {
it("expands short hex, lowercases and adds the hash", () => {
expect(normalizeHex("#ABC")).toBe("#aabbcc");
expect(normalizeHex("4F46E5")).toBe("#4f46e5");
});

it("returns null for invalid colors", () => {
expect(normalizeHex("nope")).toBeNull();
expect(normalizeHex("#12345")).toBeNull();
});
});

describe("suggestAccessibleAccent", () => {
it("leaves an already accessible color unchanged", () => {
expect(suggestAccessibleAccent("#4F46E5")).toBe("#4f46e5");
});

it("darkens an inaccessible color until it passes", () => {
const suggestion = suggestAccessibleAccent("#facc15");
expect(suggestion).not.toBe("#facc15");
expect(meetsWcagAaAgainstWhite(suggestion!)).toBe(true);
expect(meetsWcagAaAgainstWhite("#ffffff")).toBe(false);
expect(meetsWcagAaAgainstWhite(suggestAccessibleAccent("#ffffff")!)).toBe(true);
});

it("returns null for invalid input", () => {
expect(suggestAccessibleAccent("zzz")).toBeNull();
});
});

describe("readableTextColor", () => {
it("picks the more legible of black and white", () => {
expect(readableTextColor("#000000")).toBe("#ffffff");
expect(readableTextColor("#ffffff")).toBe("#000000");
expect(readableTextColor("#facc15")).toBe("#000000");
});
});

describe("BrandCustomizationTools", () => {
it("applies a preset and reports the brand", () => {
const onApply = vi.fn();
render(<BrandCustomizationTools onApply={onApply} />);
fireEvent.click(screen.getByLabelText("Use Emerald preset"));
fireEvent.change(screen.getByLabelText("Tagline"), { target: { value: " Pay with ease " } });
fireEvent.click(screen.getByText("Apply branding"));
expect(onApply).toHaveBeenCalledWith({ accentColor: "#047857", tagline: "Pay with ease" });
});

it("blocks inaccessible colors until they are fixed", () => {
const onApply = vi.fn();
render(<BrandCustomizationTools accentColor="#facc15" onApply={onApply} />);
expect(screen.getByRole("status")).toHaveTextContent("fails WCAG AA");
expect(screen.getByText("Apply branding")).toBeDisabled();

fireEvent.click(screen.getByText("Make accessible"));
expect(screen.getByRole("status")).toHaveTextContent("passes WCAG AA");
expect(screen.getByText("Apply branding")).toBeEnabled();
});

it("flags invalid hex input and disables applying", () => {
render(<BrandCustomizationTools />);
fireEvent.change(screen.getByLabelText("Accent color"), { target: { value: "not-a-color" } });
expect(screen.getByRole("status")).toHaveTextContent("Enter a valid hex color");
expect(screen.getByText("Apply branding")).toBeDisabled();
});

it("previews the tagline", () => {
render(<BrandCustomizationTools tagline="Hello there" />);
expect(screen.getByTestId("brand-preview")).toHaveTextContent("Hello there");
});
});
113 changes: 113 additions & 0 deletions src/__tests__/payerSegmentation.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,113 @@
import { render, screen, fireEvent, within } from "@testing-library/react";
import PayerSegmentation from "@/components/analytics/PayerSegmentation";
import {
championThreshold,
classifyPayer,
segmentPayers,
summarizeSegments,
type PayerRecord,
} from "@/lib/payerSegmentation";

const now = Date.parse("2026-06-30T00:00:00Z");
const daysAgo = (n: number) => new Date(now - n * 24 * 60 * 60 * 1000).toISOString();

const payer = (address: string, overrides: Partial<PayerRecord>): PayerRecord => ({
address,
totalPaid: 10,
paymentCount: 1,
firstPaidAt: daysAgo(200),
lastPaidAt: daysAgo(5),
...overrides,
});

describe("classifyPayer", () => {
it("marks payers with no payment in over 90 days as lapsed", () => {
expect(classifyPayer(payer("a", { lastPaidAt: daysAgo(120) }), now, 0)).toBe("lapsed");
});

it("marks payers quiet for 31-90 days as at-risk", () => {
expect(classifyPayer(payer("a", { lastPaidAt: daysAgo(45) }), now, 0)).toBe("at-risk");
});

it("marks recent first-time payers as new", () => {
expect(classifyPayer(payer("a", { firstPaidAt: daysAgo(3), lastPaidAt: daysAgo(3) }), now, 0)).toBe("new");
});

it("marks frequent, high-value, recent payers as champions", () => {
expect(classifyPayer(payer("a", { paymentCount: 8, totalPaid: 500 }), now, 100)).toBe("champions");
});

it("marks frequent but lower-value payers as loyal", () => {
expect(classifyPayer(payer("a", { paymentCount: 8, totalPaid: 50 }), now, 100)).toBe("loyal");
expect(classifyPayer(payer("a", { paymentCount: 3 }), now, 100)).toBe("loyal");
});

it("marks recent occasional payers as casual", () => {
expect(classifyPayer(payer("a", { paymentCount: 2 }), now, 100)).toBe("casual");
});
});

describe("championThreshold", () => {
it("is 0 for no payers and the 75th percentile otherwise", () => {
expect(championThreshold([])).toBe(0);
const payers = [10, 20, 30, 40].map((t, i) => payer(`p${i}`, { totalPaid: t }));
expect(championThreshold(payers)).toBe(30);
});
});

describe("summarizeSegments", () => {
it("counts members, totals and shares per segment", () => {
const segmented = segmentPayers(
[
payer("a", { lastPaidAt: daysAgo(120), totalPaid: 40 }),
payer("b", { lastPaidAt: daysAgo(150), totalPaid: 60 }),
payer("c", { paymentCount: 2, totalPaid: 5 }),
payer("d", { lastPaidAt: daysAgo(45) }),
],
now,
);
const summary = summarizeSegments(segmented);
const lapsed = summary.find((s) => s.segment === "lapsed")!;
expect(lapsed.count).toBe(2);
expect(lapsed.totalPaid).toBe(100);
expect(lapsed.share).toBe(0.5);
expect(summary.reduce((n, s) => n + s.count, 0)).toBe(4);
});

it("returns zero shares for no payers", () => {
expect(summarizeSegments([]).every((s) => s.count === 0 && s.share === 0)).toBe(true);
});
});

describe("PayerSegmentation", () => {
const payers = [
payer("GLAPSED", { lastPaidAt: daysAgo(200) }),
payer("GCASUAL", { paymentCount: 2 }),
];

it("shows an empty state", () => {
render(<PayerSegmentation payers={[]} now={now} />);
expect(screen.getByRole("status")).toHaveTextContent("No payers to segment yet.");
});

it("lists payers with their segment and filters by segment", () => {
render(<PayerSegmentation payers={payers} now={now} />);
expect(screen.getByText("GLAPSED")).toBeInTheDocument();
expect(screen.getByText("GCASUAL")).toBeInTheDocument();

fireEvent.change(screen.getByLabelText("Segment"), { target: { value: "lapsed" } });
expect(screen.getByText("GLAPSED")).toBeInTheDocument();
expect(screen.queryByText("GCASUAL")).not.toBeInTheDocument();
expect(within(screen.getByRole("list")).getByTestId("segment-label")).toHaveTextContent("Lapsed");
});

it("filters when a segment card is toggled", () => {
render(<PayerSegmentation payers={payers} now={now} />);
const casualCard = screen.getByRole("button", { name: /Casual/ });
fireEvent.click(casualCard);
expect(casualCard).toHaveAttribute("aria-pressed", "true");
expect(screen.queryByText("GLAPSED")).not.toBeInTheDocument();
fireEvent.click(casualCard);
expect(screen.getByText("GLAPSED")).toBeInTheDocument();
});
});
103 changes: 103 additions & 0 deletions src/__tests__/paymentGamification.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,103 @@
import { render, screen } from "@testing-library/react";
import PaymentGamificationPanel from "@/components/PaymentGamificationPanel";
import {
computeGamification,
levelForPoints,
paymentStreakDays,
pointsForPayment,
type PaymentEvent,
} from "@/lib/paymentGamification";

const ev = (day: string, overrides: Partial<PaymentEvent> = {}): PaymentEvent => ({
invoiceId: `inv-${day}`,
amount: 10,
paidAt: `${day}T12:00:00Z`,
...overrides,
});

describe("pointsForPayment", () => {
it("awards base points plus an on-time bonus and a capped amount bonus", () => {
expect(pointsForPayment(ev("2026-01-01", { amount: 0 }))).toBe(10);
expect(pointsForPayment(ev("2026-01-01", { amount: 0, onTime: true }))).toBe(15);
expect(pointsForPayment(ev("2026-01-01", { amount: 55 }))).toBe(15);
expect(pointsForPayment(ev("2026-01-01", { amount: 100000 }))).toBe(30);
});
});

describe("levelForPoints", () => {
it("returns the highest level whose threshold is met", () => {
expect(levelForPoints(0).name).toBe("Starter");
expect(levelForPoints(49).name).toBe("Starter");
expect(levelForPoints(50).name).toBe("Contributor");
expect(levelForPoints(5000).name).toBe("Legend");
});
});

describe("paymentStreakDays", () => {
it("counts consecutive days ending at the latest payment", () => {
expect(paymentStreakDays([ev("2026-01-01"), ev("2026-01-02"), ev("2026-01-03")])).toBe(3);
expect(paymentStreakDays([ev("2026-01-01"), ev("2026-01-03"), ev("2026-01-04")])).toBe(2);
});

it("ignores duplicate days and handles no events", () => {
expect(paymentStreakDays([ev("2026-01-01"), ev("2026-01-01")])).toBe(1);
expect(paymentStreakDays([])).toBe(0);
});
});

describe("computeGamification", () => {
it("has no badges and level 1 with no payments", () => {
const s = computeGamification([]);
expect(s.points).toBe(0);
expect(s.level.level).toBe(1);
expect(s.levelProgress).toBe(0);
expect(s.badges.every((b) => !b.earned)).toBe(true);
});

it("earns badges and reports progress", () => {
const s = computeGamification([
ev("2026-01-01", { onTime: true }),
ev("2026-01-02", { onTime: true }),
ev("2026-01-03", { onTime: true }),
]);
const earned = s.badges.filter((b) => b.earned).map((b) => b.id);
expect(earned).toEqual(["first-payment", "on-time-3", "streak-3"]);
expect(s.points).toBe(48);
expect(s.level.name).toBe("Starter");
expect(s.nextLevel?.name).toBe("Contributor");
expect(s.levelProgress).toBeCloseTo(48 / 50);
});

it("reports full progress at the top level", () => {
const events = Array.from({ length: 60 }, (_, i) =>
ev(`2026-01-${String((i % 28) + 1).padStart(2, "0")}`, { amount: 1000, onTime: true }),
);
const s = computeGamification(events);
expect(s.level.name).toBe("Legend");
expect(s.nextLevel).toBeNull();
expect(s.levelProgress).toBe(1);
});
});

describe("PaymentGamificationPanel", () => {
it("renders level, points, progress and badges", () => {
render(
<PaymentGamificationPanel
events={[ev("2026-01-01", { onTime: true }), ev("2026-01-02", { onTime: true })]}
/>,
);
expect(screen.getByText("Level 1: Starter")).toBeInTheDocument();
expect(screen.getByText("32 points")).toBeInTheDocument();
expect(screen.getByRole("progressbar")).toHaveAttribute("aria-valuenow", "64");
expect(screen.getByText("18 points to Contributor")).toBeInTheDocument();
const badge = screen.getByText("First payment").closest("li");
expect(badge).toHaveAttribute("data-earned", "true");
expect(screen.getByText("Regular").closest("li")).toHaveAttribute("data-earned", "false");
});

it("shows the empty state for no payments", () => {
render(<PaymentGamificationPanel events={[]} />);
expect(screen.getByText("0 points")).toBeInTheDocument();
expect(screen.getByText("50 points to Contributor")).toBeInTheDocument();
});
});
18 changes: 18 additions & 0 deletions src/app/resources/invoices/page.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
import type { Metadata } from "next";
import InvoiceEducationalResources from "@/components/InvoiceEducationalResources";

export const metadata: Metadata = {
title: "Invoice resources — StellarSplit",
};

export default function InvoiceResourcesPage() {
return (
<main className="mx-auto max-w-3xl px-4 py-8">
<h1 className="mb-2 text-2xl font-semibold">Invoice resources</h1>
<p className="mb-6 text-sm text-gray-500">
Guides for creating, paying and safely handling invoices on StellarSplit.
</p>
<InvoiceEducationalResources />
</main>
);
}
Loading