diff --git a/src/__tests__/InvoiceEducationalResources.test.tsx b/src/__tests__/InvoiceEducationalResources.test.tsx new file mode 100644 index 0000000..9904544 --- /dev/null +++ b/src/__tests__/InvoiceEducationalResources.test.tsx @@ -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(); + 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(); + 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(); + 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(); + }); +}); diff --git a/src/__tests__/brandTools.test.tsx b/src/__tests__/brandTools.test.tsx new file mode 100644 index 0000000..04b87ee --- /dev/null +++ b/src/__tests__/brandTools.test.tsx @@ -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(); + 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(); + 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(); + 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(); + expect(screen.getByTestId("brand-preview")).toHaveTextContent("Hello there"); + }); +}); diff --git a/src/__tests__/payerSegmentation.test.tsx b/src/__tests__/payerSegmentation.test.tsx new file mode 100644 index 0000000..d70b427 --- /dev/null +++ b/src/__tests__/payerSegmentation.test.tsx @@ -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 => ({ + 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(); + expect(screen.getByRole("status")).toHaveTextContent("No payers to segment yet."); + }); + + it("lists payers with their segment and filters by segment", () => { + render(); + 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(); + 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(); + }); +}); diff --git a/src/__tests__/paymentGamification.test.tsx b/src/__tests__/paymentGamification.test.tsx new file mode 100644 index 0000000..0a17f8d --- /dev/null +++ b/src/__tests__/paymentGamification.test.tsx @@ -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 => ({ + 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( + , + ); + 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(); + expect(screen.getByText("0 points")).toBeInTheDocument(); + expect(screen.getByText("50 points to Contributor")).toBeInTheDocument(); + }); +}); diff --git a/src/app/resources/invoices/page.tsx b/src/app/resources/invoices/page.tsx new file mode 100644 index 0000000..7c13bf0 --- /dev/null +++ b/src/app/resources/invoices/page.tsx @@ -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 ( +
+

Invoice resources

+

+ Guides for creating, paying and safely handling invoices on StellarSplit. +

+ +
+ ); +} diff --git a/src/components/InvoiceEducationalResources.tsx b/src/components/InvoiceEducationalResources.tsx new file mode 100644 index 0000000..92eda63 --- /dev/null +++ b/src/components/InvoiceEducationalResources.tsx @@ -0,0 +1,73 @@ +"use client"; + +import { useState } from "react"; +import { + INVOICE_RESOURCES, + RESOURCE_CATEGORIES, + filterResources, + type InvoiceResource, + type ResourceCategory, +} from "@/lib/invoiceResources"; + +export default function InvoiceEducationalResources({ + resources = INVOICE_RESOURCES, +}: { + resources?: InvoiceResource[]; +}) { + const [query, setQuery] = useState(""); + const [category, setCategory] = useState("all"); + const visible = filterResources(resources, { query, category }); + + return ( +
+
+ setQuery(e.target.value)} + /> + +
+ + {visible.length === 0 ? ( +

+ No resources match your search. +

+ ) : ( +
    + {visible.map((r) => ( +
  • +
    + + {r.title} + {r.minutes} min read + +

    {r.summary}

    + {r.body.map((p, i) => ( +

    + {p} +

    + ))} +
    +
  • + ))} +
+ )} +
+ ); +} diff --git a/src/components/PaymentGamificationPanel.tsx b/src/components/PaymentGamificationPanel.tsx new file mode 100644 index 0000000..d6ace44 --- /dev/null +++ b/src/components/PaymentGamificationPanel.tsx @@ -0,0 +1,63 @@ +"use client"; + +import { useMemo } from "react"; +import { computeGamification, type PaymentEvent } from "@/lib/paymentGamification"; + +export default function PaymentGamificationPanel({ events }: { events: PaymentEvent[] }) { + const summary = useMemo(() => computeGamification(events), [events]); + const percent = Math.round(summary.levelProgress * 100); + + return ( +
+
+

+ Level {summary.level.level}: {summary.level.name} +

+ {summary.points} points +
+ +
+
+
+
+

+ {summary.nextLevel + ? `${summary.nextLevel.minPoints - summary.points} points to ${summary.nextLevel.name}` + : "Top level reached"} +

+
+ +
+
+
Payments
+
{summary.paymentCount}
+
+
+
Day streak
+
{summary.streakDays}
+
+
+ +
    + {summary.badges.map((b) => ( +
  • + {b.label} + {b.description} + {b.earned ? "Earned" : "Locked"} +
  • + ))} +
+
+ ); +} diff --git a/src/components/analytics/PayerSegmentation.tsx b/src/components/analytics/PayerSegmentation.tsx new file mode 100644 index 0000000..b7ad27a --- /dev/null +++ b/src/components/analytics/PayerSegmentation.tsx @@ -0,0 +1,83 @@ +"use client"; + +import { useMemo, useState } from "react"; +import { + SEGMENT_DEFINITIONS, + segmentPayers, + summarizeSegments, + type PayerRecord, + type PayerSegmentId, +} from "@/lib/payerSegmentation"; + +export default function PayerSegmentation({ payers, now }: { payers: PayerRecord[]; now?: number }) { + const [selected, setSelected] = useState("all"); + const segmented = useMemo(() => segmentPayers(payers, now), [payers, now]); + const summary = useMemo(() => summarizeSegments(segmented), [segmented]); + const visible = selected === "all" ? segmented : segmented.filter((p) => p.segment === selected); + + if (payers.length === 0) { + return ( +

+ No payers to segment yet. +

+ ); + } + + return ( +
+
+ {summary.map((s) => ( + + ))} +
+ +
+ + +
+ +
    + {visible.map((p) => ( +
  • + {p.address} + + + {SEGMENT_DEFINITIONS.find((d) => d.id === p.segment)?.label} + + {p.paymentCount} payments + {p.totalPaid.toFixed(2)} paid + {p.recencyDays}d ago + +
  • + ))} +
+
+ ); +} diff --git a/src/components/settings/BrandCustomizationTools.tsx b/src/components/settings/BrandCustomizationTools.tsx new file mode 100644 index 0000000..657b9be --- /dev/null +++ b/src/components/settings/BrandCustomizationTools.tsx @@ -0,0 +1,112 @@ +"use client"; + +import { useState } from "react"; +import { checkContrast } from "@/lib/contrast"; +import { DEFAULT_ACCENT_COLOR, MAX_TAGLINE_LENGTH } from "@/lib/brandSettings"; +import { + BRAND_PRESETS, + normalizeHex, + readableTextColor, + suggestAccessibleAccent, +} from "@/lib/brandTools"; + +interface Props { + accentColor?: string | null; + tagline?: string | null; + onApply?: (brand: { accentColor: string; tagline: string | null }) => void; +} + +export default function BrandCustomizationTools({ accentColor, tagline, onApply }: Props) { + const [color, setColor] = useState(accentColor ?? DEFAULT_ACCENT_COLOR); + const [text, setText] = useState(tagline ?? ""); + + const normalized = normalizeHex(color); + const contrast = normalized ? checkContrast(normalized) : null; + const previewColor = normalized ?? DEFAULT_ACCENT_COLOR; + const trimmedText = text.trim(); + const canApply = normalized !== null && contrast?.passes === true && trimmedText.length <= MAX_TAGLINE_LENGTH; + + return ( +
+
+

Presets

+
+ {BRAND_PRESETS.map((p) => ( +
+
+ +
+ + +
+ +
+ {contrast === null ? ( + Enter a valid hex color (e.g. #4f46e5). + ) : contrast.passes ? ( + Contrast {contrast.ratio.toFixed(2)}:1 โ€” passes WCAG AA. + ) : ( + + Contrast {contrast.ratio.toFixed(2)}:1 โ€” fails WCAG AA.{" "} + + + )} +
+ +
+

+ Invoice +

+ {trimmedText &&

{trimmedText}

} + + Pay now + +
+ + +
+ ); +} diff --git a/src/lib/brandTools.ts b/src/lib/brandTools.ts new file mode 100644 index 0000000..a34fb46 --- /dev/null +++ b/src/lib/brandTools.ts @@ -0,0 +1,51 @@ +import { contrastRatio, hexToRgb, meetsWcagAaAgainstWhite } from "@/lib/contrast"; + +export interface BrandPreset { + name: string; + accentColor: string; +} + +/** Every preset meets WCAG AA (4.5:1) against white, the invoice background. */ +export const BRAND_PRESETS: BrandPreset[] = [ + { name: "Indigo", accentColor: "#4f46e5" }, + { name: "Emerald", accentColor: "#047857" }, + { name: "Crimson", accentColor: "#be123c" }, + { name: "Ocean", accentColor: "#0369a1" }, + { name: "Amber", accentColor: "#b45309" }, + { name: "Violet", accentColor: "#7c3aed" }, + { name: "Slate", accentColor: "#334155" }, +]; + +const toHexPair = (n: number) => n.toString(16).padStart(2, "0"); + +/** Normalizes "#RGB" / "#RRGGBB" (any case, "#" optional) to lowercase "#rrggbb", or null. */ +export function normalizeHex(hex: string): string | null { + const rgb = hexToRgb(hex); + return rgb ? `#${toHexPair(rgb.r)}${toHexPair(rgb.g)}${toHexPair(rgb.b)}` : null; +} + +/** + * Darkens `hex` in 5% steps towards black until it meets WCAG AA against + * white. Returns the normalized color unchanged when it already passes, and + * null for an invalid color. + */ +export function suggestAccessibleAccent(hex: string): string | null { + const normalized = normalizeHex(hex); + const rgb = hexToRgb(hex); + if (!normalized || !rgb) return null; + for (let step = 0; step <= 20; step++) { + const factor = 1 - step / 20; + const candidate = `#${toHexPair(Math.round(rgb.r * factor))}${toHexPair( + Math.round(rgb.g * factor), + )}${toHexPair(Math.round(rgb.b * factor))}`; + if (meetsWcagAaAgainstWhite(candidate)) return candidate; + } + return "#000000"; +} + +/** Black or white, whichever is more legible on top of `background`. */ +export function readableTextColor(background: string): "#ffffff" | "#000000" { + return contrastRatio(background, "#ffffff") >= contrastRatio(background, "#000000") + ? "#ffffff" + : "#000000"; +} diff --git a/src/lib/invoiceResources.ts b/src/lib/invoiceResources.ts new file mode 100644 index 0000000..c2325b3 --- /dev/null +++ b/src/lib/invoiceResources.ts @@ -0,0 +1,125 @@ +export type ResourceCategory = "basics" | "payments" | "security" | "tips"; + +export interface InvoiceResource { + id: string; + title: string; + summary: string; + category: ResourceCategory; + minutes: number; + body: string[]; +} + +export const RESOURCE_CATEGORIES: { value: ResourceCategory; label: string }[] = [ + { value: "basics", label: "Basics" }, + { value: "payments", label: "Payments" }, + { value: "security", label: "Security" }, + { value: "tips", label: "Tips" }, +]; + +export const INVOICE_RESOURCES: InvoiceResource[] = [ + { + id: "what-is-a-split-invoice", + title: "What is a split invoice?", + summary: "How one invoice can be funded by several payers.", + category: "basics", + minutes: 3, + body: [ + "A split invoice has one total amount and one or more recipients. Each payer contributes a share until the target is reached.", + "Once the invoice is fully funded, the funds are released to the recipients according to their configured splits.", + ], + }, + { + id: "reading-invoice-status", + title: "Understanding invoice statuses", + summary: "What pending, funded, released and expired mean.", + category: "basics", + minutes: 2, + body: [ + "Pending invoices are still collecting contributions. Funded invoices have reached their target and are ready to release.", + "Released invoices have paid out to recipients. Expired invoices passed their deadline before reaching the target.", + ], + }, + { + id: "paying-with-a-wallet", + title: "Paying an invoice with your wallet", + summary: "Connect a Stellar wallet, review the fee and confirm.", + category: "payments", + minutes: 4, + body: [ + "Connect your wallet from the pay page, then choose how much to contribute. The estimated network fee is shown before you sign.", + "Confirm the transaction in your wallet. The invoice updates as soon as the payment is confirmed on the network.", + ], + }, + { + id: "partial-payments", + title: "Contributing part of the total", + summary: "Pay your share now and let others cover the rest.", + category: "payments", + minutes: 2, + body: [ + "You can contribute any amount up to the remaining balance. Your contribution is recorded even if the invoice is not yet fully funded.", + ], + }, + { + id: "verify-before-you-pay", + title: "Verify an invoice before paying", + summary: "Check the recipient, amount and creator before you sign.", + category: "security", + minutes: 3, + body: [ + "Confirm the recipient address and the total amount match what you expect. Look for a verified creator badge on the invoice.", + "Never sign a transaction you were not expecting, and never share your wallet secret key with anyone.", + ], + }, + { + id: "spotting-phishing-links", + title: "Spotting phishing links", + summary: "Make sure a payment link really belongs to the invoice creator.", + category: "security", + minutes: 3, + body: [ + "Only open invoice links from people you know. Check the domain in the address bar before connecting your wallet.", + ], + }, + { + id: "writing-clear-invoices", + title: "Writing clear invoice descriptions", + summary: "Help payers understand what they are paying for.", + category: "tips", + minutes: 2, + body: [ + "Use a specific title and itemize line items. Clear descriptions reduce questions and speed up payment.", + ], + }, + { + id: "choosing-a-deadline", + title: "Choosing a sensible deadline", + summary: "Balance urgency with enough time for payers to act.", + category: "tips", + minutes: 2, + body: [ + "Give payers at least several days when contributions come from multiple people. Send a reminder as the deadline approaches.", + ], + }, +]; + +export interface ResourceFilter { + query?: string; + category?: ResourceCategory | "all"; +} + +export function filterResources( + resources: InvoiceResource[], + { query = "", category = "all" }: ResourceFilter = {}, +): InvoiceResource[] { + const needle = query.trim().toLowerCase(); + return resources.filter((r) => { + if (category !== "all" && r.category !== category) return false; + if (!needle) return true; + return ( + r.title.toLowerCase().includes(needle) || + r.summary.toLowerCase().includes(needle) || + r.body.some((p) => p.toLowerCase().includes(needle)) + ); + }); +} diff --git a/src/lib/payerSegmentation.ts b/src/lib/payerSegmentation.ts new file mode 100644 index 0000000..f052d1d --- /dev/null +++ b/src/lib/payerSegmentation.ts @@ -0,0 +1,97 @@ +export interface PayerRecord { + address: string; + totalPaid: number; + paymentCount: number; + firstPaidAt: string; + lastPaidAt: string; +} + +export type PayerSegmentId = "champions" | "loyal" | "new" | "at-risk" | "lapsed" | "casual"; + +export interface SegmentedPayer extends PayerRecord { + segment: PayerSegmentId; + recencyDays: number; +} + +export interface SegmentSummary { + segment: PayerSegmentId; + label: string; + description: string; + count: number; + totalPaid: number; + share: number; +} + +export const SEGMENT_DEFINITIONS: { id: PayerSegmentId; label: string; description: string }[] = [ + { id: "champions", label: "Champions", description: "Paid recently, often and in high volume." }, + { id: "loyal", label: "Loyal", description: "Pay repeatedly and recently." }, + { id: "new", label: "New", description: "First payment in the last 30 days." }, + { id: "casual", label: "Casual", description: "Paid recently but only occasionally." }, + { id: "at-risk", label: "At risk", description: "Used to pay but quiet for 30 to 90 days." }, + { id: "lapsed", label: "Lapsed", description: "No payment for more than 90 days." }, +]; + +const DAY_MS = 24 * 60 * 60 * 1000; +const NEW_WINDOW_DAYS = 30; +const AT_RISK_AFTER_DAYS = 30; +const LAPSED_AFTER_DAYS = 90; +const LOYAL_MIN_PAYMENTS = 3; +const CHAMPION_MIN_PAYMENTS = 5; + +function daysBetween(fromIso: string, now: number): number { + const from = Date.parse(fromIso); + if (Number.isNaN(from)) return Infinity; + return Math.max(0, Math.floor((now - from) / DAY_MS)); +} + +/** + * Assigns one segment to a payer. `championMinTotal` is the total paid a payer + * needs, on top of frequency and recency, to count as a champion. + */ +export function classifyPayer( + payer: PayerRecord, + now: number, + championMinTotal: number, +): PayerSegmentId { + const recency = daysBetween(payer.lastPaidAt, now); + if (recency > LAPSED_AFTER_DAYS) return "lapsed"; + if (recency > AT_RISK_AFTER_DAYS) return "at-risk"; + if (daysBetween(payer.firstPaidAt, now) <= NEW_WINDOW_DAYS && payer.paymentCount < LOYAL_MIN_PAYMENTS) { + return "new"; + } + if (payer.paymentCount >= CHAMPION_MIN_PAYMENTS && payer.totalPaid >= championMinTotal) return "champions"; + if (payer.paymentCount >= LOYAL_MIN_PAYMENTS) return "loyal"; + return "casual"; +} + +/** The champion spend threshold is the 75th percentile of total paid across payers. */ +export function championThreshold(payers: PayerRecord[]): number { + if (payers.length === 0) return 0; + const sorted = payers.map((p) => p.totalPaid).sort((a, b) => a - b); + const idx = Math.min(sorted.length - 1, Math.ceil(sorted.length * 0.75) - 1); + return sorted[idx]; +} + +export function segmentPayers(payers: PayerRecord[], now: number = Date.now()): SegmentedPayer[] { + const threshold = championThreshold(payers); + return payers.map((p) => ({ + ...p, + segment: classifyPayer(p, now, threshold), + recencyDays: daysBetween(p.lastPaidAt, now), + })); +} + +export function summarizeSegments(segmented: SegmentedPayer[]): SegmentSummary[] { + const total = segmented.length; + return SEGMENT_DEFINITIONS.map((def) => { + const members = segmented.filter((p) => p.segment === def.id); + return { + segment: def.id, + label: def.label, + description: def.description, + count: members.length, + totalPaid: members.reduce((sum, p) => sum + p.totalPaid, 0), + share: total === 0 ? 0 : members.length / total, + }; + }); +} diff --git a/src/lib/paymentGamification.ts b/src/lib/paymentGamification.ts new file mode 100644 index 0000000..1708c10 --- /dev/null +++ b/src/lib/paymentGamification.ts @@ -0,0 +1,118 @@ +export interface PaymentEvent { + invoiceId: string; + amount: number; + paidAt: string; + /** True when the payment landed before the invoice deadline. */ + onTime?: boolean; +} + +export interface Level { + level: number; + name: string; + minPoints: number; +} + +export interface BadgeDefinition { + id: string; + label: string; + description: string; +} + +export interface Badge extends BadgeDefinition { + earned: boolean; +} + +export interface GamificationSummary { + points: number; + level: Level; + nextLevel: Level | null; + /** 0..1 progress through the current level towards the next one. */ + levelProgress: number; + /** Consecutive calendar days (UTC) ending at the latest payment day. */ + streakDays: number; + paymentCount: number; + badges: Badge[]; +} + +export const LEVELS: Level[] = [ + { level: 1, name: "Starter", minPoints: 0 }, + { level: 2, name: "Contributor", minPoints: 50 }, + { level: 3, name: "Supporter", minPoints: 150 }, + { level: 4, name: "Champion", minPoints: 400 }, + { level: 5, name: "Legend", minPoints: 1000 }, +]; + +export const BADGES: BadgeDefinition[] = [ + { id: "first-payment", label: "First payment", description: "Paid your first invoice." }, + { id: "five-payments", label: "Regular", description: "Paid five invoices." }, + { id: "on-time-3", label: "Punctual", description: "Paid three invoices on time." }, + { id: "streak-3", label: "On a roll", description: "Paid on three consecutive days." }, + { id: "big-spender", label: "Big spender", description: "Paid 1,000 or more in total." }, +]; + +const DAY_MS = 24 * 60 * 60 * 1000; +const BASE_POINTS = 10; +const ON_TIME_BONUS = 5; +const MAX_AMOUNT_BONUS = 20; + +/** 10 base points, +5 when on time, plus 1 per 10 units paid (capped at 20). */ +export function pointsForPayment(event: PaymentEvent): number { + const amountBonus = Math.min(MAX_AMOUNT_BONUS, Math.floor(Math.max(0, event.amount) / 10)); + return BASE_POINTS + (event.onTime ? ON_TIME_BONUS : 0) + amountBonus; +} + +export function levelForPoints(points: number): Level { + let current = LEVELS[0]; + for (const l of LEVELS) { + if (points >= l.minPoints) current = l; + } + return current; +} + +function dayNumber(iso: string): number | null { + const t = Date.parse(iso); + return Number.isNaN(t) ? null : Math.floor(t / DAY_MS); +} + +export function paymentStreakDays(events: PaymentEvent[]): number { + const days = Array.from( + new Set(events.map((e) => dayNumber(e.paidAt)).filter((d): d is number => d !== null)), + ).sort((a, b) => b - a); + if (days.length === 0) return 0; + let streak = 1; + for (let i = 1; i < days.length; i++) { + if (days[i - 1] - days[i] !== 1) break; + streak++; + } + return streak; +} + +export function computeGamification(events: PaymentEvent[]): GamificationSummary { + const points = events.reduce((sum, e) => sum + pointsForPayment(e), 0); + const level = levelForPoints(points); + const nextLevel = LEVELS.find((l) => l.minPoints > level.minPoints) ?? null; + const levelProgress = nextLevel + ? (points - level.minPoints) / (nextLevel.minPoints - level.minPoints) + : 1; + const streakDays = paymentStreakDays(events); + const total = events.reduce((sum, e) => sum + Math.max(0, e.amount), 0); + const onTimeCount = events.filter((e) => e.onTime).length; + + const earned: Record = { + "first-payment": events.length >= 1, + "five-payments": events.length >= 5, + "on-time-3": onTimeCount >= 3, + "streak-3": streakDays >= 3, + "big-spender": total >= 1000, + }; + + return { + points, + level, + nextLevel, + levelProgress, + streakDays, + paymentCount: events.length, + badges: BADGES.map((b) => ({ ...b, earned: earned[b.id] ?? false })), + }; +}