("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 })),
+ };
+}