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
39 changes: 39 additions & 0 deletions src/components/creator/CommunityForumPanel.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,39 @@
import { DEFAULT_FORUM_URL, newTopicUrl, sortTopicsByActivity, topicUrl, type ForumTopic } from "@/lib/creatorForum";

interface Props {
topics: ForumTopic[];
baseUrl?: string;
}

export default function CommunityForumPanel({ topics, baseUrl = DEFAULT_FORUM_URL }: Props) {
const config = { baseUrl };
return (
<section aria-label="Creator community forum" className="w-full rounded-lg border p-4 sm:p-6">
<div className="flex flex-col gap-2 sm:flex-row sm:items-center sm:justify-between">
<h3 className="text-base font-semibold">Creator community</h3>
<a
href={newTopicUrl(config, "")}
target="_blank"
rel="noopener noreferrer"
className="rounded bg-blue-600 px-3 py-1 text-center text-sm text-white"
>
Start a discussion
</a>
</div>
{topics.length === 0 ? (
<p className="mt-3 text-sm text-gray-500">No discussions yet.</p>
) : (
<ul className="mt-3 divide-y text-sm">
{sortTopicsByActivity(topics).map((t) => (
<li key={t.id} className="flex justify-between gap-4 py-2">
<a href={topicUrl(config, t)} target="_blank" rel="noopener noreferrer" className="truncate hover:underline">
{t.title}
</a>
<span className="shrink-0 text-gray-500">{t.replies} replies</span>
</li>
))}
</ul>
)}
</section>
);
}
22 changes: 22 additions & 0 deletions src/components/creator/CreatorTrustBadges.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
import { getTrustBadges, getTrustTier, type CreatorProfile, type TrustTier } from "@/lib/creatorVerification";

const TIER_STYLES: Record<TrustTier, string> = {
unverified: "bg-gray-100 text-gray-700",
verified: "bg-blue-100 text-blue-800",
trusted: "bg-green-100 text-green-800",
};

export default function CreatorTrustBadges({ profile }: { profile: CreatorProfile }) {
const tier = getTrustTier(profile);
const badges = getTrustBadges(profile);
return (
<div aria-label="Creator trust signals" className="flex flex-wrap items-center gap-2">
<span className={`rounded-full px-2.5 py-0.5 text-xs font-semibold capitalize ${TIER_STYLES[tier]}`}>{tier}</span>
{badges.map((b) => (
<span key={b.id} className="rounded-full border px-2.5 py-0.5 text-xs text-gray-700">
✓ {b.label}
</span>
))}
</div>
);
}
60 changes: 60 additions & 0 deletions src/components/creator/TaxReportExport.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,60 @@
"use client";

import { useState } from "react";
import { buildTaxReportCsv, summarizeTaxYear, type TaxablePayment } from "@/lib/creatorTaxReport";

interface Props {
payments: TaxablePayment[];
}

export default function TaxReportExport({ payments }: Props) {
const years = Array.from(new Set(payments.map((p) => new Date(p.paidAt).getUTCFullYear()))).sort((a, b) => b - a);
const [year, setYear] = useState(years[0] ?? new Date().getUTCFullYear());
const summary = summarizeTaxYear(payments, year);

const handleExport = () => {
const blob = new Blob([buildTaxReportCsv(payments, year)], { type: "text/csv;charset=utf-8" });
const url = URL.createObjectURL(blob);
const a = document.createElement("a");
a.href = url;
a.download = `tax-report-${year}.csv`;
a.click();
URL.revokeObjectURL(url);
};

return (
<section aria-label="Tax report export" className="w-full rounded-lg border p-4 sm:p-6">
<div className="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
<h3 className="text-base font-semibold">Tax report</h3>
<div className="flex gap-2">
<select
aria-label="Tax year"
value={year}
onChange={(e) => setYear(Number(e.target.value))}
className="rounded border px-2 py-1 text-sm"
>
{(years.length ? years : [year]).map((y) => (
<option key={y} value={y}>
{y}
</option>
))}
</select>
<button
type="button"
onClick={handleExport}
disabled={summary.count === 0}
className="rounded bg-blue-600 px-3 py-1 text-sm text-white disabled:opacity-50"
>
Export CSV
</button>
</div>
</div>
<dl className="mt-3 grid grid-cols-2 gap-2 text-sm sm:grid-cols-4">
<div><dt className="text-gray-500">Payments</dt><dd>{summary.count}</dd></div>
<div><dt className="text-gray-500">Gross</dt><dd>{summary.gross}</dd></div>
<div><dt className="text-gray-500">Fees</dt><dd>{summary.fees}</dd></div>
<div><dt className="text-gray-500">Net</dt><dd>{summary.net}</dd></div>
</dl>
</section>
);
}
35 changes: 35 additions & 0 deletions src/components/invoice/RiskAssessmentPanel.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
"use client";

import { assessInvoiceRisk, type RiskInput, type RiskLevel } from "@/lib/invoiceRiskAssessment";

const LEVEL_STYLES: Record<RiskLevel, string> = {
low: "bg-green-100 text-green-800",
medium: "bg-amber-100 text-amber-800",
high: "bg-red-100 text-red-800",
};

export default function RiskAssessmentPanel(props: RiskInput) {
const { score, level, factors } = assessInvoiceRisk(props);
return (
<section aria-label="Risk assessment" className="w-full rounded-lg border p-4 sm:p-6">
<div className="flex flex-wrap items-center justify-between gap-2">
<h3 className="text-base font-semibold">Risk assessment</h3>
<span className={`rounded-full px-3 py-1 text-sm font-medium ${LEVEL_STYLES[level]}`}>
{level} risk · {score}/100
</span>
</div>
{factors.length > 0 ? (
<ul className="mt-3 space-y-1 text-sm">
{factors.map((f) => (
<li key={f.id} className="flex justify-between gap-4">
<span>{f.label}</span>
<span className="text-gray-500">+{f.points}</span>
</li>
))}
</ul>
) : (
<p className="mt-3 text-sm text-gray-500">No risk factors detected.</p>
)}
</section>
);
}
22 changes: 22 additions & 0 deletions src/lib/creatorForum.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
import { describe, it, expect } from "vitest";
import { newTopicUrl, sortTopicsByActivity, topicUrl } from "./creatorForum";

const config = { baseUrl: "https://forum.example.com/" };

describe("creatorForum", () => {
it("builds topic URLs without double slashes", () => {
expect(topicUrl(config, { id: 42, slug: "tips" })).toBe("https://forum.example.com/t/tips/42");
});

it("builds an encoded new-topic URL", () => {
expect(newTopicUrl(config, "Help & tips")).toBe("https://forum.example.com/new-topic?title=Help+%26+tips&category=creators");
});

it("sorts topics by most recent activity", () => {
const topics = [
{ id: 1, title: "a", slug: "a", replies: 0, lastActivity: "2025-01-01T00:00:00Z" },
{ id: 2, title: "b", slug: "b", replies: 3, lastActivity: "2025-06-01T00:00:00Z" },
];
expect(sortTopicsByActivity(topics).map((t) => t.id)).toEqual([2, 1]);
});
});
27 changes: 27 additions & 0 deletions src/lib/creatorForum.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
export interface ForumConfig {
/** Base URL of the community forum (e.g. a Discourse instance). */
baseUrl: string;
}

export interface ForumTopic {
id: number;
title: string;
slug: string;
replies: number;
lastActivity: string;
}

export const DEFAULT_FORUM_URL = process.env.NEXT_PUBLIC_COMMUNITY_FORUM_URL ?? "https://forum.stellarsplit.app";

export function topicUrl(config: ForumConfig, topic: Pick<ForumTopic, "id" | "slug">): string {
return `${config.baseUrl.replace(/\/+$/, "")}/t/${encodeURIComponent(topic.slug)}/${topic.id}`;
}

export function newTopicUrl(config: ForumConfig, title: string, category = "creators"): string {
const params = new URLSearchParams({ title, category });
return `${config.baseUrl.replace(/\/+$/, "")}/new-topic?${params.toString()}`;
}

export function sortTopicsByActivity(topics: ForumTopic[]): ForumTopic[] {
return [...topics].sort((a, b) => Date.parse(b.lastActivity) - Date.parse(a.lastActivity));
}
22 changes: 22 additions & 0 deletions src/lib/creatorTaxReport.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
import { describe, it, expect } from "vitest";
import { buildTaxReportCsv, summarizeTaxYear } from "./creatorTaxReport";

const payments = [
{ invoiceId: "1", title: "Dinner, split", paidAt: "2025-03-01T00:00:00Z", amount: 100, fee: 1, asset: "XLM" },
{ invoiceId: "2", title: "Rent", paidAt: "2025-12-31T23:00:00Z", amount: 500, fee: 5, asset: "USDC" },
{ invoiceId: "3", title: "Old", paidAt: "2024-06-01T00:00:00Z", amount: 999, fee: 9, asset: "XLM" },
];

describe("creatorTaxReport", () => {
it("summarizes only the requested year", () => {
expect(summarizeTaxYear(payments, 2025)).toEqual({ year: 2025, gross: 600, fees: 6, net: 594, count: 2 });
});

it("builds a CSV with escaped cells and a totals row", () => {
const lines = buildTaxReportCsv(payments, 2025).split("\n");
expect(lines[0]).toBe("Invoice ID,Title,Paid At,Asset,Gross,Fee,Net");
expect(lines[1]).toBe('1,"Dinner, split",2025-03-01T00:00:00Z,XLM,100,1,99');
expect(lines).toHaveLength(4);
expect(lines[3]).toBe("TOTAL,,,,600,6,594");
});
});
43 changes: 43 additions & 0 deletions src/lib/creatorTaxReport.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,43 @@
export interface TaxablePayment {
invoiceId: string;
title: string;
/** ISO date the payment settled. */
paidAt: string;
amount: number;
fee: number;
asset: string;
}

export interface TaxReportSummary {
year: number;
gross: number;
fees: number;
net: number;
count: number;
}

export function filterByTaxYear(payments: TaxablePayment[], year: number): TaxablePayment[] {
return payments.filter((p) => new Date(p.paidAt).getUTCFullYear() === year);
}

export function summarizeTaxYear(payments: TaxablePayment[], year: number): TaxReportSummary {
const rows = filterByTaxYear(payments, year);
const gross = rows.reduce((s, p) => s + p.amount, 0);
const fees = rows.reduce((s, p) => s + p.fee, 0);
return { year, gross, fees, net: gross - fees, count: rows.length };
}

function csvCell(value: string | number): string {
const s = String(value);
return /[",\n]/.test(s) ? `"${s.replace(/"/g, '""')}"` : s;
}

export function buildTaxReportCsv(payments: TaxablePayment[], year: number): string {
const header = ["Invoice ID", "Title", "Paid At", "Asset", "Gross", "Fee", "Net"];
const lines = filterByTaxYear(payments, year).map((p) =>
[p.invoiceId, p.title, p.paidAt, p.asset, p.amount, p.fee, p.amount - p.fee].map(csvCell).join(",")
);
const s = summarizeTaxYear(payments, year);
lines.push(["TOTAL", "", "", "", s.gross, s.fees, s.net].map(csvCell).join(","));
return [header.join(","), ...lines].join("\n");
}
25 changes: 25 additions & 0 deletions src/lib/creatorVerification.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
import { describe, it, expect } from "vitest";
import { getTrustBadges, getTrustTier } from "./creatorVerification";

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

describe("creatorVerification", () => {
it("returns no badges and unverified tier for a new profile", () => {
expect(getTrustBadges(base)).toEqual([]);
expect(getTrustTier(base)).toBe("unverified");
});

it("marks wallet-verified creators as verified", () => {
expect(getTrustTier({ ...base, walletVerified: true })).toBe("verified");
});

it("marks fully verified reliable creators as trusted", () => {
const p = { emailVerified: true, walletVerified: true, kycVerified: true, accountAgeDays: 365, completedInvoices: 20, disputeRate: 0 };
expect(getTrustBadges(p).map((b) => b.id)).toEqual(["email", "wallet", "kyc", "established", "reliable"]);
expect(getTrustTier(p)).toBe("trusted");
});

it("withholds the reliable badge when dispute rate is high", () => {
expect(getTrustBadges({ ...base, completedInvoices: 20, disputeRate: 0.2 })).toEqual([]);
});
});
33 changes: 33 additions & 0 deletions src/lib/creatorVerification.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
export interface CreatorProfile {
emailVerified: boolean;
walletVerified: boolean;
kycVerified: boolean;
accountAgeDays: number;
completedInvoices: number;
disputeRate: number; // 0-1
}

export type BadgeId = "email" | "wallet" | "kyc" | "established" | "reliable";

export interface TrustBadge {
id: BadgeId;
label: string;
}

export type TrustTier = "unverified" | "verified" | "trusted";

export function getTrustBadges(p: CreatorProfile): TrustBadge[] {
const badges: TrustBadge[] = [];
if (p.emailVerified) badges.push({ id: "email", label: "Email verified" });
if (p.walletVerified) badges.push({ id: "wallet", label: "Wallet verified" });
if (p.kycVerified) badges.push({ id: "kyc", label: "Identity verified" });
if (p.accountAgeDays >= 180) badges.push({ id: "established", label: "Established creator" });
if (p.completedInvoices >= 10 && p.disputeRate < 0.05) badges.push({ id: "reliable", label: "Reliable payer history" });
return badges;
}

export function getTrustTier(p: CreatorProfile): TrustTier {
if (!p.walletVerified) return "unverified";
const ids = getTrustBadges(p).map((b) => b.id);
return ids.includes("kyc") && ids.includes("reliable") ? "trusted" : "verified";
}
22 changes: 22 additions & 0 deletions src/lib/invoiceRiskAssessment.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
import { describe, it, expect } from "vitest";
import { assessInvoiceRisk } from "./invoiceRiskAssessment";

const base = { amount: 100, recipientCount: 2, creatorAccountAgeDays: 365, priorDisputes: 0, deadlineDays: 14 };

describe("assessInvoiceRisk", () => {
it("rates a typical invoice as low risk", () => {
expect(assessInvoiceRisk(base)).toEqual({ score: 0, level: "low", factors: [] });
});

it("flags medium risk for elevated amount from a young account", () => {
const r = assessInvoiceRisk({ ...base, amount: 5_000, creatorAccountAgeDays: 10, priorDisputes: 1, isPrivate: true });
expect(r.level).toBe("medium");
expect(r.factors.map((f) => f.id)).toEqual(["medium-amount", "young-account", "disputes", "private"]);
});

it("flags high risk and caps score at 100", () => {
const r = assessInvoiceRisk({ amount: 50_000, recipientCount: 50, creatorAccountAgeDays: 1, priorDisputes: 5, deadlineDays: 0 });
expect(r.level).toBe("high");
expect(r.score).toBe(100);
});
});
Loading