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

306 lines
11 KiB
JavaScript

import donationServer from "/donations/@server/donations.js"
import "/_/code/components/LoadingCircle.js"
css(`
donations- {
font-family: 'Arial';
scrollbar-width: none;
-ms-overflow-style: none;
}
donations-::-webkit-scrollbar { display: none; }
donations- input::placeholder {
color: var(--headertext);
opacity: 0.35;
}
`)
class Donations extends Shadow {
donations = []
subscriptions = []
typeFilter = "all"
searchText = ""
searchOpen = false
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].sort((a, b) => new Date(b.created) - new Date(a.created))
}
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)
)
}
return all
}
get stats() {
const all = this.allTransactions
const totalRevenue = all.reduce((s, t) => s + t.amount, 0)
const now = new Date()
const monthRevenue = all
.filter(t => {
const d = new Date(t.created)
return d.getMonth() === now.getMonth() && d.getFullYear() === now.getFullYear()
})
.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 }
}
render() {
VStack(() => {
this.renderHeader()
this.renderStats()
this.renderFilters()
this.renderList()
})
.height(100, pct)
.width(100, pct)
.maxWidth(100, vw)
.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 || []
this.rerender()
}
})
}
renderHeader() {
if (this.searchOpen) {
HStack(() => {
input(this.searchText, "100%")
.paddingHorizontal(0.75, em)
.paddingVertical(0.5, em)
.background("var(--darkaccent)")
.border("1px solid var(--divider)")
.borderRadius(0.65, em)
.fontSize(0.9, em)
.color("var(--headertext)")
.outline("none")
.attr({ autofocus: "true" })
.onInput(v => { this.searchText = v; this.rerender() })
})
.paddingHorizontal(1.1, em)
.paddingBottom(0.65, em)
.flexShrink(0)
}
}
renderStats() {
const s = this.stats
const cards = [
{ label: "Total", value: this.fmt(s.totalRevenue), icon: "📊" },
{ label: "This Month", value: this.fmt(s.monthRevenue), icon: "📅" },
{ label: "MRR", value: this.fmt(s.mrr), icon: "🔄" },
{ label: "Donors", value: String(s.donorCount), icon: "👥" },
{ label: "Active Subs", value: String(s.activeSubs), icon: "💰" },
]
HStack(() => {
cards.forEach(c => {
VStack(() => {
p(c.icon).margin(0).fontSize(1, em).lineHeight("1")
p(c.value)
.margin(0).marginTop(0.35, em)
.fontSize(1.05, em).fontWeight("700")
.color("var(--headertext)").lineHeight("1")
p(c.label)
.margin(0).marginTop(0.22, em)
.fontSize(0.62, em)
.color("var(--headertext)").opacity(0.42)
})
.padding(0.8, em)
.background("var(--darkaccent)")
.border("1px solid var(--divider)")
.borderRadius(0.75, em)
.alignItems("center")
.minWidth(80, px)
.flexShrink(0)
})
})
.paddingHorizontal(1.1, em)
.paddingBottom(0.75, em)
.gap(0.6, em)
.overflowX("auto")
.flexShrink(0)
}
renderFilters() {
HStack(() => {
this.filterTab("All", "all")
this.filterTab("Donations", "donations")
this.filterTab("Subscriptions", "subscriptions")
})
.paddingHorizontal(1.1, em)
.paddingBottom(0.65, em)
.gap(0.4, 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.9, em)
.paddingVertical(0.45, em)
.borderRadius(100, px)
.background(isActive ? "var(--darkaccent)" : "transparent")
.border(`1px solid ${isActive ? "var(--divider)" : "transparent"}`)
.cursor("pointer")
.onTouch((start) => {
if (!start) {
this.typeFilter = value
this.rerender()
}
})
}
renderList() {
const txs = this.transactions
VStack(() => {
if (this.donations.length === 0 && this.subscriptions.length === 0) {
LoadingCircle()
} else if (txs.length === 0) {
VStack(() => {
p("No transactions match your filters")
.margin(0).fontSize(0.9, em)
.color("var(--headertext)").opacity(0.38)
.textAlign("center")
}).flex(1).justifyContent("center").alignItems("center")
} else {
txs.forEach(tx => this.renderCard(tx))
}
})
.flex(1)
.minHeight(0)
.overflowY("auto")
.paddingHorizontal(1.1, em)
.paddingBottom(1.5, em)
.gap(0.6, em)
}
renderCard(tx) {
VStack(() => {
HStack(() => {
// Name + type badge
VStack(() => {
p(tx.name)
.margin(0)
.fontSize(0.92, em)
.fontWeight("600")
.color("var(--headertext)")
.overflow("hidden")
.whiteSpace("nowrap")
.textOverflow("ellipsis")
.maxWidth(180, px)
p(tx.email)
.margin(0).marginTop(0.18, em)
.fontSize(0.72, em)
.color("var(--headertext)").opacity(0.45)
.overflow("hidden")
.whiteSpace("nowrap")
.textOverflow("ellipsis")
.maxWidth(180, px)
})
.flex(1).minWidth(0).alignItems("flex-start")
// Amount + status
VStack(() => {
p(this.fmt(tx.amount))
.margin(0)
.fontSize(1, em).fontWeight("700")
.color("var(--headertext)")
.textAlign("right")
p(tx.status === "active" ? "Active" : tx.status === "complete" ? "Complete" : "Inactive")
.margin(0).marginTop(0.2, em)
.fontSize(0.68, em).fontWeight("500")
.color(tx.status === "inactive" ? "var(--headertext)" : "#10b981")
.opacity(tx.status === "inactive" ? 0.35 : 1)
.textAlign("right")
})
.alignItems("flex-end").flexShrink(0)
})
.alignItems("flex-start")
HStack(() => {
// Type badge
p(tx.type === "donation" ? "Donation" : tx.product)
.margin(0)
.fontSize(0.68, 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.2, em)
.borderRadius(100, px)
.whiteSpace("nowrap")
p(this.formatDate(tx.created))
.margin(0)
.fontSize(0.72, em)
.color("var(--headertext)").opacity(0.38)
.flex(1).textAlign("right")
})
.marginTop(0.65, em)
.alignItems("center")
.gap(0.5, em)
})
.padding(0.95, em)
.background("var(--darkaccent)")
.border("1px solid var(--divider)")
.borderRadius(0.75, em)
}
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: 2, maximumFractionDigits: 2 })
}
}
register(Donations)