Files
apps/donations/desktop/donations.js
metacryst 0d6c7683ff init
2026-04-28 20:05:00 -05:00

426 lines
17 KiB
JavaScript

import donationServer from "/donations/@server/donations.js"
css(`
donations- {
font-family: 'Arial';
scrollbar-width: none;
-ms-overflow-style: none;
}
donations- input::placeholder {
color: var(--headertext);
opacity: 0.35;
}
`)
class Donations extends Shadow {
donations = []
subscriptions = []
typeFilter = "all"
searchText = ""
sortKey = "created"
sortDir = "desc"
get allTransactions() {
const d = this.donations.map(don => ({
id: `d-${don.id}`,
created: don.created,
name: don.name || "—",
email: don.email || "—",
amount: Number(don.amount),
type: "donation",
product: "Donation",
status: "complete"
}))
const s = this.subscriptions.map(sub => ({
id: `s-${sub.id}`,
created: sub.created,
name: [sub.first_name, sub.last_name].filter(Boolean).join(" ") || sub.email || "—",
email: sub.email || "—",
amount: Number(sub.plan_price),
type: "subscription",
product: sub.plan_name || "Subscription",
status: sub.active ? "active" : "inactive"
}))
return [...d, ...s]
}
get transactions() {
let all = this.allTransactions
if (this.typeFilter === "donations") all = all.filter(t => t.type === "donation")
else if (this.typeFilter === "subscriptions") all = all.filter(t => t.type === "subscription")
if (this.searchText) {
const q = this.searchText.toLowerCase()
all = all.filter(t =>
t.name.toLowerCase().includes(q) ||
t.email.toLowerCase().includes(q) ||
t.product.toLowerCase().includes(q)
)
}
all.sort((a, b) => {
let av = a[this.sortKey], bv = b[this.sortKey]
if (this.sortKey === "created") { av = new Date(av); bv = new Date(bv) }
else if (this.sortKey === "amount") { av = Number(av); bv = Number(bv) }
else { av = String(av).toLowerCase(); bv = String(bv).toLowerCase() }
if (av < bv) return this.sortDir === "asc" ? -1 : 1
if (av > bv) return this.sortDir === "asc" ? 1 : -1
return 0
})
return all
}
get stats() {
const all = this.allTransactions
const totalRevenue = all.reduce((s, t) => s + t.amount, 0)
const now = new Date()
const thisMonth = all.filter(t => {
const d = new Date(t.created)
return d.getMonth() === now.getMonth() && d.getFullYear() === now.getFullYear()
})
const monthRevenue = thisMonth.reduce((s, t) => s + t.amount, 0)
const activeSubs = this.subscriptions.filter(s => s.active).length
const mrr = this.subscriptions.filter(s => s.active).reduce((s, sub) => s + Number(sub.plan_price), 0)
const donorCount = new Set(this.donations.filter(d => d.email).map(d => d.email)).size
return { totalRevenue, monthRevenue, activeSubs, mrr, donorCount }
}
get monthlyData() {
const now = new Date()
const months = []
for (let i = 5; i >= 0; i--) {
const d = new Date(now.getFullYear(), now.getMonth() - i, 1)
months.push({ label: d.toLocaleString("en-US", { month: "short" }), year: d.getFullYear(), month: d.getMonth(), donations: 0, subscriptions: 0 })
}
this.donations.forEach(t => {
const d = new Date(t.created)
const m = months.find(m => m.year === d.getFullYear() && m.month === d.getMonth())
if (m) m.donations += Number(t.amount)
})
this.subscriptions.forEach(t => {
const d = new Date(t.created)
const m = months.find(m => m.year === d.getFullYear() && m.month === d.getMonth())
if (m) m.subscriptions += Number(t.plan_price)
})
return months
}
render() {
VStack(() => {
this.renderToolbar()
HStack(() => {
this.renderStats()
this.renderChart()
})
.paddingHorizontal(1.25, em)
.paddingBottom(1, em)
.gap(1, em)
.alignItems("stretch")
.flexShrink(0)
VStack(() => {}).height(1, px).background("var(--divider)").marginHorizontal(1.25, em).flexShrink(0)
this.renderTable()
})
.height(100, pct)
.width(100, pct)
.boxSizing("border-box")
.overflow("hidden")
.onAppear(async () => {
const data = await donationServer.getMoneyData(global.currentNetwork.id)
if(this.donations.length !== data.purchases.length || this.subscriptions.length !== data.subscriptions.length) {
this.donations = data.purchases || []
this.subscriptions = data.subscriptions || []
console.log(this.donations, this.subscriptions)
this.rerender()
}
})
}
renderToolbar() {
HStack(() => {
HStack(() => {
this.filterTab("All", "all")
this.filterTab("Donations", "donations")
this.filterTab("Subscriptions", "subscriptions")
})
.background("var(--darkaccent)")
.border("1px solid var(--divider)")
.borderRadius(0.5, em)
.padding(0.2, em)
.gap(0.15, em)
.flexShrink(0)
input("", "180px")
.paddingHorizontal(0.65, em)
.paddingVertical(0.42, em)
.background("var(--darkaccent)")
.border("1px solid var(--divider)")
.borderRadius(0.45, em)
.fontSize(0.85, em)
.color("var(--headertext)")
.outline("none")
.onInput(v => { this.searchText = v; this.rerender() })
})
.marginTop(20, px)
.paddingHorizontal(1.25, em)
.paddingTop(1.1, em)
.paddingBottom(0.85, em)
.alignItems("center")
.gap(0.75, em)
.flexShrink(0)
}
filterTab(label, value) {
const isActive = this.typeFilter === value
p(label)
.margin(0)
.fontSize(0.8, em)
.fontWeight(isActive ? "600" : "400")
.color("var(--headertext)")
.opacity(isActive ? 1 : 0.5)
.paddingHorizontal(0.7, em)
.paddingVertical(0.32, em)
.borderRadius(0.35, em)
.background(isActive ? "var(--app)" : "transparent")
.cursor("pointer")
.onClick((done) => { if(!done) return; this.typeFilter = value; this.rerender() })
}
renderStats() {
const s = this.stats
HStack(() => {
this.statCard("Total Revenue", this.fmt(s.totalRevenue), "📊")
this.statCard("This Month", this.fmt(s.monthRevenue), "📅")
this.statCard("Active Subs", String(s.activeSubs), "🔄")
this.statCard("MRR", this.fmt(s.mrr), "💰")
this.statCard("Donors", String(s.donorCount), "👥")
})
.gap(0.65, em)
.flexShrink(0)
.alignSelf("stretch")
}
statCard(label, value, icon) {
VStack(() => {
p(icon).margin(0).fontSize(1.05, em).lineHeight("1")
p(value)
.margin(0).marginTop(0.5, em)
.fontSize(1.2, em).fontWeight("700")
.color("var(--headertext)").lineHeight("1")
p(label)
.margin(0).marginTop(0.28, em)
.fontSize(0.68, em)
.color("var(--headertext)").opacity(0.42)
})
.padding(0.9, em)
.background("var(--darkaccent)")
.border("1px solid var(--divider)")
.borderRadius(0.6, em)
.minWidth(0)
.flex(1)
}
renderChart() {
const months = this.monthlyData
const max = Math.max(...months.map(m => m.donations + m.subscriptions), 1)
VStack(() => {
p("Monthly Revenue")
.margin(0).marginBottom(0.6, em)
.fontSize(0.68, em).fontWeight("700").letterSpacing("0.06em")
.color("var(--headertext)").opacity(0.4)
HStack(() => {
months.forEach(m => {
const total = m.donations + m.subscriptions
const barPct = Math.max((total / max) * 100, total > 0 ? 3 : 0)
VStack(() => {
VStack(() => {
if (m.subscriptions > 0) {
VStack(() => {}).flex(m.subscriptions).background("#3b82f6")
}
if (m.donations > 0) {
VStack(() => {}).flex(m.donations).background("var(--quillred)")
}
if (total === 0) {
VStack(() => {}).flex(1).background("var(--divider)")
}
})
.height(barPct, pct)
.width(60, pct)
.borderRadius(3, px)
.overflow("hidden")
.alignSelf("center")
.minHeight(total === 0 ? 2 : barPct < 3 ? 3 : barPct, px)
p(m.label)
.margin(0).marginTop(0.4, em)
.fontSize(0.65, em)
.color("var(--headertext)").opacity(0.4)
.textAlign("center")
})
.flex(1)
.height(100, pct)
.alignItems("stretch")
.justifyContent("flex-end")
.boxSizing("border-box")
})
})
.flex(1)
.alignItems("flex-end")
.gap(0.3, em)
HStack(() => {
HStack(() => {
VStack(() => {}).width(8, px).height(8, px).borderRadius(50, pct).background("var(--quillred)").flexShrink(0)
p("Donations").margin(0).fontSize(0.65, em).color("var(--headertext)").opacity(0.45)
}).gap(0.3, em).alignItems("center")
HStack(() => {
VStack(() => {}).width(8, px).height(8, px).borderRadius(50, pct).background("#3b82f6").flexShrink(0)
p("Subscriptions").margin(0).fontSize(0.65, em).color("var(--headertext)").opacity(0.45)
}).gap(0.3, em).alignItems("center")
})
.gap(0.85, em)
.marginTop(0.55, em)
.flexShrink(0)
})
.padding(1, em)
.background("var(--darkaccent)")
.border("1px solid var(--divider)")
.borderRadius(0.6, em)
.flex(1)
.minWidth(0)
.height(160, px)
.boxSizing("border-box")
.flexShrink(0)
}
renderTable() {
const txs = this.transactions
const COLS = [
{ label: "Date", key: "created", width: "140px" },
{ label: "Name", key: "name", width: "1fr" },
{ label: "Email", key: "email", width: "220px" },
{ label: "Type", key: "type", width: "130px" },
{ label: "Product", key: "product", width: "160px" },
{ label: "Amount", key: "amount", width: "110px" },
{ label: "Status", key: "status", width: "100px" },
]
const gridCols = COLS.map(c => c.width).join(" ")
VStack(() => {
// Header row
HStack(() => {
COLS.forEach(col => {
const isSort = this.sortKey === col.key
HStack(() => {
p(col.label)
.margin(0).fontSize(0.7, em).fontWeight("700").letterSpacing("0.05em")
.color("var(--headertext)").opacity(isSort ? 0.9 : 0.38)
if (isSort) {
p(this.sortDir === "asc" ? "↑" : "↓")
.margin(0).marginLeft(0.2, em).fontSize(0.65, em)
.color("var(--headertext)").opacity(0.5)
}
})
.alignItems("center")
.cursor("pointer")
.onClick((done) => {
if(!done) return
if (this.sortKey === col.key) this.sortDir = this.sortDir === "asc" ? "desc" : "asc"
else { this.sortKey = col.key; this.sortDir = "desc" }
this.rerender()
})
})
})
.attr({ style: `display: grid; grid-template-columns: ${gridCols};` })
.paddingHorizontal(1.25, em)
.paddingVertical(0.65, em)
.borderBottom("1px solid var(--divider)")
.flexShrink(0)
// Body
VStack(() => {
if (txs.length === 0) {
VStack(() => {
p("No transactions match your filters")
.margin(0).fontSize(0.9, em)
.color("var(--headertext)").opacity(0.4)
.textAlign("center")
})
.flex(1).justifyContent("center").alignItems("center")
} else {
txs.forEach((tx, i) => {
HStack(() => {
p(this.formatDate(tx.created))
.margin(0).fontSize(0.78, em)
.color("var(--headertext)").opacity(0.65)
p(tx.name)
.margin(0).fontSize(0.82, em).color("var(--headertext)")
.overflow("hidden").whiteSpace("nowrap").textOverflow("ellipsis")
.minWidth(0)
p(tx.email)
.margin(0).fontSize(0.75, em)
.color("var(--headertext)").opacity(0.5)
.overflow("hidden").whiteSpace("nowrap").textOverflow("ellipsis")
.minWidth(0)
HStack(() => {
p(tx.type === "donation" ? "Donation" : "Subscription")
.margin(0).fontSize(0.7, em).fontWeight("600")
.color(tx.type === "donation" ? "var(--quillred)" : "#3b82f6")
.background(tx.type === "donation" ? "rgba(159,28,41,0.12)" : "rgba(59,130,246,0.12)")
.paddingHorizontal(0.55, em).paddingVertical(0.18, em)
.borderRadius(100, px).whiteSpace("nowrap")
})
p(tx.product)
.margin(0).fontSize(0.78, em)
.color("var(--headertext)").opacity(0.65)
.overflow("hidden").whiteSpace("nowrap").textOverflow("ellipsis")
.minWidth(0)
p(this.fmt(tx.amount))
.margin(0).fontSize(0.85, em).fontWeight("600")
.color("var(--headertext)")
p(tx.status === "active" ? "Active" : tx.status === "complete" ? "Complete" : "Inactive")
.margin(0).fontSize(0.72, em).fontWeight("500")
.color(tx.status === "inactive" ? "var(--headertext)" : "#10b981")
.opacity(tx.status === "inactive" ? 0.35 : 1)
})
.attr({ style: `display: grid; grid-template-columns: ${gridCols}; align-items: center;` })
.paddingHorizontal(1.25, em)
.paddingVertical(0.65, em)
.background(i % 2 === 1 ? "var(--darkaccent)" : "transparent")
.borderBottom("1px solid var(--divider)")
})
}
})
.flex(1)
.overflowY("auto")
})
.flex(1)
.minHeight(0)
.overflow("hidden")
}
formatDate(time) {
return new Intl.DateTimeFormat("en-US", { month: "short", day: "numeric", year: "numeric" }).format(new Date(time))
}
fmt(amount) {
return "$" + Number(amount).toLocaleString("en-US", { minimumFractionDigits: 0, maximumFractionDigits: 0 })
}
}
register(Donations)